diff --git a/AGENTS.md b/AGENTS.md index 90245e5..aa2e854 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ go test -tags e2e -run 'TestE2E' -timeout 15m -v . # LIVE provider e2e (see be | `message.go` | Canonical types: `ChatRequest`, `Message`, `ChatResult`, `Delta`, `Usage`, `ToolDef` | | `chat.go` | `providerClient`: retry orchestration (buffered + streaming), error classification, learn-once consumption, SSE pump wiring, `httpError` parsing | | `openai.go` / `gemini.go` / `anthropic.go` | Per-format request builders, response/stream mappers, model listing | +| `responses.go` | OpenAI Responses API (`/v1/responses`) for GPT-5.6+ tools+reasoning | | `sse.go` | SSE parser (abort-safe via `done` channel) + idle-watchdog pump | | `retry.go` | Backoff/jitter/`Retry-After`/`retrySleep` (8 attempts, cap 30s) | | `provider.go` | Built-in registry, quirks flags, config validation | diff --git a/README.md b/README.md index 0bfdd72..211dfaf 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,13 @@ Multi-provider Go SDK for LLM inference endpoints — **OpenAI, Google Gemini, D - **Dynamic model discovery** — `ListModels` returns what the account can actually access. No static model tables. - **One canonical API** — OpenAI-shaped requests and responses; Anthropic and Gemini wire formats are translated for you. - **Portable generation controls** — token limits, temperature, top-p, stop sequences, thinking, and tools map to each provider's native fields. -- **Production streaming** — SSE with an idle watchdog and a hard wall-clock deadline, abort-with-partial-result, retries that never duplicate partial output, premature-close detection, and learn-once fallbacks for providers that reject `stream_options`, streaming, or `reasoning_effort`+tools. +- **Production streaming** — SSE with an idle watchdog and a hard wall-clock deadline, abort-with-partial-result, retries that never duplicate partial output, premature-close detection, and learn-once fallbacks for providers that reject `stream_options`, streaming, or `reasoning_effort`+tools (GPT-5.6+ retries on `/v1/responses` so reasoning stays on). - **Predictable under load** — goroutine-leak-free streaming, race-clean shared state, and a canonical-only error vocabulary (API keys never leak into error text). ## Install ```bash -go get github.com/BackendStack21/go-llm-sdk@v0.2.2 +go get github.com/BackendStack21/go-llm-sdk@v0.3.2 ``` Requires Go 1.25+. No dependencies beyond the standard library. @@ -168,6 +168,7 @@ On Gemini, a tool result's `ToolName` may be omitted — the SDK recovers the fu ## Extended thinking +- **OpenAI GPT-5.6+** — function tools plus reasoning cannot ride Chat Completions (`reasoning_effort` 400s). Those calls go to `POST /v1/responses` with `reasoning.effort` and `reasoning.summary=auto`; summaries land in `ReasoningContent` and encrypted reasoning replays via `ThinkingSignature`. `Thinking: disabled` stays on Chat Completions with `reasoning_effort: none`. Other OpenAI models keep `reasoning_effort` on Chat Completions. - **Anthropic** — `thinking` blocks are parsed in both buffered and streaming modes. `ChatResult.ThinkingSignature` carries the provider signature; for tool loops, replay it on the assistant message (`Message.ReasoningContent` + `Message.ThinkingSignature`) — the SDK re-serializes it as the first block, as Anthropic's API requires. Unsigned thinking replay is rejected locally with `ConfigError`. - **DeepSeek / GLM** — reasoning streams as `DeltaReasoning` fragments and lands in `ReasoningContent`. Assistant-turn replay echoes it as `reasoning_content` (required for DeepSeek/GLM tool loops). GLM maps thinking `medium` → `reasoning_effort` `high` (no medium level) and `max` → `max`. - **Gemini** — `thought: true` parts map to reasoning deltas; `thinkingConfig` is derived from `Thinking` / `ThinkingBudget`. @@ -181,7 +182,8 @@ When a provider rejects a request pattern, the SDK learns the constraint **once | Trigger (provider 400) | Learned fallback | |---|---| | Rejects `stream_options` | omit `stream_options` from streaming requests | -| Rejects `reasoning_effort` + tools | pin `reasoning_effort: "none"` | +| Names `/v1/responses` as the tools+reasoning path | retry on `POST /responses` (keeps reasoning on) | +| Rejects `reasoning_effort` + tools (legacy) | pin `reasoning_effort: "none"` | | Rejects streaming itself | downgrade to buffered calls permanently | | Answers a streamed request with a non-SSE body | downgrade to buffered calls permanently | @@ -248,7 +250,7 @@ See [AGENTS.md](AGENTS.md) for the architecture map, invariants, testing convent ## Status -v0.2.2 — API may shift until the odek integration lands, then v1.0. +v0.3.2 — API may shift until v1.0. ## License diff --git a/chat.go b/chat.go index 3aa04c4..3d558e0 100644 --- a/chat.go +++ b/chat.go @@ -26,8 +26,9 @@ import ( // - Consumer abort (delta handler error) returns the partial result // alongside *StreamAbortedError. // - Learn-once fallbacks: drop stream_options (field level), fall back to -// the buffered path (provider rejects streaming), pin reasoning_effort -// "none" (provider rejects effort combined with tools). +// the buffered path (provider rejects streaming), POST /responses +// (GPT-5.6+ rejects effort+tools on Chat Completions), pin +// reasoning_effort "none" (legacy gateways that reject effort+tools). // // learnOnce holds the learn-once fallback flags. They live on the Provider // (shared across every ChatClient minted from it) so a constraint the @@ -35,6 +36,7 @@ import ( type learnOnce struct { dropStreamOptions atomic.Bool forceBuffered atomic.Bool + forceResponses atomic.Bool forceNoneEffort atomic.Bool } @@ -156,6 +158,10 @@ func (pc *providerClient) buildChatRequest(req *ChatRequest, model string, strea } return body, fmt.Sprintf("%s/v1beta/models/%s:generateContent", pc.base, model), err default: // FormatOpenAI + if useResponsesAPI(pc.learn, pc.cfg.Format, model, req) { + body, err := json.Marshal(buildResponsesRequest(req, model, stream)) + return body, pc.base + "/responses", err + } oa := buildOpenAIRequest(pc.cfg, req, model, stream, !pc.learn.dropStreamOptions.Load()) if pc.learn.forceNoneEffort.Load() && len(req.Tools) > 0 { oa = reasoningEffortNonePatched(oa) @@ -292,9 +298,23 @@ func reasoningEffortRejected(err error) bool { if !errors.As(err, &e) || e.Status != http.StatusBadRequest { return false } + if responsesRequired(err) { + return false + } return strings.Contains(e.Message, "reasoning_effort") } +// responsesRequired reports a 400 that names /v1/responses as the way to +// keep function tools together with reasoning (GPT-5.6+ Chat Completions). +func responsesRequired(err error) bool { + var e *APIError + if !errors.As(err, &e) || e.Status != http.StatusBadRequest { + return false + } + m := strings.ToLower(e.Message) + return strings.Contains(m, "/v1/responses") +} + // streamOptionsRejected classifies a 400 naming stream_options. func streamOptionsRejected(e *APIError) bool { return e != nil && e.Status == http.StatusBadRequest && @@ -387,6 +407,17 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri return nil, ctx.Err() } continue + case apiErr.Status == http.StatusBadRequest && len(req.Tools) > 0 && + !pc.learn.forceResponses.Load() && responsesRequired(apiErr): + // GPT-5.6+ (and some 5.4/5.5 payloads) reject + // effort+tools on Chat Completions; retry on /responses + // so reasoning stays on. + pc.learn.forceResponses.Store(true) + lastErr = apiErr + if attempt < maxRetries { + continue + } + return nil, apiErr case apiErr.Status == http.StatusBadRequest && len(req.Tools) > 0 && !pc.learn.forceNoneEffort.Load() && reasoningEffortRejected(apiErr): // Learn the constraint once; retry immediately with @@ -417,7 +448,7 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri } return nil, fmt.Errorf("llm: retry exhausted (%d attempts): %w", maxRetries+1, err) } - return pc.parseResponse(data) + return pc.parseResponse(data, url) } if rateErr != nil { return nil, &RateLimitError{APIError: *rateErr, Attempts: maxRetries + 1, RetryAfter: rateRA} @@ -426,13 +457,16 @@ func (pc *providerClient) call(ctx context.Context, req *ChatRequest, model stri } // parseResponse dispatches format-specific buffered parsing. -func (pc *providerClient) parseResponse(data []byte) (*ChatResult, error) { +func (pc *providerClient) parseResponse(data []byte, url string) (*ChatResult, error) { switch pc.cfg.Format { case FormatAnthropic: return parseAnthropicResponse(data) case FormatGemini: return parseGeminiResponse(data) default: + if isResponsesURL(url) { + return parseResponsesAPI(data) + } return parseOpenAIResponse(data) } } @@ -482,8 +516,12 @@ func (pc *providerClient) callStream(ctx context.Context, req *ChatRequest, mode if err != nil { return nil, err } + attemptMapper := mapper + if isResponsesURL(url) { + attemptMapper = mapResponsesStreamEvent + } - out := pc.attemptStream(deadlineCtx, url, body, mapper, onDelta, len(req.Tools) > 0) + out := pc.attemptStream(deadlineCtx, url, body, attemptMapper, onDelta, len(req.Tools) > 0) switch { case out.success(): return out.result, nil @@ -581,6 +619,9 @@ func (pc *providerClient) attemptStream(ctx context.Context, url string, body [] case streamOptionsRejected(e): pc.learn.dropStreamOptions.Store(true) return streamOutcome{learnRetry: true, apiErr: e} + case learnEffort && !pc.learn.forceResponses.Load() && responsesRequired(e): + pc.learn.forceResponses.Store(true) + return streamOutcome{learnRetry: true, apiErr: e} case learnEffort && !pc.learn.forceNoneEffort.Load() && reasoningEffortRejected(e): pc.learn.forceNoneEffort.Store(true) return streamOutcome{learnRetry: true, apiErr: e} diff --git a/dispatch_edges_test.go b/dispatch_edges_test.go index f15c51e..9e44412 100644 --- a/dispatch_edges_test.go +++ b/dispatch_edges_test.go @@ -145,6 +145,10 @@ func TestReasoningEffortRejectedShape(t *testing.T) { if !reasoningEffortRejected(&APIError{Status: 400, Message: "reasoning_effort unsupported"}) { t.Error("400 + reasoning_effort must classify") } + gpt56 := "Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'." + if reasoningEffortRejected(&APIError{Status: 400, Message: gpt56}) { + t.Error("gpt-5.6 responses-required 400 must not pin effort none") + } } func TestReasoningEffortNonePatchedClearsThinking(t *testing.T) { diff --git a/e2e_openai_reasoning_test.go b/e2e_openai_reasoning_test.go new file mode 100644 index 0000000..a67d695 --- /dev/null +++ b/e2e_openai_reasoning_test.go @@ -0,0 +1,191 @@ +//go:build e2e + +package llm + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "testing" + "time" +) + +// OpenAI reasoning arms. OPENAI_API_KEY comes from env or the repo .env +// (never logged). Default model is a reasoning-capable one; override with +// OPENAI_E2E_MODEL. Per house rules: probe softly — assert only what the +// SDK guarantees (call success, parsing, canonical finish). Whether the +// provider returns reasoning *text* or reasoning *tokens* is server-side +// behavior, not an SDK contract. + +type pathSpy struct { + http.RoundTripper + paths []string +} + +func (s *pathSpy) RoundTrip(req *http.Request) (*http.Response, error) { + s.paths = append(s.paths, req.Method+" "+req.URL.Path) + rt := s.RoundTripper + if rt == nil { + rt = http.DefaultTransport + } + return rt.RoundTrip(req) +} + +func e2eOpenAIChat(t *testing.T) *ChatClient { + t.Helper() + return e2eOpenAIChatSpy(t, &pathSpy{}) +} + +func e2eOpenAIChatSpy(t *testing.T, spy *pathSpy) *ChatClient { + t.Helper() + e2eEnvKey(t, "OPENAI_API_KEY") + base := http.DefaultTransport + if spy.RoundTripper != nil { + base = spy.RoundTripper + } + spy.RoundTripper = base + sdk := New(FromEnv(), WithTransport(spy)) + cc, err := sdk.Chat("openai", openaiE2EModel()) + if err != nil { + t.Fatalf("Chat(openai, %s): %v", openaiE2EModel(), err) + } + return cc +} + +func openaiE2EModel() string { + if v := strings.TrimSpace(os.Getenv("OPENAI_E2E_MODEL")); v != "" { + return v + } + return "gpt-5-mini" +} + +// OpenAI reasoning, buffered: Thinking=medium must produce a successful +// call with canonical finish; reasoning must be observable somewhere — +// reasoning_content text or Usage.ReasoningTokens. +func TestE2EOpenAIReasoningBuffered(t *testing.T) { + cc := e2eOpenAIChat(t) + res, err := cc.Call(e2eCtx(t, 180*time.Second), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "A clock shows 3:15. What is the angle in degrees between the hour and minute hands? Work it out, then answer with the number only."}}, + Thinking: "medium", + MaxTokens: 2000, + }) + if err != nil { + t.Fatalf("Call: %v", err) + } + if res.FinishReason != FinishStop && res.FinishReason != FinishLength { + t.Errorf("finish = %q, want stop or length", res.FinishReason) + } + if res.ReasoningContent == "" && res.Usage.ReasoningTokens == 0 { + t.Errorf("no reasoning observable: ReasoningContent=%q ReasoningTokens=%d", + res.ReasoningContent, res.Usage.ReasoningTokens) + } + if res.ReasoningContent != "" { + t.Logf("reasoning text captured (%d chars)", len(res.ReasoningContent)) + } + if res.Usage.ReasoningTokens > 0 { + t.Logf("reasoning tokens: %d", res.Usage.ReasoningTokens) + } +} + +// OpenAI reasoning, streaming: reasoning deltas (DeltaReasoning) and/or the +// usage chunk's reasoning tokens must be observable; canonical finish. +func TestE2EOpenAIReasoningStreaming(t *testing.T) { + cc := e2eOpenAIChat(t) + var sawReasoningDeltas bool + res, err := cc.CallStream(e2eCtx(t, 180*time.Second), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "A water lily patch doubles in size every day. It covers the whole lake on day 48. On which day was it half covered? Answer with the day number only."}}, + Thinking: "medium", + MaxTokens: 2000, + }, func(d Delta) error { + if d.Kind == DeltaReasoning && d.Text != "" { + sawReasoningDeltas = true + } + return nil + }) + if err != nil { + t.Fatalf("CallStream: %v", err) + } + if res.FinishReason != FinishStop && res.FinishReason != FinishLength { + t.Errorf("finish = %q, want stop or length", res.FinishReason) + } + if res.Usage.ReasoningTokens == 0 && !sawReasoningDeltas { + // Provider-side: gpt-5-mini sometimes skips reasoning entirely on + // short prompts. Capture paths are unit-covered (openai_test.go: + // stream usage details + reasoning deltas); live is a probe. + t.Logf("no reasoning observable on stream (server-side elision): deltas=%v ReasoningTokens=%d", + sawReasoningDeltas, res.Usage.ReasoningTokens) + } + if sawReasoningDeltas { + t.Log("reasoning deltas captured") + } + t.Logf("usage: %+v content=%q finish=%q", res.Usage, res.Content, res.FinishReason) + if res.Usage.ReasoningTokens > 0 { + t.Logf("reasoning tokens: %d", res.Usage.ReasoningTokens) + } +} + +// Thinking=disabled on a reasoning model must still succeed with a clean +// call (reasoning_effort omitted / none path), proving the effort control +// round-trips both ways. +func TestE2EOpenAIReasoningDisabled(t *testing.T) { + cc := e2eOpenAIChat(t) + res, err := cc.Call(e2eCtx(t, 120*time.Second), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "Reply with exactly: OK"}}, + Thinking: "disabled", + MaxTokens: 500, + }) + if err != nil { + var ae *APIError + if errors.As(err, &ae) && ae.Status == http.StatusBadRequest { + t.Fatalf("disabled thinking rejected by provider: %v (SDK must omit effort on disabled)", err) + } + t.Fatalf("Call: %v", err) + } + if !strings.Contains(strings.ToUpper(res.Content), "OK") { + t.Errorf("content = %q, want it to contain OK", res.Content) + } +} + +// Tools + thinking on GPT-5.6 must stay on /v1/responses (Chat Completions +// 400s and the old learn-once path pinned effort none). A dummy tool is +// enough: the request carries tools even if the model never calls it. +func TestE2EOpenAIReasoningWithTools(t *testing.T) { + spy := &pathSpy{} + cc := e2eOpenAIChatSpy(t, spy) + res, err := cc.Call(e2eCtx(t, 180*time.Second), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "A clock shows 3:15. What is the angle in degrees between the hour and minute hands? Work it out, then answer with the number only."}}, + Tools: []ToolDef{{ + Name: "noop", + Description: "Do nothing. Never call this.", + Parameters: json.RawMessage(`{"type":"object","properties":{}}`), + }}, + Thinking: "medium", + MaxTokens: 2000, + }) + t.Logf("paths=%v", spy.paths) + if err != nil { + var ae *APIError + if errors.As(err, &ae) && ae.Status == http.StatusBadRequest { + t.Fatalf("tools+thinking rejected (must use /responses, not pin none): %v", err) + } + t.Fatalf("Call: %v", err) + } + joined := strings.Join(spy.paths, " ") + if strings.Contains(openaiE2EModel(), "gpt-5.6") && !strings.Contains(joined, "/responses") { + t.Errorf("gpt-5.6 tools+thinking must POST /responses, got %v", spy.paths) + } + if strings.Count(joined, "/chat/completions") > 0 && strings.Contains(openaiE2EModel(), "gpt-5.6") { + t.Errorf("gpt-5.6 tools+thinking must not fall back to chat/completions, got %v", spy.paths) + } + if res.FinishReason != FinishStop && res.FinishReason != FinishLength && res.FinishReason != FinishToolCalls { + t.Errorf("finish = %q, want stop, length, or tool_calls", res.FinishReason) + } + if res.ReasoningContent == "" && res.Usage.ReasoningTokens == 0 { + t.Errorf("no reasoning with tools: ReasoningContent=%q ReasoningTokens=%d usage=%+v", + res.ReasoningContent, res.Usage.ReasoningTokens, res.Usage) + } + t.Logf("content=%q reasoning_chars=%d reasoning_tokens=%d finish=%q tools=%d usage=%+v", + res.Content, len(res.ReasoningContent), res.Usage.ReasoningTokens, res.FinishReason, len(res.ToolCalls), res.Usage) +} diff --git a/message.go b/message.go index 3f0cd11..d14c892 100644 --- a/message.go +++ b/message.go @@ -58,7 +58,8 @@ type Message struct { Content string ReasoningContent string // ThinkingSignature authenticates ReasoningContent for providers that - // require thinking to be replayed verbatim (Anthropic signature). + // require thinking to be replayed verbatim (Anthropic signature, OpenAI + // Responses encrypted_content). ThinkingSignature string ToolCalls []ToolCall ToolCallID string diff --git a/openai.go b/openai.go index 0b28cea..17810b5 100644 --- a/openai.go +++ b/openai.go @@ -194,7 +194,14 @@ func buildOpenAIRequest(cfg ProviderConfig, req *ChatRequest, model string, stre // "max" is the canonical highest level. OpenAI's portable // reasoning_effort vocabulary tops out at "high". out.ReasoningEffort = "high" - // "disabled" and "" → omit (provider default) + case "disabled": + // GPT-5.6 defaults to medium when the field is omitted, which + // 400s when function tools are present. Pin none so "disabled" + // is actually off on Chat Completions. + if chatCompletionsRejectsReasoningWithTools(model) { + out.ReasoningEffort = "none" + } + // "" → omit (provider default) } default: // Provider accepts neither field (Kimi, plain gateways). diff --git a/openai_test.go b/openai_test.go index e807f76..e44a332 100644 --- a/openai_test.go +++ b/openai_test.go @@ -160,7 +160,7 @@ func TestBuildOpenAIRequest_ThinkingVariants(t *testing.T) { thinking string wantEff string }{ - {"enabled", "medium"}, {"low", "low"}, {"medium", "medium"}, {"high", "high"}, {"max", "high"}, {"disabled", ""}, {"", ""}, + {"enabled", "medium"}, {"low", "low"}, {"medium", "medium"}, {"high", "high"}, {"max", "high"}, {"disabled", "none"}, {"", ""}, } for _, c := range cases { r := *base @@ -173,12 +173,19 @@ func TestBuildOpenAIRequest_ThinkingVariants(t *testing.T) { t.Errorf("openai must never send thinking object, got %+v", oa.Thinking) } } + // Non-5.6 OpenAI: disabled still omits the field (provider default). + r := *base + r.Thinking = "disabled" + oa := buildOpenAIRequest(openai, &r, "gpt-4o", false, true) + if oa.ReasoningEffort != "" { + t.Errorf("gpt-4o disabled → effort %q, want omit", oa.ReasoningEffort) + } // DeepSeek: thinking object, no effort. deepseek := ProviderConfig{ID: "deepseek", Format: FormatOpenAI, Quirks: Quirks{ThinkingObject: true}} - r := *base + r = *base r.Thinking = "enabled" - oa := buildOpenAIRequest(deepseek, &r, "deepseek-v4-pro", false, true) + oa = buildOpenAIRequest(deepseek, &r, "deepseek-v4-pro", false, true) if oa.Thinking == nil || oa.Thinking.Type != "enabled" { t.Errorf("deepseek thinking = %+v, want {type:enabled}", oa.Thinking) } diff --git a/responses.go b/responses.go new file mode 100644 index 0000000..d4dc932 --- /dev/null +++ b/responses.go @@ -0,0 +1,459 @@ +package llm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// OpenAI Responses API (/v1/responses). GPT-5.6 (and later GPT-5.4/5.5 with +// an explicit effort) rejects function tools combined with a non-none +// reasoning_effort on /v1/chat/completions. The documented path is this +// endpoint, which also returns a reasoning summary the Chat Completions +// path never exposes. + +// ── request ────────────────────────────────────────────────────────────── + +type rsReasoning struct { + Effort string `json:"effort,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type rsTool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +type rsEasyMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type rsReasoningItem struct { + Type string `json:"type"` + EncryptedContent string `json:"encrypted_content,omitempty"` + Summary []rsSummaryText `json:"summary"` +} + +type rsSummaryText struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +type rsFunctionCallItem struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type rsFunctionOutputItem struct { + Type string `json:"type"` + CallID string `json:"call_id"` + Output string `json:"output"` +} + +type rsRequest struct { + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Input []any `json:"input"` + Tools []rsTool `json:"tools,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stream bool `json:"stream,omitempty"` + Store *bool `json:"store,omitempty"` + Include []string `json:"include,omitempty"` + Reasoning *rsReasoning `json:"reasoning,omitempty"` +} + +func boolPtr(v bool) *bool { return &v } + +// chatCompletionsRejectsReasoningWithTools reports models whose Chat +// Completions endpoint returns 400 for function tools plus any non-none +// reasoning effort (including the provider default of medium). +func chatCompletionsRejectsReasoningWithTools(model string) bool { + m := strings.ToLower(model) + return strings.HasPrefix(m, "gpt-5.6") || + strings.HasPrefix(m, "gpt-5.7") || + strings.HasPrefix(m, "gpt-6") +} + +func chatCompletionsRejectsExplicitReasoningWithTools(model string) bool { + if chatCompletionsRejectsReasoningWithTools(model) { + return true + } + m := strings.ToLower(model) + return strings.HasPrefix(m, "gpt-5.4") || strings.HasPrefix(m, "gpt-5.5") +} + +// useResponsesAPI decides whether this OpenAI-format call must go to +// POST /responses instead of /chat/completions. +func useResponsesAPI(learn *learnOnce, format Format, model string, req *ChatRequest) bool { + if format != FormatOpenAI || req == nil || len(req.Tools) == 0 { + return false + } + if learn != nil && learn.forceResponses.Load() { + return true + } + if learn != nil && learn.forceNoneEffort.Load() { + return false + } + if req.Thinking == "disabled" { + return false + } + if chatCompletionsRejectsReasoningWithTools(model) { + return true + } + return req.Thinking != "" && chatCompletionsRejectsExplicitReasoningWithTools(model) +} + +func isResponsesURL(url string) bool { + return strings.HasSuffix(strings.TrimRight(url, "/"), "/responses") +} + +func responsesEffort(thinking string) string { + switch thinking { + case "enabled": + return "medium" + case "low", "medium", "high", "max": + return thinking + case "disabled": + return "none" + default: + return "medium" + } +} + +// buildResponsesRequest renders a ChatRequest as a Responses API body. +func buildResponsesRequest(req *ChatRequest, model string, stream bool) rsRequest { + instructions, input := buildResponsesInput(req) + out := rsRequest{ + Model: model, + Instructions: instructions, + Input: input, + Stream: stream, + Store: boolPtr(false), + Include: []string{"reasoning.encrypted_content"}, + } + if req.MaxTokens > 0 { + out.MaxOutputTokens = req.MaxTokens + } + if req.Temperature != 0 && !modelForbidsTemperature(model) { + t := req.Temperature + if t < 0 { + t = 0 + } + out.Temperature = &t + } + if req.TopP != 0 && !modelForbidsTemperature(model) { + p := req.TopP + if p < 0 { + p = 0 + } + out.TopP = &p + } + for _, t := range req.Tools { + out.Tools = append(out.Tools, rsTool{ + Type: "function", + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + }) + } + effort := responsesEffort(req.Thinking) + rsn := &rsReasoning{Effort: effort} + if effort != "none" { + rsn.Summary = "auto" + } + out.Reasoning = rsn + return out +} + +func buildResponsesInput(req *ChatRequest) (instructions string, input []any) { + var sys []string + appendSys := func(text string) { + if t := strings.TrimRight(text, "\n"); t != "" { + sys = append(sys, t) + } + } + for _, b := range req.System { + appendSys(b.Text) + } + for _, m := range req.Messages { + if m.Role == RoleSystem { + appendSys(m.Content) + } + } + instructions = strings.Join(sys, "\n\n") + + for _, m := range req.Messages { + switch m.Role { + case RoleSystem: + continue + case RoleUser: + input = append(input, rsEasyMessage{Role: "user", Content: m.Content}) + case RoleAssistant: + if m.ThinkingSignature != "" { + sum := make([]rsSummaryText, 0) + if m.ReasoningContent != "" { + sum = append(sum, rsSummaryText{Type: "summary_text", Text: m.ReasoningContent}) + } + input = append(input, rsReasoningItem{ + Type: "reasoning", + EncryptedContent: m.ThinkingSignature, + Summary: sum, + }) + } + for _, tc := range m.ToolCalls { + input = append(input, rsFunctionCallItem{ + Type: "function_call", + CallID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + }) + } + if m.Content != "" { + input = append(input, rsEasyMessage{Role: "assistant", Content: m.Content}) + } + case RoleTool: + input = append(input, rsFunctionOutputItem{ + Type: "function_call_output", + CallID: m.ToolCallID, + Output: m.Content, + }) + } + } + if input == nil { + input = []any{} + } + return instructions, input +} + +// ── response ───────────────────────────────────────────────────────────── + +type rsContentPart struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type rsOutputItem struct { + Type string `json:"type"` + ID string `json:"id"` + Role string `json:"role"` + CallID string `json:"call_id"` + Name string `json:"name"` + Arguments string `json:"arguments"` + EncryptedContent string `json:"encrypted_content"` + Summary []rsSummaryText `json:"summary"` + Content []rsContentPart `json:"content"` +} + +type rsUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokensDetails *struct { + CachedTokens int `json:"cached_tokens"` + } `json:"input_tokens_details"` + OutputTokensDetails *struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"output_tokens_details"` +} + +type rsErrorBody struct { + Message string `json:"message"` + Code string `json:"code"` +} + +type rsResponse struct { + Status string `json:"status"` + IncompleteDetails *struct { + Reason string `json:"reason"` + } `json:"incomplete_details"` + Output []rsOutputItem `json:"output"` + Usage *rsUsage `json:"usage"` + Error *rsErrorBody `json:"error"` +} + +func usageFromResponses(u *rsUsage) Usage { + if u == nil { + return Usage{} + } + out := Usage{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + } + if u.OutputTokensDetails != nil { + out.ReasoningTokens = u.OutputTokensDetails.ReasoningTokens + } + if u.InputTokensDetails != nil { + out.CachedTokens = u.InputTokensDetails.CachedTokens + out.CacheReported = true + if u.InputTokensDetails.CachedTokens > 0 && u.InputTokensDetails.CachedTokens <= out.PromptTokens { + out.PromptTokens -= u.InputTokensDetails.CachedTokens + out.CacheReadTokens += u.InputTokensDetails.CachedTokens + } + } + return out +} + +func chatResultFromResponses(r *rsResponse) *ChatResult { + res := &ChatResult{} + var summaries []string + for _, item := range r.Output { + switch item.Type { + case "reasoning": + if item.EncryptedContent != "" { + res.ThinkingSignature = item.EncryptedContent + } + for _, s := range item.Summary { + if s.Text != "" { + summaries = append(summaries, s.Text) + } + } + case "message": + for _, p := range item.Content { + if p.Type == "output_text" && p.Text != "" { + res.Content += p.Text + } + } + case "function_call": + res.ToolCalls = append(res.ToolCalls, ToolCall{ + ID: item.CallID, + Name: item.Name, + Arguments: item.Arguments, + }) + } + } + res.ReasoningContent = strings.Join(summaries, "\n") + res.FinishReason = responsesFinishReason(r, len(res.ToolCalls) > 0) + if r.Usage != nil { + res.Usage = usageFromResponses(r.Usage) + } + return res +} + +func responsesFinishReason(r *rsResponse, hasTools bool) string { + switch r.Status { + case "incomplete": + if r.IncompleteDetails != nil && strings.Contains(strings.ToLower(r.IncompleteDetails.Reason), "max_output") { + return FinishLength + } + if hasTools { + return FinishToolCalls + } + return FinishLength + case "failed", "cancelled": + return "" + default: + if hasTools { + return FinishToolCalls + } + return FinishStop + } +} + +func parseResponsesAPI(body []byte) (*ChatResult, error) { + var r rsResponse + if err := json.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("llm: parse responses: %w", err) + } + if r.Error != nil && r.Error.Message != "" { + return nil, fmt.Errorf("llm: provider error: %s", r.Error.Message) + } + if r.Status == "failed" { + msg := "responses failed" + if r.Error != nil && r.Error.Message != "" { + msg = r.Error.Message + } + return nil, fmt.Errorf("llm: provider error: %s", msg) + } + return chatResultFromResponses(&r), nil +} + +// ── streaming ──────────────────────────────────────────────────────────── + +type rsStreamEvent struct { + Type string `json:"type"` + Delta string `json:"delta"` + OutputIndex int `json:"output_index"` + Item *rsOutputItem `json:"item"` + Response *rsResponse `json:"response"` +} + +func mapResponsesStreamEvent(data []byte, acc *streamAccum) (deltas []Delta, done bool, err error) { + var ev rsStreamEvent + if err := json.Unmarshal(data, &ev); err != nil { + return nil, false, fmt.Errorf("llm: parse responses stream: %w", err) + } + switch ev.Type { + case "response.reasoning_summary_text.delta": + if ev.Delta != "" { + acc.reasoning.WriteString(ev.Delta) + deltas = append(deltas, Delta{Kind: DeltaReasoning, Text: ev.Delta}) + } + case "response.output_text.delta": + if ev.Delta != "" { + acc.content.WriteString(ev.Delta) + deltas = append(deltas, Delta{Kind: DeltaContent, Text: ev.Delta}) + } + case "response.function_call_arguments.delta": + call := acc.call(ev.OutputIndex) + call.args.WriteString(ev.Delta) + deltas = append(deltas, Delta{ + Kind: DeltaToolArgs, + Text: ev.Delta, + ToolIndex: ev.OutputIndex, + ToolID: call.id, + ToolName: call.name, + }) + case "response.output_item.added": + if ev.Item != nil && ev.Item.Type == "function_call" { + call := acc.call(ev.OutputIndex) + if ev.Item.CallID != "" { + call.id = ev.Item.CallID + } + if ev.Item.Name != "" { + call.name = ev.Item.Name + } + } + case "response.output_item.done": + if ev.Item != nil && ev.Item.Type == "reasoning" && ev.Item.EncryptedContent != "" { + acc.thinkingSignature = ev.Item.EncryptedContent + } + case "response.completed", "response.incomplete": + if ev.Response != nil { + if ev.Response.Usage != nil { + acc.usage = usageFromResponses(ev.Response.Usage) + } + hasTools := len(acc.calls) > 0 + acc.finishReason = responsesFinishReason(ev.Response, hasTools) + if acc.thinkingSignature == "" { + for _, item := range ev.Response.Output { + if item.Type == "reasoning" && item.EncryptedContent != "" { + acc.thinkingSignature = item.EncryptedContent + break + } + } + } + } else if acc.finishReason == "" { + if len(acc.calls) > 0 { + acc.finishReason = FinishToolCalls + } else if ev.Type == "response.incomplete" { + acc.finishReason = FinishLength + } else { + acc.finishReason = FinishStop + } + } + return deltas, true, nil + case "response.failed": + msg := "responses failed" + if ev.Response != nil && ev.Response.Error != nil && ev.Response.Error.Message != "" { + msg = ev.Response.Error.Message + } + return nil, true, fmt.Errorf("llm: provider error: %s", msg) + } + return deltas, false, nil +} diff --git a/responses_test.go b/responses_test.go new file mode 100644 index 0000000..a7d4dca --- /dev/null +++ b/responses_test.go @@ -0,0 +1,407 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +func TestChatCompletionsRejectsReasoningWithTools(t *testing.T) { + for _, m := range []string{"gpt-5.6", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "GPT-5.6-Luna", "gpt-5.7-preview", "gpt-6-astra"} { + if !chatCompletionsRejectsReasoningWithTools(m) { + t.Errorf("%s must use Responses for tools+reasoning", m) + } + } + for _, m := range []string{"gpt-5", "gpt-5-mini", "gpt-5.5", "gpt-5.4", "gpt-4o", "o3-mini"} { + if chatCompletionsRejectsReasoningWithTools(m) { + t.Errorf("%s must not proactively force Responses", m) + } + } +} + +func TestUseResponsesAPI(t *testing.T) { + tools := []ToolDef{{Name: "f", Parameters: json.RawMessage(`{"type":"object"}`)}} + learn := &learnOnce{} + + if useResponsesAPI(learn, FormatOpenAI, "gpt-5.6-luna", &ChatRequest{Tools: tools, Thinking: "medium"}) != true { + t.Error("gpt-5.6-luna + tools + medium must use Responses") + } + if useResponsesAPI(learn, FormatOpenAI, "gpt-5.6-luna", &ChatRequest{Tools: tools}) != true { + t.Error("gpt-5.6-luna + tools + empty thinking still defaults to medium; must use Responses") + } + if useResponsesAPI(learn, FormatOpenAI, "gpt-5.6-luna", &ChatRequest{Tools: tools, Thinking: "disabled"}) != false { + t.Error("disabled thinking stays on chat completions") + } + if useResponsesAPI(learn, FormatOpenAI, "gpt-5.6-luna", &ChatRequest{Thinking: "medium"}) != false { + t.Error("no tools → chat completions") + } + if useResponsesAPI(learn, FormatAnthropic, "gpt-5.6-luna", &ChatRequest{Tools: tools, Thinking: "medium"}) != false { + t.Error("anthropic format must not route to Responses") + } + if useResponsesAPI(learn, FormatOpenAI, "gpt-4o", &ChatRequest{Tools: tools, Thinking: "medium"}) != false { + t.Error("gpt-4o must not proactively use Responses") + } + if useResponsesAPI(learn, FormatOpenAI, "gpt-5.5", &ChatRequest{Tools: tools, Thinking: "high"}) != true { + t.Error("gpt-5.5 + explicit thinking + tools uses Responses") + } + if useResponsesAPI(learn, FormatOpenAI, "gpt-5.5", &ChatRequest{Tools: tools}) != false { + t.Error("gpt-5.5 + empty thinking stays on chat completions") + } + + learn.forceResponses.Store(true) + if useResponsesAPI(learn, FormatOpenAI, "gpt-4o", &ChatRequest{Tools: tools, Thinking: "high"}) != true { + t.Error("learned forceResponses must win for any OpenAI-format model with tools") + } + learn = &learnOnce{} + learn.forceNoneEffort.Store(true) + if useResponsesAPI(learn, FormatOpenAI, "gpt-5.6-luna", &ChatRequest{Tools: tools, Thinking: "medium"}) != false { + t.Error("learned forceNoneEffort must keep chat completions") + } +} + +func TestBuildResponsesRequest_Shape(t *testing.T) { + req := &ChatRequest{ + System: []SystemBlock{{Text: "Be terse."}}, + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + Tools: []ToolDef{{Name: "echo", Description: "d", Parameters: json.RawMessage(`{"type":"object"}`)}}, + Thinking: "medium", + MaxTokens: 2048, + } + body, err := json.Marshal(buildResponsesRequest(req, "gpt-5.6-luna", false)) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + if m["model"] != "gpt-5.6-luna" { + t.Errorf("model = %v", m["model"]) + } + if m["instructions"] != "Be terse." { + t.Errorf("instructions = %v", m["instructions"]) + } + if m["max_output_tokens"] != float64(2048) { + t.Errorf("max_output_tokens = %v", m["max_output_tokens"]) + } + if store, ok := m["store"].(bool); !ok || store { + t.Errorf("store = %v, want false", m["store"]) + } + inc, _ := m["include"].([]any) + if len(inc) != 1 || inc[0] != "reasoning.encrypted_content" { + t.Errorf("include = %v", m["include"]) + } + rsn, _ := m["reasoning"].(map[string]any) + if rsn["effort"] != "medium" || rsn["summary"] != "auto" { + t.Errorf("reasoning = %v", rsn) + } + tools, _ := m["tools"].([]any) + if len(tools) != 1 { + t.Fatalf("tools = %v", m["tools"]) + } + tool, _ := tools[0].(map[string]any) + if tool["type"] != "function" || tool["name"] != "echo" { + t.Errorf("tool = %v", tool) + } + if _, ok := tool["function"]; ok { + t.Error("Responses tools must be flat, not nested under function") + } + if _, ok := m["reasoning_effort"]; ok { + t.Error("Responses must not send reasoning_effort") + } +} + +func TestBuildResponsesInput_ToolLoopReplay(t *testing.T) { + req := &ChatRequest{ + Messages: []Message{ + {Role: RoleUser, Content: "weather?"}, + { + Role: RoleAssistant, + ReasoningContent: "need a tool", + ThinkingSignature: "enc-1", + ToolCalls: []ToolCall{{ID: "call_1", Name: "get_weather", Arguments: `{"q":"sf"}`}}, + }, + {Role: RoleTool, ToolCallID: "call_1", Content: "72F"}, + }, + } + _, input := buildResponsesInput(req) + body, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + var items []map[string]any + if err := json.Unmarshal(body, &items); err != nil { + t.Fatal(err) + } + if len(items) != 4 { + t.Fatalf("items = %d (%s), want 4", len(items), body) + } + if items[0]["role"] != "user" { + t.Errorf("item0 = %v", items[0]) + } + if items[1]["type"] != "reasoning" || items[1]["encrypted_content"] != "enc-1" { + t.Errorf("item1 = %v", items[1]) + } + if items[2]["type"] != "function_call" || items[2]["call_id"] != "call_1" { + t.Errorf("item2 = %v", items[2]) + } + if items[3]["type"] != "function_call_output" || items[3]["output"] != "72F" { + t.Errorf("item3 = %v", items[3]) + } +} + +func TestParseResponsesAPI_TextToolsReasoning(t *testing.T) { + raw := `{ + "status":"completed", + "output":[ + {"type":"reasoning","encrypted_content":"enc-9","summary":[{"type":"summary_text","text":"plan"}]}, + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}, + {"type":"function_call","call_id":"c1","name":"echo","arguments":"{}"} + ], + "usage":{"input_tokens":20,"output_tokens":8,"input_tokens_details":{"cached_tokens":4},"output_tokens_details":{"reasoning_tokens":3}} + }` + res, err := parseResponsesAPI([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if res.Content != "hi" || res.ReasoningContent != "plan" || res.ThinkingSignature != "enc-9" { + t.Errorf("res = %+v", res) + } + if res.FinishReason != FinishToolCalls { + t.Errorf("finish = %q, want tool_calls", res.FinishReason) + } + if len(res.ToolCalls) != 1 || res.ToolCalls[0].ID != "c1" || res.ToolCalls[0].Name != "echo" { + t.Errorf("tools = %+v", res.ToolCalls) + } + if res.Usage.PromptTokens != 16 || res.Usage.CacheReadTokens != 4 || res.Usage.ReasoningTokens != 3 { + t.Errorf("usage = %+v", res.Usage) + } +} + +func TestParseResponsesAPI_IncompleteLength(t *testing.T) { + raw := `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"type":"message","content":[{"type":"output_text","text":"cut"}]}]}` + res, err := parseResponsesAPI([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if res.FinishReason != FinishLength || res.Content != "cut" { + t.Errorf("res = %+v", res) + } +} + +func TestParseResponsesAPI_ProviderError(t *testing.T) { + _, err := parseResponsesAPI([]byte(`{"error":{"message":"nope"}}`)) + if err == nil || !strings.Contains(err.Error(), "nope") { + t.Fatalf("err = %v", err) + } +} + +func TestMapResponsesStreamEvent(t *testing.T) { + acc := newStreamAccum() + events := []string{ + `{"type":"response.reasoning_summary_text.delta","delta":"think "}`, + `{"type":"response.reasoning_summary_text.delta","delta":"hard"}`, + `{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"c1","name":"echo"}}`, + `{"type":"response.function_call_arguments.delta","output_index":1,"delta":"{}"}`, + `{"type":"response.output_item.done","item":{"type":"reasoning","encrypted_content":"enc"}}`, + `{"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":5,"output_tokens":2,"output_tokens_details":{"reasoning_tokens":1}}}}`, + } + var kinds []DeltaKind + var lastDone bool + for _, e := range events { + ds, done, err := mapResponsesStreamEvent([]byte(e), acc) + if err != nil { + t.Fatalf("event %s: %v", e, err) + } + for _, d := range ds { + kinds = append(kinds, d.Kind) + } + lastDone = done + } + if !lastDone { + t.Fatal("completed event must signal done") + } + res := acc.result() + if res.ReasoningContent != "think hard" { + t.Errorf("reasoning = %q", res.ReasoningContent) + } + if res.ThinkingSignature != "enc" { + t.Errorf("signature = %q", res.ThinkingSignature) + } + if len(res.ToolCalls) != 1 || res.ToolCalls[0].ID != "c1" || res.ToolCalls[0].Arguments != "{}" { + t.Errorf("tools = %+v", res.ToolCalls) + } + if res.FinishReason != FinishToolCalls { + t.Errorf("finish = %q", res.FinishReason) + } + if res.Usage.ReasoningTokens != 1 { + t.Errorf("usage = %+v", res.Usage) + } + if len(kinds) < 3 || kinds[0] != DeltaReasoning || kinds[len(kinds)-1] != DeltaToolArgs { + t.Errorf("kinds = %v", kinds) + } +} + +func TestCall_GPT56ToolsUseResponses(t *testing.T) { + var path string + var body []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + body, _ = io.ReadAll(r.Body) + fmt.Fprint(w, `{"status":"completed","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"plan"}],"encrypted_content":"enc"},{"type":"message","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":3,"output_tokens":2,"output_tokens_details":{"reasoning_tokens":4}}}`) + })) + defer srv.Close() + + cc := newTestClient(t, ProviderConfig{ID: "openai", Format: FormatOpenAI, BaseURL: srv.URL, APIKey: "k", Quirks: Quirks{ReasoningEffort: true}}, srv) + cc.model = "gpt-5.6-luna" + res, err := cc.Call(context.Background(), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + Tools: []ToolDef{{Name: "f", Parameters: json.RawMessage(`{"type":"object"}`)}}, + Thinking: "medium", + }) + if err != nil { + t.Fatalf("Call: %v", err) + } + if !strings.HasSuffix(path, "/responses") { + t.Fatalf("path = %q, want .../responses", path) + } + if !strings.Contains(string(body), `"effort":"medium"`) || strings.Contains(string(body), "reasoning_effort") { + t.Errorf("body = %s", body) + } + if res.Content != "ok" || res.ReasoningContent != "plan" || res.ThinkingSignature != "enc" { + t.Errorf("res = %+v", res) + } + if res.Usage.ReasoningTokens != 4 { + t.Errorf("usage = %+v", res.Usage) + } +} + +func TestCall_GPT56DisabledStaysChatCompletions(t *testing.T) { + var path, payload string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + b, _ := io.ReadAll(r.Body) + payload = string(b) + fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`) + })) + defer srv.Close() + + cc := newTestClient(t, ProviderConfig{ID: "openai", Format: FormatOpenAI, BaseURL: srv.URL, APIKey: "k", Quirks: Quirks{ReasoningEffort: true}}, srv) + cc.model = "gpt-5.6-luna" + _, err := cc.Call(context.Background(), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + Tools: []ToolDef{{Name: "f", Parameters: json.RawMessage(`{"type":"object"}`)}}, + Thinking: "disabled", + }) + if err != nil { + t.Fatalf("Call: %v", err) + } + if !strings.HasSuffix(path, "/chat/completions") { + t.Fatalf("path = %q, want chat/completions", path) + } + if !strings.Contains(payload, `"reasoning_effort":"none"`) { + t.Errorf("disabled gpt-5.6 must pin effort none, body=%s", payload) + } +} + +func TestCall_LearnOnceResponsesAPI(t *testing.T) { + var mu sync.Mutex + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + mu.Lock() + paths = append(paths, r.URL.Path+" "+string(b[:min(len(b), 40)])) + n := len(paths) + mu.Unlock() + if n == 1 { + w.WriteHeader(400) + fmt.Fprint(w, `{"error":{"message":"Function tools with reasoning_effort are not supported for gpt-5.4 in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'."}}`) + return + } + fmt.Fprint(w, `{"status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}]}`) + })) + defer srv.Close() + + cc := newTestClient(t, ProviderConfig{ID: "openai", Format: FormatOpenAI, BaseURL: srv.URL, APIKey: "k", Quirks: Quirks{ReasoningEffort: true}}, srv) + cc.model = "gpt-4o" + res, err := cc.Call(context.Background(), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + Tools: []ToolDef{{Name: "f", Parameters: json.RawMessage(`{"type":"object"}`)}}, + Thinking: "high", + }) + if err != nil { + t.Fatalf("Call: %v", err) + } + if res.Content != "ok" { + t.Errorf("content = %q", res.Content) + } + mu.Lock() + defer mu.Unlock() + if len(paths) != 2 { + t.Fatalf("requests = %d (%v), want 2", len(paths), paths) + } + if !strings.Contains(paths[0], "/chat/completions") { + t.Errorf("first path = %s", paths[0]) + } + if !strings.Contains(paths[1], "/responses") { + t.Errorf("second path = %s, want /responses (not effort none)", paths[1]) + } +} + +func TestCallStream_GPT56ToolsUseResponses(t *testing.T) { + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"plan\"}\n\n") + fmt.Fprint(w, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n") + fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"output_tokens_details\":{\"reasoning_tokens\":2}}}}\n\n") + })) + defer srv.Close() + + cc := newTestClient(t, ProviderConfig{ID: "openai", Format: FormatOpenAI, BaseURL: srv.URL, APIKey: "k", Quirks: Quirks{ReasoningEffort: true}}, srv) + cc.model = "gpt-5.6-luna" + var kinds []DeltaKind + res, err := cc.CallStream(context.Background(), &ChatRequest{ + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + Tools: []ToolDef{{Name: "f", Parameters: json.RawMessage(`{"type":"object"}`)}}, + Thinking: "medium", + }, func(d Delta) error { + kinds = append(kinds, d.Kind) + return nil + }) + if err != nil { + t.Fatalf("CallStream: %v", err) + } + if !strings.HasSuffix(path, "/responses") { + t.Fatalf("path = %q", path) + } + if res.ReasoningContent != "plan" || res.Content != "ok" { + t.Errorf("res = %+v", res) + } + if len(kinds) != 2 || kinds[0] != DeltaReasoning || kinds[1] != DeltaContent { + t.Errorf("kinds = %v", kinds) + } + if res.Usage.ReasoningTokens != 2 { + t.Errorf("usage = %+v", res.Usage) + } +} + +func TestResponsesRequiredShape(t *testing.T) { + if responsesRequired(fmt.Errorf("plain")) { + t.Error("plain must not classify") + } + if responsesRequired(&APIError{Status: 400, Message: "reasoning_effort is not supported with tools"}) { + t.Error("legacy effort-rejected must not force Responses") + } + msg := "Function tools with reasoning_effort are not supported for gpt-5.6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'." + if !responsesRequired(&APIError{Status: 400, Message: msg}) { + t.Error("gpt-5.6 400 must classify as responses-required") + } +}