Why: a crash after a tool was about to run left the call unpaired, and workspace writes could not be rewound. Evidence: GOWORK=off go test ./runtime/ ./runtime/llm/ ./runtime/checkpoint/ ./runtime/telemetry/ ./runtime/tools/ ./runtime/session/ ./adapters/native/ Impact: pending calls rerun only when both the stored and current replay declarations are safe; otherwise the result is an error. Empty fallback lists and a nil checkpoint log keep the previous behavior. Refs: feature/durable-rewind Co-authored-by: Cursor <cursoragent@cursor.com>
168 lines
4.4 KiB
Go
168 lines
4.4 KiB
Go
package llm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client calls an OpenAI-compatible chat completions endpoint with streaming.
|
|
type Client struct {
|
|
BaseURL string
|
|
APIKey string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// Stream implements StreamFunc. Transport and provider failures are encoded as
|
|
// KindError events; the returned channel is always non-nil and is closed.
|
|
func (c *Client) Stream(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
|
|
out := make(chan Event)
|
|
go func() {
|
|
defer close(out)
|
|
c.run(ctx, model, messages, tools, opts, out)
|
|
}()
|
|
return out
|
|
}
|
|
|
|
func (c *Client) run(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions, out chan<- Event) {
|
|
base := strings.TrimRight(c.BaseURL, "/")
|
|
if opts.APIBase != "" {
|
|
base = strings.TrimRight(opts.APIBase, "/")
|
|
}
|
|
key := c.APIKey
|
|
if opts.APIKey != "" {
|
|
key = opts.APIKey
|
|
}
|
|
if base == "" || key == "" || model == "" {
|
|
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
|
|
return
|
|
}
|
|
body := map[string]any{
|
|
"model": model,
|
|
"messages": toProviderMessages(messages),
|
|
"stream": true,
|
|
"stream_options": map[string]any{
|
|
"include_usage": true,
|
|
},
|
|
}
|
|
if opts.MaxTokens > 0 {
|
|
body["max_tokens"] = opts.MaxTokens
|
|
}
|
|
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
|
body["reasoning_effort"] = effort
|
|
}
|
|
if len(tools) > 0 {
|
|
body["tools"] = toProviderTools(tools)
|
|
body["tool_choice"] = "auto"
|
|
}
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
emitError(out, "", false, err.Error())
|
|
return
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(raw))
|
|
if err != nil {
|
|
emitError(out, "", false, err.Error())
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+key)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
hc := c.HTTPClient
|
|
if hc == nil {
|
|
hc = &http.Client{Timeout: 30 * time.Minute}
|
|
}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
emitAbort(out, "", ctx.Err())
|
|
return
|
|
}
|
|
emitError(out, "", true, err.Error())
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
|
|
ev := Event{
|
|
Kind: KindError,
|
|
HTTPStatus: resp.StatusCode,
|
|
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
|
|
ErrorMessage: msg,
|
|
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
|
|
Message: Message{Role: RoleAssistant, StopReason: StopError},
|
|
}
|
|
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
|
|
ev.HasRetryAfter = true
|
|
ev.RetryAfter = d
|
|
}
|
|
out <- ev
|
|
return
|
|
}
|
|
for ev := range ParseSSE(ctx, resp.Body) {
|
|
out <- ev
|
|
}
|
|
}
|
|
|
|
func toProviderMessages(messages []Message) []map[string]any {
|
|
out := make([]map[string]any, 0, len(messages))
|
|
for _, m := range messages {
|
|
switch m.Role {
|
|
case RoleTool:
|
|
out = append(out, map[string]any{
|
|
"role": "tool",
|
|
"tool_call_id": m.ToolCallID,
|
|
"content": m.Content,
|
|
})
|
|
case RoleAssistant:
|
|
item := map[string]any{"role": "assistant", "content": m.Content}
|
|
if len(m.ToolCalls) > 0 {
|
|
calls := make([]map[string]any, 0, len(m.ToolCalls))
|
|
for _, tc := range m.ToolCalls {
|
|
calls = append(calls, map[string]any{
|
|
"id": tc.ID,
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": tc.Name,
|
|
"arguments": tc.Arguments,
|
|
},
|
|
})
|
|
}
|
|
item["tool_calls"] = calls
|
|
}
|
|
out = append(out, item)
|
|
default:
|
|
out = append(out, map[string]any{
|
|
"role": string(m.Role),
|
|
"content": messageContent(m),
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func toProviderTools(tools []ToolDecl) []map[string]any {
|
|
out := make([]map[string]any, 0, len(tools))
|
|
for _, t := range tools {
|
|
params := t.Parameters
|
|
if len(params) == 0 {
|
|
params = json.RawMessage(`{"type":"object","properties":{}}`)
|
|
}
|
|
out = append(out, map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": t.Name,
|
|
"description": t.Description,
|
|
"parameters": json.RawMessage(params),
|
|
},
|
|
})
|
|
}
|
|
return out
|
|
}
|