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
7 changes: 3 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ jobs:
GOTOOLCHAIN: local
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The reusable charmbracelet/meta govulncheck job always reads go.mod.
# Keep the module baseline at Go 1.25.0, but run the scanner with Go 1.26.4
# so it uses a fixed standard library.
# Keep the module baseline in go.mod, but run the scanner with Go 1.26.5
# so it uses a fixed standard library (GO-2026-5856).
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26.4"
go-version: "1.26.5"
cache: true
check-latest: true
- run: |
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ require (
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/image v0.42.0 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
Expand Down
247 changes: 215 additions & 32 deletions providers/anthropic/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,178 @@ func defaultsToAdaptiveThinking(model string) bool {
}

func requiresAdaptiveThinking(model string) bool {
return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model)
return defaultsToAdaptiveThinking(model) || defaultsToOmittedOpusThinkingDisplay(model) || thinkingOnByDefault(model)
}

// Claude models before 4.6 reject adaptive thinking. Unknown versions use
// legacy budget thinking unless explicitly allowlisted.
func supportsAdaptiveThinking(model string) bool {
if defaultsToAdaptiveThinking(model) {
return true
}
major, minor, ok := claudeVersion(model)
if !ok {
return false
}
return major > 4 || (major == 4 && minor >= 6)
}

func claudeVersion(model string) (major, minor int, ok bool) {
model = strings.ToLower(strings.TrimSpace(model))
_, rest, found := strings.Cut(model, "claude-")
if !found {
return 0, 0, false
}
// Vertex model IDs put the version before an "@<date>" suffix
// (claude-opus-4-7@20260101).
rest, _, _ = strings.Cut(rest, "@")
parts := strings.Split(rest, "-")
for i, part := range parts {
v, valid := shortVersionComponent(part)
if !valid {
continue
}
major = v
if i+1 < len(parts) {
if m, validMinor := shortVersionComponent(parts[i+1]); validMinor {
minor = m
}
Comment thread
ibetitsmike marked this conversation as resolved.
}
return major, minor, true
}
return 0, 0, false
}

func shortVersionComponent(s string) (int, bool) {
if len(s) == 0 || len(s) > 2 {
return 0, false
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return v, true
}

// Opus 4.5 accepts output_config.effort alongside manual budget thinking,
// unlike other pre-adaptive models.
func isOpus45(model string) bool {
major, minor, ok := claudeVersion(model)
return ok && major == 4 && minor == 5 &&
strings.Contains(strings.ToLower(strings.TrimSpace(model)), "opus")
}

// Opus 4.5 supports only low, medium, and high effort.
func normalizeOpus45Effort(effort Effort) Effort {
switch effort {
case EffortMinimal:
return EffortLow
case EffortXHigh, EffortMax:
return EffortHigh
default:
return effort
}
}

// Mythos and Fable models run adaptive thinking unconditionally and reject
// thinking: {type: "disabled"} with an HTTP 400.
func rejectsDisabledThinking(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
return strings.Contains(model, "claude-mythos") || strings.Contains(model, "claude-fable")
}

// Claude 5+ models run adaptive thinking when the request omits the thinking
// field, so an explicit opt-out must send thinking: {type: "disabled"}.
func thinkingOnByDefault(model string) bool {
major, _, ok := claudeVersion(model)
return ok && major >= 5
Comment thread
ibetitsmike marked this conversation as resolved.
}

// Extended thinking with budget_tokens shipped with Claude 3.7; older
// models reject the thinking field entirely.
func supportsBudgetThinking(model string) bool {
major, minor, ok := claudeVersion(model)
return ok && (major > 3 || (major == 3 && minor >= 7))
}

// The xhigh effort tier shipped with Claude Opus 4.7; 4.6-era adaptive
// models accept only low, medium, high, and max.
func supportsXHighEffort(model string) bool {
major, minor, ok := claudeVersion(model)
return ok && (major > 4 || (major == 4 && minor >= 7))
}

// Anthropic does not accept minimal for output_config.effort, and xhigh
// only on models that support it.
func normalizeEffort(effort Effort, model string) Effort {
switch effort {
case EffortMinimal:
return EffortLow
case EffortXHigh:
if !supportsXHighEffort(model) {
return EffortMax
}
return effort
default:
return effort
}
}

// Anthropic requires at least 1024 thinking tokens. Smaller derived budgets
// disable thinking to preserve the requested output limit.
func legacyEffortBudget(effort Effort, maxTokens int64) int64 {
const minBudget = 1024
var budget int64
switch effort {
case EffortMinimal:
budget = minBudget
case EffortLow:
budget = int64(float64(maxTokens) * 0.2)
case EffortHigh:
budget = int64(float64(maxTokens) * 0.8)
case EffortXHigh:
budget = int64(float64(maxTokens) * 0.9)
case EffortMax:
budget = int64(float64(maxTokens) * 0.95)
default:
budget = int64(float64(maxTokens) * 0.5)
}
// budget_tokens must be strictly less than max_tokens.
if budget >= maxTokens {
budget = maxTokens - 1
}
if budget < minBudget {
return 0
}
return budget
}

func stripThinkingUnsupportedParams(params *anthropic.MessageNewParams, call fantasy.Call, warnings []fantasy.CallWarning) []fantasy.CallWarning {
if call.Temperature != nil {
params.Temperature = param.Opt[float64]{}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Setting: "temperature",
Details: "temperature is not supported when thinking is enabled",
})
}
if call.TopP != nil {
params.TopP = param.Opt[float64]{}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Setting: "TopP",
Details: "TopP is not supported when thinking is enabled",
})
}
if call.TopK != nil {
params.TopK = param.Opt[int64]{}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Setting: "TopK",
Details: "TopK is not supported when thinking is enabled",
})
}
return warnings
}

func setThinkingDisplay(param interface{ SetExtraFields(map[string]any) }, display ThinkingDisplay) {
Expand Down Expand Up @@ -392,14 +563,49 @@ func (a languageModel) prepareParams(call fantasy.Call) (
switch {
case providerOptions.Effort != nil:
effort := *providerOptions.Effort
params.OutputConfig = anthropic.OutputConfigParam{
Effort: anthropic.OutputConfigEffort(effort),
}
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
setThinkingDisplay(&adaptive, display)
switch {
case effort == EffortNone:
switch {
case rejectsDisabledThinking(a.modelID):
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,
Message: "thinking cannot be disabled on " + a.modelID,
})
case thinkingOnByDefault(a.modelID):
disabled := anthropic.NewThinkingConfigDisabledParam()
params.Thinking.OfDisabled = &disabled
}
case supportsAdaptiveThinking(a.modelID):
params.OutputConfig = anthropic.OutputConfigParam{
Effort: anthropic.OutputConfigEffort(normalizeEffort(effort, a.modelID)),
}
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
setThinkingDisplay(&adaptive, display)
}
params.Thinking.OfAdaptive = &adaptive
default:
if isOpus45(a.modelID) {
params.OutputConfig = anthropic.OutputConfigParam{
Effort: anthropic.OutputConfigEffort(normalizeOpus45Effort(effort)),
}
}
if !supportsBudgetThinking(a.modelID) {
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Setting: "effort",
Details: "thinking is not supported on " + a.modelID,
})
break
}
if budget := legacyEffortBudget(effort, params.MaxTokens); budget > 0 {
Comment thread
ibetitsmike marked this conversation as resolved.
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(budget)
Comment thread
ibetitsmike marked this conversation as resolved.
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
setThinkingDisplay(params.Thinking.OfEnabled, display)
}
warnings = stripThinkingUnsupportedParams(params, call, warnings)
}
}
params.Thinking.OfAdaptive = &adaptive
case providerOptions.Thinking != nil:
if providerOptions.Thinking.BudgetTokens == 0 {
return nil, nil, nil, nil, &fantasy.Error{Title: "no budget", Message: "thinking requires budget"}
Expand All @@ -416,30 +622,7 @@ func (a languageModel) prepareParams(call fantasy.Call) (
setThinkingDisplay(params.Thinking.OfEnabled, display)
}
}
if call.Temperature != nil {
params.Temperature = param.Opt[float64]{}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Setting: "temperature",
Details: "temperature is not supported when thinking is enabled",
})
}
if call.TopP != nil {
params.TopP = param.Opt[float64]{}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Setting: "TopP",
Details: "TopP is not supported when thinking is enabled",
})
}
if call.TopK != nil {
params.TopK = param.Opt[int64]{}
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeUnsupportedSetting,
Setting: "TopK",
Details: "TopK is not supported when thinking is enabled",
})
}
warnings = stripThinkingUnsupportedParams(params, call, warnings)
case defaultsToAdaptiveThinking(a.modelID):
adaptive := anthropic.NewThinkingConfigAdaptiveParam()
if display, ok := thinkingDisplay(providerOptions, a.modelID); ok {
Expand Down
Loading
Loading