Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions internal/anthropic/thinking.go
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions internal/anthropic/thinking_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
157 changes: 133 additions & 24 deletions pkg/agentsdk/providers/anthropic/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"strconv"
"strings"
"sync/atomic"

internalanthropic "github.com/gratefulagents/sdk/internal/anthropic"
"github.com/gratefulagents/sdk/pkg/agentsdk"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Comment on lines +390 to +394

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pin the shape only after the retry succeeds

When a shape-related 400 is followed by a failed retry (for example a transient 429/5xx/network error, or the opposite shape is also invalid), this stores the flipped shape before any successful response proves it works. Because the same AnthropicModel can be reused for later attempts or turns, subsequent calls bypass the per-model heuristic and are forced to the unverified shape, causing avoidable repeated failures or silently disabling thinking for models that needed the original shape.

Useful? React with 👍 / 👎.

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
Expand Down
Loading
Loading