From e6c232762a5013e5e2993e5ae8b00428df02e951 Mon Sep 17 00:00:00 2001 From: Hunter-Thompson Date: Tue, 7 Jul 2026 04:18:17 +0000 Subject: [PATCH] fix(anthropic): per-model thinking shape restores Claude reasoning on Copilot Copilot's /v1/messages shim serves two Claude generations with mutually incompatible extended-thinking request shapes: the 4.5-and-older family only implements thinking.type=enabled + budget_tokens (adaptive requests return no thinking blocks at all), while 4.6+/fable/5.x reject enabled with a 400 and require thinking.type=adaptive + output_config.effort. The provider-wide AdaptiveThinking switch sent adaptive to everything, which silently disabled visible reasoning for claude-haiku-4.5, claude-sonnet-4.5, and claude-opus-4.5 on Copilot. Upstream verification of the enabled shape on Copilot /v1/messages: BerriAI/litellm#28053 / litellm#31802. The first-party API had the mirror bug: effort-only models (claude-fable-5, claude-opus-4.7+, generation 5) got enabled+budget, which they reject. - internal/anthropic: ModelSupportsAdaptiveThinking / ModelRequiresAdaptiveThinking classify Claude generations (dotted, dashed, dated, and legacy version-first names), mirroring the models.dev reasoning_options catalogs for anthropic/github-copilot. - providers/anthropic: buildRequest resolves the thinking shape per model. AdaptiveThinking now means "effort-first gateway": adaptive wherever supported, enabled+budget for the 4.5 family; the first-party path keeps enabled except on effort-only models. Effort-only settings derive a budget on the enabled path (same ladder as ModeReasoningSettings). - Self-healing: a 400 mentioning thinking.type flips the shape once, retries, and pins the working shape on the model handle, so catalog drift degrades to one retry instead of a failed run. Co-authored-by: captaintrips-gratefulagents --- internal/anthropic/thinking.go | 106 +++++++++ internal/anthropic/thinking_test.go | 62 +++++ pkg/agentsdk/providers/anthropic/model.go | 157 +++++++++++-- .../providers/anthropic/model_test.go | 211 ++++++++++++++++++ pkg/agentsdk/providers/factory.go | 4 + .../providers/factory_copilot_test.go | 49 ++++ 6 files changed, 565 insertions(+), 24 deletions(-) create mode 100644 internal/anthropic/thinking.go create mode 100644 internal/anthropic/thinking_test.go diff --git a/internal/anthropic/thinking.go b/internal/anthropic/thinking.go new file mode 100644 index 0000000..abe4503 --- /dev/null +++ b/internal/anthropic/thinking.go @@ -0,0 +1,106 @@ +package anthropic + +import ( + "strconv" + "strings" +) + +// Claude exposes two mutually incompatible extended-thinking request shapes, +// split by model generation: +// +// - thinking.type=enabled + budget_tokens: the classic shape, implemented by +// the 4.5-and-older generations (claude-haiku-4.5, claude-sonnet-4.5, +// claude-opus-4.5, claude-sonnet-4, claude-3-x). Effort-only models reject +// it (live 400: `"thinking.type.enabled" is not supported for this model. +// Use "thinking.type.adaptive" and "output_config.effort" ...`). +// - thinking.type=adaptive + output_config.effort: the effort-first shape +// introduced with the 4.6 generation (claude-sonnet-4.6, claude-opus-4.6+, +// claude-sonnet-5, claude-fable-5). Older models reject or ignore it — +// notably GitHub Copilot's /v1/messages shim returns no thinking blocks at +// all for adaptive requests against the 4.5 family, silently disabling +// visible reasoning. +// +// The split below mirrors the models.dev reasoning_options catalog for the +// "anthropic" and "github-copilot" providers (and opencode's per-model +// adaptive-thinking gating). Callers that guess wrong self-heal via the +// thinking-shape 400 retry in the providers layer. + +// ModelSupportsAdaptiveThinking reports whether a Claude model accepts +// thinking.type=adaptive + output_config.effort: the fable family and every +// sonnet/opus/haiku generation from 4.6 upward. +func ModelSupportsAdaptiveThinking(model string) bool { + family, major, minor, ok := claudeThinkingGeneration(model) + if !ok { + return false + } + if family == "fable" { + return true + } + return major > 4 || (major == 4 && minor >= 6) +} + +// ModelRequiresAdaptiveThinking reports whether a Claude model accepts ONLY +// the adaptive shape on the first-party Messages API, i.e. rejects +// thinking.type=enabled + budget_tokens: the fable family, opus 4.7+, and any +// generation 5 model. (claude-sonnet-4.6 and claude-opus-4.6 still accept +// budget_tokens on api.anthropic.com, so they are not in this set.) +func ModelRequiresAdaptiveThinking(model string) bool { + family, major, minor, ok := claudeThinkingGeneration(model) + if !ok { + return false + } + if family == "fable" { + return true + } + if major >= 5 { + return true + } + return family == "opus" && major == 4 && minor >= 7 +} + +// claudeThinkingGeneration parses a Claude model identifier into its family +// and version. It tolerates the dotted (claude-sonnet-4.6), dashed +// (claude-sonnet-4-6), dated (claude-sonnet-4-5-20250929), and legacy +// version-first (claude-3-7-sonnet) naming forms; date stamps (>= 1000) are +// not version segments. ok is false for non-Claude models. +func claudeThinkingGeneration(model string) (family string, major, minor int, ok bool) { + normalized := strings.ToLower(strings.TrimSpace(model)) + if idx := strings.LastIndex(normalized, "/"); idx >= 0 { + normalized = normalized[idx+1:] + } + if !strings.Contains(normalized, "claude") { + return "", 0, 0, false + } + + versionSeen := false + for _, token := range strings.Split(normalized, "-") { + switch token { + case "fable", "sonnet", "opus", "haiku": + if family == "" { + family = token + } + continue + } + if versionSeen && minor > 0 { + continue + } + // A token is a version segment when it is numeric ("4", "5") or + // dotted-numeric ("4.6"); 4+ digit numbers are date stamps. + for _, part := range strings.SplitN(token, ".", 2) { + n, err := strconv.Atoi(part) + if err != nil || n >= 1000 { + break + } + if !versionSeen { + major = n + versionSeen = true + } else if minor == 0 { + minor = n + } + } + } + if family == "" && !versionSeen { + return "", 0, 0, false + } + return family, major, minor, true +} diff --git a/internal/anthropic/thinking_test.go b/internal/anthropic/thinking_test.go new file mode 100644 index 0000000..2a5ed14 --- /dev/null +++ b/internal/anthropic/thinking_test.go @@ -0,0 +1,62 @@ +package anthropic + +import "testing" + +// TestModelThinkingShapeCapabilities pins the per-generation thinking-shape +// split mirrored from the models.dev reasoning_options catalogs: the 4.5 +// generation and older only implement enabled+budget_tokens, 4.6+/fable/5.x +// implement adaptive+output_config.effort, and only the effort-only models +// (fable, opus 4.7+, generation 5) reject enabled on the first-party API. +func TestModelThinkingShapeCapabilities(t *testing.T) { + cases := []struct { + model string + supports bool // ModelSupportsAdaptiveThinking + requires bool // ModelRequiresAdaptiveThinking + }{ + // Budget-only generation (Copilot's /v1/messages shim returns no + // thinking blocks for adaptive requests against these). + {"claude-haiku-4.5", false, false}, + {"claude-haiku-4-5", false, false}, + {"claude-haiku-4-5-20251001", false, false}, + {"claude-sonnet-4.5", false, false}, + {"claude-sonnet-4-5-20250929", false, false}, + {"claude-opus-4.5", false, false}, + {"claude-opus-4-5-20251101", false, false}, + {"claude-sonnet-4", false, false}, + {"claude-sonnet-4-20250514", false, false}, + {"claude-opus-4-1", false, false}, + {"claude-3-7-sonnet", false, false}, + {"claude-3-5-haiku-20241022", false, false}, + + // Adaptive-capable, budget still accepted on api.anthropic.com. + {"claude-sonnet-4.6", true, false}, + {"claude-sonnet-4-6", true, false}, + {"claude-opus-4.6", true, false}, + {"claude-opus-4-6", true, false}, + + // Effort-only models: adaptive everywhere. + {"claude-opus-4.7", true, true}, + {"claude-opus-4-7", true, true}, + {"claude-opus-4.8", true, true}, + {"claude-sonnet-5", true, true}, + {"claude-fable-5", true, true}, + {"claude-fable-5-20260601", true, true}, + + // Prefixed model IDs are tolerated. + {"copilot/claude-sonnet-4.5", false, false}, + {"anthropic/claude-fable-5", true, true}, + + // Non-Claude models never match. + {"gpt-5.2", false, false}, + {"gemini-3-pro", false, false}, + {"", false, false}, + } + for _, tc := range cases { + if got := ModelSupportsAdaptiveThinking(tc.model); got != tc.supports { + t.Errorf("ModelSupportsAdaptiveThinking(%q) = %v, want %v", tc.model, got, tc.supports) + } + if got := ModelRequiresAdaptiveThinking(tc.model); got != tc.requires { + t.Errorf("ModelRequiresAdaptiveThinking(%q) = %v, want %v", tc.model, got, tc.requires) + } + } +} diff --git a/pkg/agentsdk/providers/anthropic/model.go b/pkg/agentsdk/providers/anthropic/model.go index 305c35a..683ad91 100644 --- a/pkg/agentsdk/providers/anthropic/model.go +++ b/pkg/agentsdk/providers/anthropic/model.go @@ -6,6 +6,7 @@ import ( "io" "strconv" "strings" + "sync/atomic" internalanthropic "github.com/gratefulagents/sdk/internal/anthropic" "github.com/gratefulagents/sdk/pkg/agentsdk" @@ -40,10 +41,13 @@ type ProviderConfig struct { // RequestHeaders, when set, supplies per-request headers (gateway auth and // integration headers) via SDK middleware. RequestHeaders func(context.Context) (map[string]string, error) - // AdaptiveThinking makes the provider emit adaptive thinking - // (thinking.type=adaptive + output_config.effort) instead of a fixed - // thinking-budget. Required by GitHub Copilot's /v1/messages shim for newer - // Claude models, which reject thinking.type=enabled. + // AdaptiveThinking marks the deployment as effort-first: an + // Anthropic-compatible gateway (e.g. GitHub Copilot's /v1/messages shim) + // that controls reasoning via thinking.type=adaptive + output_config.effort + // on every model generation that supports it. The shape is still resolved + // per model: generations that only implement thinking.type=enabled + + // budget_tokens (claude-*-4.5 and older) keep the enabled shape, which is + // the only one that returns thinking blocks for them. AdaptiveThinking bool // PromptCaching enables Anthropic prompt-cache breakpoints: the tool // prefix, system prompt, and the last two conversation positions are @@ -106,6 +110,11 @@ type AnthropicModel struct { adaptiveThinking bool promptCaching bool defaultMaxTokens int + + // thinkingShape overrides the per-model thinking-shape heuristic after the + // API rejected the derived shape with the thinking.type 400 (see + // flipThinkingShapeOnError). 0 = auto, 1 = adaptive, 2 = enabled. + thinkingShape atomic.Int32 } type anthropicModelConfig struct { @@ -162,7 +171,13 @@ func (m *AnthropicModel) GetResponse(ctx context.Context, req agentsdk.ModelRequ apiReq := m.buildRequest(req) resp, err := m.client.CreateMessage(ctx, apiReq) if err != nil { - return nil, err + flipped, ok := m.flipThinkingShapeOnError(err, apiReq, req) + if !ok { + return nil, err + } + if resp, err = m.client.CreateMessage(ctx, flipped); err != nil { + return nil, err + } } return m.convertResponse(resp), nil } @@ -174,7 +189,13 @@ func (m *AnthropicModel) StreamResponse(ctx context.Context, req agentsdk.ModelR apiReq := m.buildRequest(req) stream, err := m.client.CreateMessageStream(ctx, apiReq) if err != nil { - return nil, err + flipped, ok := m.flipThinkingShapeOnError(err, apiReq, req) + if !ok { + return nil, err + } + if stream, err = m.client.CreateMessageStream(ctx, flipped); err != nil { + return nil, err + } } return m.wrapStream(stream), nil } @@ -248,24 +269,7 @@ func (m *AnthropicModel) buildRequest(req agentsdk.ModelRequest) internalanthrop apiReq.MaxTokens = 16384 } - if m.adaptiveThinking { - // Gateways such as Copilot's /v1/messages shim reject thinking.type=enabled - // for newer Claude models and instead control reasoning via adaptive - // thinking + output_config.effort. Emit that shape whenever reasoning is - // requested (a thinking budget or an explicit effort). - if effort := mapReasoningEffortToAnthropic(req.Settings.ReasoningEffort); effort != "" { - apiReq.Thinking = &internalanthropic.ThinkingConfig{Type: "adaptive"} - apiReq.OutputEffort = effort - } else if req.Settings.ThinkingBudget > 0 { - apiReq.Thinking = &internalanthropic.ThinkingConfig{Type: "adaptive"} - apiReq.OutputEffort = string(internalanthropic.OutputEffortMedium) - } - } else if req.Settings.ThinkingBudget > 0 { - apiReq.Thinking = &internalanthropic.ThinkingConfig{ - Type: "enabled", - BudgetTokens: req.Settings.ThinkingBudget, - } - } + m.applyThinkingConfig(&apiReq, model, req.Settings) // Convert tools. for _, t := range req.Tools { @@ -286,6 +290,111 @@ func (m *AnthropicModel) buildRequest(req agentsdk.ModelRequest) internalanthrop return apiReq } +// Thinking-shape override states recorded after a thinking.type 400. +const ( + thinkingShapeAuto int32 = iota // resolve per model + thinkingShapeAdaptive // force adaptive + output_config.effort + thinkingShapeEnabled // force enabled + budget_tokens +) + +// applyThinkingConfig emits the extended-thinking request config in the shape +// the target model implements. Claude generations up to 4.5 only accept +// thinking.type=enabled + budget_tokens; the 4.6+/fable/5.x generations accept +// (and on effort-first gateways such as Copilot's /v1/messages shim, require) +// thinking.type=adaptive + output_config.effort. Sending the wrong shape either +// 400s or — on the Copilot shim's 4.5 family — silently returns no thinking +// blocks, which is what used to hide Claude reasoning on Copilot. +func (m *AnthropicModel) applyThinkingConfig(apiReq *internalanthropic.CreateMessageRequest, model string, settings agentsdk.ModelSettings) { + effort := mapReasoningEffortToAnthropic(settings.ReasoningEffort) + if effort == "" && settings.ThinkingBudget <= 0 { + // No reasoning requested (or explicitly "none" without a budget). + return + } + if m.useAdaptiveThinking(model) { + if effort == "" { + effort = string(internalanthropic.OutputEffortMedium) + } + apiReq.Thinking = &internalanthropic.ThinkingConfig{Type: "adaptive"} + apiReq.OutputEffort = effort + return + } + budget := settings.ThinkingBudget + if budget <= 0 { + budget = thinkingBudgetForEffort(effort) + } + if budget <= 0 { + return + } + apiReq.Thinking = &internalanthropic.ThinkingConfig{ + Type: "enabled", + BudgetTokens: budget, + } + apiReq.OutputEffort = "" +} + +// useAdaptiveThinking picks the thinking request shape for a model. A shape +// recorded by flipThinkingShapeOnError wins; otherwise effort-first gateways +// (adaptiveThinking, e.g. Copilot's /v1/messages shim) use adaptive on every +// generation that supports it, and the first-party API keeps enabled + +// budget_tokens except on the models that reject it. +func (m *AnthropicModel) useAdaptiveThinking(model string) bool { + switch m.thinkingShape.Load() { + case thinkingShapeAdaptive: + return true + case thinkingShapeEnabled: + return false + } + if m.adaptiveThinking { + return internalanthropic.ModelSupportsAdaptiveThinking(model) + } + return internalanthropic.ModelRequiresAdaptiveThinking(model) +} + +// thinkingBudgetForEffort converts a reasoning-effort label into a fixed +// thinking budget for models that only implement enabled + budget_tokens. +// The ladder mirrors agent.ModeReasoningSettings so an effort-only request +// behaves the same as the equivalent mode-level reasoning setting. +func thinkingBudgetForEffort(effort string) int { + switch effort { + case internalanthropic.OutputEffortLow: + return 2048 + case internalanthropic.OutputEffortMedium: + return 4096 + case internalanthropic.OutputEffortHigh: + return 8192 + case internalanthropic.OutputEffortXHigh, internalanthropic.OutputEffortMax: + return 12288 + default: + return 0 + } +} + +// flipThinkingShapeOnError rebuilds the request with the opposite thinking +// shape when the API rejected the current one, and records the working shape +// for the rest of the model's lifetime. The per-model generation split is a +// heuristic mirror of the provider catalogs, so a drifted deployment answers +// with HTTP 400 bodies like `"thinking.type.enabled" is not supported for this +// model. Use "thinking.type.adaptive" and "output_config.effort" ...` — the +// one-shot flip self-heals in both directions instead of failing the run. +func (m *AnthropicModel) flipThinkingShapeOnError(err error, sent internalanthropic.CreateMessageRequest, req agentsdk.ModelRequest) (internalanthropic.CreateMessageRequest, bool) { + if sent.Thinking == nil { + return sent, false + } + var reqErr *internalanthropic.RequestError + if !errors.As(err, &reqErr) || reqErr.StatusCode != 400 { + return sent, false + } + if !strings.Contains(strings.ToLower(reqErr.Body), "thinking.type") { + return sent, false + } + if sent.Thinking.Type == "adaptive" { + m.thinkingShape.Store(thinkingShapeEnabled) + } else { + m.thinkingShape.Store(thinkingShapeAdaptive) + } + return m.buildRequest(req), true +} + // applyPromptCacheBreakpoints marks the standard agent-loop cache boundaries // with ephemeral cache_control (Anthropic allows at most 4 breakpoints): // the last tool definition (caches the whole tool prefix), the last system diff --git a/pkg/agentsdk/providers/anthropic/model_test.go b/pkg/agentsdk/providers/anthropic/model_test.go index 210ae79..b245fd3 100644 --- a/pkg/agentsdk/providers/anthropic/model_test.go +++ b/pkg/agentsdk/providers/anthropic/model_test.go @@ -2,6 +2,9 @@ package anthropic import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" @@ -174,3 +177,211 @@ func TestBuildRequestAPIKeyOmitsClaudeCodeIdentity(t *testing.T) { t.Fatalf("System = %+v, want instructions only", req.System) } } + +// TestBuildRequestThinkingShapePerModel pins the per-model thinking request +// shape. On effort-first gateways (adaptiveThinking, e.g. Copilot's +// /v1/messages shim) the 4.5-and-older Claude generations must keep +// thinking.type=enabled + budget_tokens — the shim returns no thinking blocks +// for adaptive requests against them — while 4.6+/fable/5.x use adaptive + +// output_config.effort. On the first-party API only the effort-only models +// (fable, opus 4.7+, generation 5) switch to adaptive. +func TestBuildRequestThinkingShapePerModel(t *testing.T) { + cases := []struct { + name string + adaptive bool // provider AdaptiveThinking (effort-first gateway) + model string + settings agentsdk.ModelSettings + wantType string // "" = no thinking config + wantBudget int + wantEffort string + }{ + { + name: "copilot 4.5 generation keeps enabled+budget", + adaptive: true, + model: "claude-sonnet-4.5", + settings: agentsdk.ModelSettings{ThinkingBudget: 8192, ReasoningEffort: "high"}, + wantType: "enabled", + wantBudget: 8192, + }, + { + name: "copilot 4.5 with effort only derives a budget", + adaptive: true, + model: "claude-haiku-4.5", + settings: agentsdk.ModelSettings{ReasoningEffort: "medium"}, + wantType: "enabled", + wantBudget: 4096, + }, + { + name: "copilot 4.6+ uses adaptive+effort", + adaptive: true, + model: "claude-opus-4.8", + settings: agentsdk.ModelSettings{ThinkingBudget: 8192, ReasoningEffort: "xhigh"}, + wantType: "adaptive", + wantEffort: "max", + }, + { + name: "copilot adaptive with budget only defaults to medium effort", + adaptive: true, + model: "claude-fable-5", + settings: agentsdk.ModelSettings{ThinkingBudget: 8192}, + wantType: "adaptive", + wantEffort: "medium", + }, + { + name: "first-party 4.5 keeps enabled+budget", + adaptive: false, + model: "claude-sonnet-4-5", + settings: agentsdk.ModelSettings{ThinkingBudget: 4096, ReasoningEffort: "medium"}, + wantType: "enabled", + wantBudget: 4096, + }, + { + name: "first-party 4.6 still accepts budget so keeps enabled", + adaptive: false, + model: "claude-sonnet-4-6", + settings: agentsdk.ModelSettings{ThinkingBudget: 4096, ReasoningEffort: "medium"}, + wantType: "enabled", + wantBudget: 4096, + }, + { + name: "first-party effort-only model switches to adaptive", + adaptive: false, + model: "claude-fable-5", + settings: agentsdk.ModelSettings{ThinkingBudget: 4096}, + wantType: "adaptive", + wantEffort: "medium", + }, + { + name: "first-party opus 4.7 is effort-only", + adaptive: false, + model: "claude-opus-4-7", + settings: agentsdk.ModelSettings{ThinkingBudget: 8192, ReasoningEffort: "high"}, + wantType: "adaptive", + wantEffort: "high", + }, + { + name: "reasoning none without budget disables thinking", + adaptive: true, + model: "claude-sonnet-4.5", + settings: agentsdk.ModelSettings{ReasoningEffort: "none"}, + wantType: "", + }, + { + name: "no reasoning settings sends no thinking config", + adaptive: false, + model: "claude-sonnet-4-5", + wantType: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := &AnthropicModel{model: tc.model, adaptiveThinking: tc.adaptive} + apiReq := m.buildRequest(agentsdk.ModelRequest{Model: tc.model, Settings: tc.settings}) + if tc.wantType == "" { + if apiReq.Thinking != nil { + t.Fatalf("Thinking = %+v, want none", apiReq.Thinking) + } + return + } + if apiReq.Thinking == nil || apiReq.Thinking.Type != tc.wantType { + t.Fatalf("Thinking = %+v, want type %q", apiReq.Thinking, tc.wantType) + } + if apiReq.Thinking.BudgetTokens != tc.wantBudget { + t.Fatalf("budget_tokens = %d, want %d", apiReq.Thinking.BudgetTokens, tc.wantBudget) + } + if apiReq.OutputEffort != tc.wantEffort { + t.Fatalf("output_config.effort = %q, want %q", apiReq.OutputEffort, tc.wantEffort) + } + }) + } +} + +// TestThinkingShapeFlipOn400 verifies the self-healing retry: when the API +// rejects the derived thinking shape with the thinking.type 400, the request +// is retried once with the opposite shape and the working shape sticks for +// subsequent requests from the same model handle. +func TestThinkingShapeFlipOn400(t *testing.T) { + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + bodies = append(bodies, body) + w.Header().Set("Content-Type", "application/json") + if len(bodies) == 1 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"invalid_request_error","message":"\"thinking.type.enabled\" is not supported for this model. Use \"thinking.type.adaptive\" and \"output_config.effort\" to control thinking behavior."}}`)) + return + } + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-test","content":[{"type":"thinking","thinking":"hmm","signature":"sig"},{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer srv.Close() + + m, err := newAnthropicModel(anthropicModelConfig{apiKey: "test-key", baseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + m.model = "claude-sonnet-4-5" // resolves to enabled+budget + + req := agentsdk.ModelRequest{ + Model: "claude-sonnet-4-5", + Settings: agentsdk.ModelSettings{ThinkingBudget: 4096, ReasoningEffort: "medium"}, + Input: []agentsdk.RunItem{{Type: agentsdk.RunItemMessage, Message: &agentsdk.MessageOutput{Text: "hi"}}}, + } + resp, err := m.GetResponse(context.Background(), req) + if err != nil { + t.Fatalf("GetResponse() error = %v", err) + } + if len(bodies) != 2 { + t.Fatalf("requests = %d, want 2 (original + flipped retry)", len(bodies)) + } + first, _ := bodies[0]["thinking"].(map[string]any) + if got, _ := first["type"].(string); got != "enabled" { + t.Fatalf("first thinking.type = %q, want enabled", got) + } + second, _ := bodies[1]["thinking"].(map[string]any) + if got, _ := second["type"].(string); got != "adaptive" { + t.Fatalf("retry thinking.type = %q, want adaptive", got) + } + if _, hasBudget := second["budget_tokens"]; hasBudget { + t.Fatalf("retry thinking must not carry budget_tokens: %v", second) + } + if resp == nil { + t.Fatal("GetResponse() = nil response after flipped retry") + } + + // The working shape sticks: the next request goes straight to adaptive. + if apiReq := m.buildRequest(req); apiReq.Thinking == nil || apiReq.Thinking.Type != "adaptive" { + t.Fatalf("post-flip Thinking = %+v, want adaptive", apiReq.Thinking) + } +} + +// TestThinkingShapeFlipIgnoresUnrelated400 ensures ordinary bad-request errors +// are not retried with a different thinking shape. +func TestThinkingShapeFlipIgnoresUnrelated400(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens is too large"}}`)) + })) + defer srv.Close() + + m, err := newAnthropicModel(anthropicModelConfig{apiKey: "test-key", baseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + m.model = "claude-sonnet-4-5" + + _, err = m.GetResponse(context.Background(), agentsdk.ModelRequest{ + Model: "claude-sonnet-4-5", + Settings: agentsdk.ModelSettings{ThinkingBudget: 4096}, + Input: []agentsdk.RunItem{{Type: agentsdk.RunItemMessage, Message: &agentsdk.MessageOutput{Text: "hi"}}}, + }) + if err == nil { + t.Fatal("GetResponse() = nil error, want 400 passthrough") + } + if calls != 1 { + t.Fatalf("requests = %d, want 1 (no shape-flip retry)", calls) + } +} diff --git a/pkg/agentsdk/providers/factory.go b/pkg/agentsdk/providers/factory.go index de2d20f..e466380 100644 --- a/pkg/agentsdk/providers/factory.go +++ b/pkg/agentsdk/providers/factory.go @@ -340,6 +340,10 @@ func newCopilotProviderFromSpec(spec ProviderSpec) agentsdk.ModelProvider { // token; the static bearer only satisfies the client's credential check. BearerToken: firstNonEmpty(initialToken, "copilot-placeholder"), RequestHeaders: anthropicHeaders, + // Effort-first shim: Claude 4.6+/fable/5.x take thinking.type=adaptive + + // output_config.effort here (they 400 on enabled), while the 4.5-and-older + // family still resolves to enabled + budget_tokens per model — the shim + // returns no thinking blocks for adaptive requests against those models. AdaptiveThinking: true, // Verified against the Copilot /v1/messages shim: cache_control // breakpoints bill cache reads at 0.1x, and oversized max_tokens is diff --git a/pkg/agentsdk/providers/factory_copilot_test.go b/pkg/agentsdk/providers/factory_copilot_test.go index 5a0f51c..6de73c5 100644 --- a/pkg/agentsdk/providers/factory_copilot_test.go +++ b/pkg/agentsdk/providers/factory_copilot_test.go @@ -301,3 +301,52 @@ func TestCopilotChatCompletionsEscapeHatch(t *testing.T) { t.Fatalf("path = %q, want /v1/chat/completions", gotPath) } } + +// TestCopilotClaude45UsesEnabledThinking locks in the restored thinking path +// for the budget-tokens-only Claude generation on Copilot's /v1/messages shim +// (claude-haiku-4.5 / claude-sonnet-4.5 / claude-opus-4.5): these models +// return no thinking blocks for thinking.type=adaptive, and Copilot upstream +// verifies thinking.type=enabled + budget_tokens end-to-end (see +// BerriAI/litellm#28053). Only the 4.6+/fable/5.x generations use adaptive. +func TestCopilotClaude45UsesEnabledThinking(t *testing.T) { + var body struct { + Thinking map[string]any `json:"thinking"` + OutputConfig map[string]any `json:"output_config"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4.5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + defer srv.Close() + + provider, err := NewProviderFromConfig(ProviderSpec{ + Provider: DefaultProviderCopilot, + Model: "claude-sonnet-4.5", + ProviderAPIKeys: map[string]string{DefaultProviderCopilot: "copilot-token"}, + ProviderBaseURLs: map[string]string{DefaultProviderCopilot: srv.URL}, + }) + if err != nil { + t.Fatal(err) + } + model, err := provider.GetModel("claude-sonnet-4.5") + if err != nil { + t.Fatal(err) + } + if _, err := model.GetResponse(context.Background(), agentsdk.ModelRequest{ + Model: "claude-sonnet-4.5", + Settings: agentsdk.ModelSettings{ThinkingBudget: 8192, ReasoningEffort: "high"}, + Input: []agentsdk.RunItem{{Type: agentsdk.RunItemMessage, Message: &agentsdk.MessageOutput{Text: "hi"}}}, + }); err != nil { + t.Fatalf("GetResponse() error = %v", err) + } + if got, _ := body.Thinking["type"].(string); got != "enabled" { + t.Fatalf("thinking.type = %q, want enabled (adaptive yields no thinking blocks on the 4.5 family)", got) + } + if got, _ := body.Thinking["budget_tokens"].(float64); int(got) != 8192 { + t.Fatalf("thinking.budget_tokens = %v, want 8192", body.Thinking["budget_tokens"]) + } + if len(body.OutputConfig) != 0 { + t.Fatalf("output_config must be empty on the enabled path: %v", body.OutputConfig) + } +}