From e93b5246c99f97beea4c493c5da975b5602fd1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 21 Oct 2026 18:20:34 +0200 Subject: [PATCH 1/7] feat(anthropic): parse and validate server-side fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fallbacks accepts "default" or a list of at most three distinct models, none equal to the requested model. Claude Haiku 5.5 has no server-side fallback: a list is rejected and "default" only warns. Malformed values, silently ignored until now, fail at client construction. Signed-off-by: Arnaud Héritier --- pkg/model/provider/anthropic/fallbacks.go | 71 ++++++++++++++++ .../provider/anthropic/fallbacks_test.go | 80 +++++++++++++++++++ .../provider/anthropic/thinking_binding.go | 3 + pkg/modelinfo/claude5.go | 6 +- pkg/modelinfo/claude5_test.go | 4 +- 5 files changed, 161 insertions(+), 3 deletions(-) diff --git a/pkg/model/provider/anthropic/fallbacks.go b/pkg/model/provider/anthropic/fallbacks.go index 50a2149ee9..7016998837 100644 --- a/pkg/model/provider/anthropic/fallbacks.go +++ b/pkg/model/provider/anthropic/fallbacks.go @@ -3,6 +3,7 @@ package anthropic import ( "fmt" "log/slog" + "slices" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" @@ -17,6 +18,76 @@ import ( // trip. const serverSideFallbackBeta anthropic.AnthropicBeta = "server-side-fallback-2026-06-01" +// maxFallbacks is the longest list the API accepts. +const maxFallbacks = 3 + +// fallbackSpec is the parsed `fallbacks` provider_opt: the server's default +// routing, or an explicit list of models tried in order. +type fallbackSpec struct { + Default bool + Models []string +} + +// parseFallbacks reads the `fallbacks` provider_opt: "default", or a list of +// at most three distinct model IDs. An absent or empty value disables +// fallbacks. +func parseFallbacks(opts map[string]any) (fallbackSpec, error) { + var entries []any + switch v := opts["fallbacks"].(type) { + case nil: + return fallbackSpec{}, nil + case string: + if v != "default" { + return fallbackSpec{}, fmt.Errorf(`anthropic: fallbacks must be "default" or a list of model IDs, got %q`, v) + } + return fallbackSpec{Default: true}, nil + case []string: + for _, m := range v { + entries = append(entries, m) + } + case []any: + entries = v + default: + return fallbackSpec{}, fmt.Errorf(`anthropic: fallbacks must be "default" or a list of model IDs, got %T`, v) + } + if len(entries) > maxFallbacks { + return fallbackSpec{}, fmt.Errorf("anthropic: fallbacks accepts at most %d models, got %d", maxFallbacks, len(entries)) + } + var models []string + for _, e := range entries { + m, ok := e.(string) + if !ok || m == "" { + return fallbackSpec{}, fmt.Errorf("anthropic: fallbacks entries must be model IDs, got %v", e) + } + if slices.Contains(models, m) { + return fallbackSpec{}, fmt.Errorf("anthropic: fallbacks lists %q twice", m) + } + models = append(models, m) + } + return fallbackSpec{Models: models}, nil +} + +// validateFallbackSpec checks the rules the API enforces with a 400, so a bad +// config fails at client construction. +func validateFallbackSpec(cfg *latest.ModelConfig) error { + spec, err := parseFallbacks(cfg.ProviderOpts) + if err != nil { + return err + } + if slices.Contains(spec.Models, cfg.Model) { + return fmt.Errorf("anthropic: fallback %q is the same as the model", cfg.Model) + } + if m, ok := modelinfo.LookupClaude5(cfg.Model); ok && m.NoServerSideFallback { + if len(spec.Models) > 0 { + return fmt.Errorf("anthropic: %s has no server-side fallback; remove fallbacks or use \"default\"", cfg.Model) + } + if spec.Default { + slog.Warn("Anthropic: fallbacks: default has no effect on this model", "model", cfg.Model) + } + } + return nil +} + // fallbackModels extracts the `fallbacks` provider_opt: a list of model IDs, // in priority order. Returns nil when the option is absent or malformed. // diff --git a/pkg/model/provider/anthropic/fallbacks_test.go b/pkg/model/provider/anthropic/fallbacks_test.go index 2504e5fadb..f403ef47e6 100644 --- a/pkg/model/provider/anthropic/fallbacks_test.go +++ b/pkg/model/provider/anthropic/fallbacks_test.go @@ -76,3 +76,83 @@ func TestNewClient_RejectsClaude5Fallbacks(t *testing.T) { _, err := NewClient(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"ANTHROPIC_API_KEY": "k"})) require.ErrorContains(t, err, "issues/4584") } + +func TestParseFallbacks(t *testing.T) { + t.Parallel() + + valid := map[string]struct { + value any + want fallbackSpec + }{ + "absent": {}, + "empty list": {value: []any{}}, + "default": {value: "default", want: fallbackSpec{Default: true}}, + "one model": {value: []any{"claude-opus-4-8"}, want: fallbackSpec{Models: []string{"claude-opus-4-8"}}}, + "three models": {value: []any{"a", "b", "c"}, want: fallbackSpec{Models: []string{"a", "b", "c"}}}, + "string slice": {value: []string{"a"}, want: fallbackSpec{Models: []string{"a"}}}, + } + for name, tc := range valid { + t.Run(name, func(t *testing.T) { + t.Parallel() + opts := map[string]any{} + if tc.value != nil { + opts["fallbacks"] = tc.value + } + got, err := parseFallbacks(opts) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + + invalid := map[string]any{ + "other string": "claude-opus-4-8", + "four models": []any{"a", "b", "c", "d"}, + "duplicate": []any{"a", "a"}, + "empty entry": []any{"a", ""}, + "non-string": []any{"a", 1}, + "object entry": []any{map[string]any{"model": "a"}}, + "wrong type": 42, + } + for name, value := range invalid { + t.Run("invalid "+name, func(t *testing.T) { + t.Parallel() + _, err := parseFallbacks(map[string]any{"fallbacks": value}) + require.ErrorContains(t, err, "fallbacks") + }) + } +} + +func TestValidateFallbackSpec(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + model string + fallbacks any + wantErr string + }{ + "list": {model: "claude-opus-4-8", fallbacks: []any{"claude-sonnet-4-6"}}, + "default": {model: "claude-opus-4-8", fallbacks: "default"}, + "entry equals primary": {model: "claude-opus-4-8", fallbacks: []any{"claude-opus-4-8"}, wantErr: "same as the model"}, + "malformed": {model: "claude-opus-4-8", fallbacks: "nope", wantErr: "fallbacks"}, + "haiku 5.5 list": {model: "claude-haiku-5-5", fallbacks: []any{"claude-sonnet-4-6"}, wantErr: "no server-side fallback"}, + "haiku 5.5 default only": {model: "claude-haiku-5-5", fallbacks: "default"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + err := validateFallbackSpec(&latest.ModelConfig{Provider: "anthropic", Model: tc.model, ProviderOpts: map[string]any{"fallbacks": tc.fallbacks}}) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestNewClient_RejectsMalformedFallbacks(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-8", ProviderOpts: map[string]any{"fallbacks": "claude-sonnet-4-6"}} + _, err := NewClient(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"ANTHROPIC_API_KEY": "k"})) + require.ErrorContains(t, err, `fallbacks must be "default" or a list`) +} diff --git a/pkg/model/provider/anthropic/thinking_binding.go b/pkg/model/provider/anthropic/thinking_binding.go index 2089cd069f..b1bcbe8d92 100644 --- a/pkg/model/provider/anthropic/thinking_binding.go +++ b/pkg/model/provider/anthropic/thinking_binding.go @@ -11,6 +11,9 @@ import ( ) func validateThinkingOptions(cfg *latest.ModelConfig) error { + if err := validateFallbackSpec(cfg); err != nil { + return err + } if err := validateFallbacks(cfg); err != nil { return err } diff --git a/pkg/modelinfo/claude5.go b/pkg/modelinfo/claude5.go index 17e36ac8c7..0e6edcffa2 100644 --- a/pkg/modelinfo/claude5.go +++ b/pkg/modelinfo/claude5.go @@ -17,10 +17,14 @@ type Claude5Model struct { // BedrockMantleOnly reports that AWS serves the model only through // Bedrock Mantle, not the runtime/Converse API docker-agent uses. BedrockMantleOnly bool + // NoServerSideFallback reports that the Claude API has no server-side + // fallback for the model: `fallbacks: "default"` has no effect and a + // list is a 400. + NoServerSideFallback bool } var claude5Models = map[string]Claude5Model{ - "claude-haiku-5-5": {CanDisableThinking: true, ChecksThinkingPrefix: true}, + "claude-haiku-5-5": {CanDisableThinking: true, ChecksThinkingPrefix: true, NoServerSideFallback: true}, "claude-opus-5": {CanDisableThinking: true}, "claude-opus-5-5": {ChecksThinkingPrefix: true}, "claude-sonnet-5": {CanDisableThinking: true}, diff --git a/pkg/modelinfo/claude5_test.go b/pkg/modelinfo/claude5_test.go index b82c090083..f098b9989c 100644 --- a/pkg/modelinfo/claude5_test.go +++ b/pkg/modelinfo/claude5_test.go @@ -16,7 +16,7 @@ func TestLookupClaude5(t *testing.T) { id string want Claude5Model }{ - {"claude-haiku-5-5", Claude5Model{CanDisableThinking: true, ChecksThinkingPrefix: true}}, + {"claude-haiku-5-5", Claude5Model{CanDisableThinking: true, ChecksThinkingPrefix: true, NoServerSideFallback: true}}, {"claude-opus-5", Claude5Model{CanDisableThinking: true}}, {"claude-opus-5-5", Claude5Model{ChecksThinkingPrefix: true}}, {"claude-sonnet-5", Claude5Model{CanDisableThinking: true}}, @@ -31,7 +31,7 @@ func TestLookupClaude5(t *testing.T) { {"global.anthropic.claude-fable-5-1", Claude5Model{ChecksThinkingPrefix: true}}, {"claude-opus-5-5@20261001", Claude5Model{ChecksThinkingPrefix: true}}, {"vendor/claude-fable-5-1", Claude5Model{ChecksThinkingPrefix: true}}, - {"CLAUDE-HAIKU-5-5", Claude5Model{CanDisableThinking: true, ChecksThinkingPrefix: true}}, + {"CLAUDE-HAIKU-5-5", Claude5Model{CanDisableThinking: true, ChecksThinkingPrefix: true, NoServerSideFallback: true}}, } for _, tc := range known { t.Run(tc.id, func(t *testing.T) { From 08f31b7919da4f1467e59f4a6caa3533234061f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 21 Oct 2026 18:21:31 +0200 Subject: [PATCH 2/7] refactor(anthropic): read fallbacks through one parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every reader of the fallbacks option now understands "default". Its targets are picked by the server, so per-model checks validate the primary only, and the temporary Claude 5.x block now also catches "default" on a 5.x model. Signed-off-by: Arnaud Héritier --- .../provider/anthropic/conversation_updates.go | 2 +- .../anthropic/conversation_updates_test.go | 1 + pkg/model/provider/anthropic/fallbacks.go | 14 +++++++++++--- pkg/model/provider/anthropic/fallbacks_test.go | 2 ++ pkg/model/provider/anthropic/thinking.go | 6 +----- pkg/model/provider/anthropic/thinking_binding.go | 4 +--- .../provider/anthropic/thinking_binding_test.go | 1 + pkg/model/provider/anthropic/thinking_test.go | 6 ++++++ 8 files changed, 24 insertions(+), 12 deletions(-) diff --git a/pkg/model/provider/anthropic/conversation_updates.go b/pkg/model/provider/anthropic/conversation_updates.go index 24a1b2d1e9..6e73f29ee5 100644 --- a/pkg/model/provider/anthropic/conversation_updates.go +++ b/pkg/model/provider/anthropic/conversation_updates.go @@ -48,7 +48,7 @@ func validateCachePreservingUpdates(cfg *latest.ModelConfig) error { if !cachePreservingUpdatesEnabled(cfg.ProviderOpts) { return nil } - fallbacks, _ := providerutil.GetProviderOptStringSlice(cfg.ProviderOpts, "fallbacks") + fallbacks := fallbackTargets(cfg.ProviderOpts) for _, model := range append([]string{cfg.Model}, fallbacks...) { if !supportsConversationUpdates(model) { return fmt.Errorf("anthropic: model %q does not support %s (requires Claude Opus 4.8+, Fable 5+ or Mythos 5+)", model, cachePreservingUpdatesOpt) diff --git a/pkg/model/provider/anthropic/conversation_updates_test.go b/pkg/model/provider/anthropic/conversation_updates_test.go index 2facd092dc..e89ffc1cf5 100644 --- a/pkg/model/provider/anthropic/conversation_updates_test.go +++ b/pkg/model/provider/anthropic/conversation_updates_test.go @@ -994,6 +994,7 @@ func TestValidateCachePreservingUpdates(t *testing.T) { {name: "supported", cfg: latest.ModelConfig{Model: "claude-opus-5", ProviderOpts: map[string]any{cachePreservingUpdatesOpt: true}}}, {name: "sonnet 5", cfg: latest.ModelConfig{Model: "claude-sonnet-5", ProviderOpts: map[string]any{cachePreservingUpdatesOpt: true}}, wantErr: `"claude-sonnet-5" does not support cache_preserving_updates`}, {name: "unsupported fallback", cfg: latest.ModelConfig{Model: "claude-fable-5-1", ProviderOpts: map[string]any{cachePreservingUpdatesOpt: true, "fallbacks": []any{"claude-opus-4-8", "claude-sonnet-4-6"}}}, wantErr: `"claude-sonnet-4-6" does not support`}, + {name: "default fallbacks check the primary only", cfg: latest.ModelConfig{Model: "claude-fable-5-1", ProviderOpts: map[string]any{cachePreservingUpdatesOpt: true, "fallbacks": "default"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/model/provider/anthropic/fallbacks.go b/pkg/model/provider/anthropic/fallbacks.go index 7016998837..c79d310091 100644 --- a/pkg/model/provider/anthropic/fallbacks.go +++ b/pkg/model/provider/anthropic/fallbacks.go @@ -67,6 +67,14 @@ func parseFallbacks(opts map[string]any) (fallbackSpec, error) { return fallbackSpec{Models: models}, nil } +// fallbackTargets returns the explicit fallback models; none for "default", +// whose targets the server picks. Construction has already validated the +// option, so a malformed value reads as none. +func fallbackTargets(opts map[string]any) []string { + spec, _ := parseFallbacks(opts) + return spec.Models +} + // validateFallbackSpec checks the rules the API enforces with a 400, so a bad // config fails at client construction. func validateFallbackSpec(cfg *latest.ModelConfig) error { @@ -119,11 +127,11 @@ func fallbacksBody(models []string) option.RequestOption { // the client does not handle yet. Malformed lists stay ignored, as in // [fallbackModels]. func validateFallbacks(cfg *latest.ModelConfig) error { - fallbacks, ok := providerutil.GetProviderOptStringSlice(cfg.ProviderOpts, "fallbacks") - if !ok || len(fallbacks) == 0 { + spec, _ := parseFallbacks(cfg.ProviderOpts) + if !spec.Default && len(spec.Models) == 0 { return nil } - for _, model := range append([]string{cfg.Model}, fallbacks...) { + for _, model := range append([]string{cfg.Model}, spec.Models...) { if _, ok := modelinfo.LookupClaude5(model); ok { return fmt.Errorf("anthropic: server-side fallback involving Claude 5.x model %q is not supported yet (see https://github.com/docker/docker-agent/issues/4584)", model) } diff --git a/pkg/model/provider/anthropic/fallbacks_test.go b/pkg/model/provider/anthropic/fallbacks_test.go index f403ef47e6..91cbe0ec39 100644 --- a/pkg/model/provider/anthropic/fallbacks_test.go +++ b/pkg/model/provider/anthropic/fallbacks_test.go @@ -49,6 +49,8 @@ func TestValidateFallbacks(t *testing.T) { {name: "empty", model: "claude-fable-5-1", fallbacks: []any{}}, {name: "no 5.x model", model: "claude-opus-4-8", fallbacks: []any{"claude-opus-4-7"}}, {name: "malformed is ignored", model: "claude-fable-5-1", fallbacks: "not-a-list"}, + {name: "default on 5.x primary", model: "claude-fable-5-1", fallbacks: "default", wantModel: "claude-fable-5-1"}, + {name: "default on legacy primary", model: "claude-opus-4-8", fallbacks: "default"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/model/provider/anthropic/thinking.go b/pkg/model/provider/anthropic/thinking.go index c5ad5643fa..c79ac397cf 100644 --- a/pkg/model/provider/anthropic/thinking.go +++ b/pkg/model/provider/anthropic/thinking.go @@ -9,7 +9,6 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/effort" - "github.com/docker/docker-agent/pkg/model/provider/providerutil" "github.com/docker/docker-agent/pkg/modelinfo" ) @@ -278,10 +277,7 @@ func validateThinkingDisplay(cfg *latest.ModelConfig) error { if !ok || (display != thinkingDisplayDisplay && display != thinkingDisplayUpdates) { return nil } - // Read fallbacks directly (not via fallbackModels) to avoid its - // "enabling server-side fallbacks" debug log during validation. - fallbacks, _ := providerutil.GetProviderOptStringSlice(cfg.ProviderOpts, "fallbacks") - for _, model := range append([]string{cfg.Model}, fallbacks...) { + for _, model := range append([]string{cfg.Model}, fallbackTargets(cfg.ProviderOpts)...) { if display == thinkingDisplayUpdates { if !supportsProgressUpdates(model) { return fmt.Errorf("anthropic: model %q does not support thinking_display: updates", model) diff --git a/pkg/model/provider/anthropic/thinking_binding.go b/pkg/model/provider/anthropic/thinking_binding.go index b1bcbe8d92..b814c972ae 100644 --- a/pkg/model/provider/anthropic/thinking_binding.go +++ b/pkg/model/provider/anthropic/thinking_binding.go @@ -7,7 +7,6 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/docker/docker-agent/pkg/config/latest" - "github.com/docker/docker-agent/pkg/model/provider/providerutil" ) func validateThinkingOptions(cfg *latest.ModelConfig) error { @@ -35,8 +34,7 @@ func (c *Client) thinkingBindingBehavior() string { if value, ok := c.ModelConfig.ProviderOpts["thinking_prefix_mismatch"].(string); ok { return value } - fallbacks, _ := providerutil.GetProviderOptStringSlice(c.ModelConfig.ProviderOpts, "fallbacks") - if slices.ContainsFunc(append([]string{c.ModelConfig.Model}, fallbacks...), checksThinkingPrefix) { + if slices.ContainsFunc(append([]string{c.ModelConfig.Model}, fallbackTargets(c.ModelConfig.ProviderOpts)...), checksThinkingPrefix) { return "drop_block" } return "" diff --git a/pkg/model/provider/anthropic/thinking_binding_test.go b/pkg/model/provider/anthropic/thinking_binding_test.go index 4bd0a75ee5..29585f91ca 100644 --- a/pkg/model/provider/anthropic/thinking_binding_test.go +++ b/pkg/model/provider/anthropic/thinking_binding_test.go @@ -31,6 +31,7 @@ func TestThinkingBindingBehavior(t *testing.T) { "sonnet 5": {model: "claude-sonnet-5"}, "opus 5": {model: "claude-opus-5"}, "fallback checks": {model: "claude-opus-4-7", opts: map[string]any{"fallbacks": []any{"claude-sonnet-4-6", "claude-fable-5-2"}}, want: "drop_block"}, + "default fallbacks keep primary rule": {model: "claude-opus-4-7", opts: map[string]any{"fallbacks": "default"}}, "explicit error": {model: "claude-fable-5-1", opts: map[string]any{"thinking_prefix_mismatch": "error"}, want: "error"}, "explicit on old": {model: "claude-sonnet-4-6", opts: map[string]any{"thinking_prefix_mismatch": "drop_block"}, want: "drop_block"}, "non-string is ignored": {model: "claude-sonnet-4-6", opts: map[string]any{"thinking_prefix_mismatch": 1}}, diff --git a/pkg/model/provider/anthropic/thinking_test.go b/pkg/model/provider/anthropic/thinking_test.go index d130921b88..c29785aba9 100644 --- a/pkg/model/provider/anthropic/thinking_test.go +++ b/pkg/model/provider/anthropic/thinking_test.go @@ -164,6 +164,12 @@ func TestValidateThinkingDisplay(t *testing.T) { }, wantErr: `model "claude-sonnet-4-6" does not support thinking_display: "display"`, }, + { + // The server picks default targets, so only the primary is checked. + name: "display with default fallbacks", + model: "claude-sonnet-4-5", + opts: map[string]any{"thinking_display": "display", "fallbacks": "default"}, + }, } for _, tt := range tests { From 381428475f64969f43611846057f31d37ea918b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 21 Oct 2026 18:23:27 +0200 Subject: [PATCH 3/7] feat: allow server-side fallback only on the Claude API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallbacks parameter is a Claude API beta: Bedrock, Google Cloud and proxies don't support it. Reject a non-empty fallbacks at construction on Vertex AI, Bedrock, a gateway, or a base_url other than api.anthropic.com, instead of sending or silently ignoring it. Signed-off-by: Arnaud Héritier --- pkg/model/provider/anthropic/client.go | 4 +++ pkg/model/provider/anthropic/fallbacks.go | 24 +++++++++++++++ .../provider/anthropic/fallbacks_test.go | 30 +++++++++++++++++++ pkg/model/provider/anthropic/vertex/vertex.go | 3 ++ .../provider/anthropic/vertex/vertex_test.go | 12 ++++---- pkg/model/provider/bedrock/client.go | 6 ++++ pkg/model/provider/bedrock/client_test.go | 14 +++++++++ .../provider/providerutil/provider_opts.go | 17 +++++++++++ .../providerutil/provider_opts_test.go | 12 ++++++++ 9 files changed, 116 insertions(+), 6 deletions(-) diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index ad2e9a754a..6e9d1abf10 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -66,6 +66,10 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro return nil, err } globalOptions := options.Apply(opts...) + if err := validateFallbackHost(cfg, globalOptions.Gateway()); err != nil { + slog.ErrorContext(ctx, "Anthropic client creation failed", "error", err) + return nil, err + } anthropicClient := &Client{ Config: base.Config{ diff --git a/pkg/model/provider/anthropic/fallbacks.go b/pkg/model/provider/anthropic/fallbacks.go index c79d310091..6332aa8cc0 100644 --- a/pkg/model/provider/anthropic/fallbacks.go +++ b/pkg/model/provider/anthropic/fallbacks.go @@ -1,8 +1,10 @@ package anthropic import ( + "errors" "fmt" "log/slog" + "net/url" "slices" "github.com/anthropics/anthropic-sdk-go" @@ -28,6 +30,28 @@ type fallbackSpec struct { Models []string } +func (s fallbackSpec) enabled() bool { return s.Default || len(s.Models) > 0 } + +// claudeAPIHost is the only host that serves server-side fallback. +const claudeAPIHost = "api.anthropic.com" + +// validateFallbackHost rejects fallbacks unless requests go straight to the +// Claude API: other platforms and proxies don't support the parameter. +func validateFallbackHost(cfg *latest.ModelConfig, gateway string) error { + if spec, _ := parseFallbacks(cfg.ProviderOpts); !spec.enabled() { + return nil + } + if gateway != "" { + return errors.New("anthropic: fallbacks requires the Claude API and is not available through a gateway") + } + if cfg.BaseURL != "" { + if u, err := url.Parse(cfg.BaseURL); err != nil || u.Hostname() != claudeAPIHost { + return fmt.Errorf("anthropic: fallbacks requires the Claude API and is not available through base_url %q", cfg.BaseURL) + } + } + return nil +} + // parseFallbacks reads the `fallbacks` provider_opt: "default", or a list of // at most three distinct model IDs. An absent or empty value disables // fallbacks. diff --git a/pkg/model/provider/anthropic/fallbacks_test.go b/pkg/model/provider/anthropic/fallbacks_test.go index 91cbe0ec39..4aea1be37b 100644 --- a/pkg/model/provider/anthropic/fallbacks_test.go +++ b/pkg/model/provider/anthropic/fallbacks_test.go @@ -8,6 +8,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider/options" ) func TestFallbackModels(t *testing.T) { @@ -158,3 +159,32 @@ func TestNewClient_RejectsMalformedFallbacks(t *testing.T) { _, err := NewClient(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"ANTHROPIC_API_KEY": "k"})) require.ErrorContains(t, err, `fallbacks must be "default" or a list`) } + +func TestNewClient_FallbacksOnlyOnClaudeAPI(t *testing.T) { + t.Parallel() + + env := environment.NewMapEnvProvider(map[string]string{"ANTHROPIC_API_KEY": "k"}) + for name, tc := range map[string]struct { + baseURL string + opts []options.Opt + wantErr string + }{ + "claude api": {}, + "explicit claude api": {baseURL: "https://api.anthropic.com"}, + "custom base_url": {baseURL: "https://proxy.example.com", wantErr: "requires the Claude API"}, + "gateway": {opts: []options.Opt{options.WithGateway("https://gateway.example.com")}, wantErr: "requires the Claude API"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + for _, fallbacks := range []any{"default", []any{"claude-sonnet-4-6"}} { + cfg := &latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-8", BaseURL: tc.baseURL, ProviderOpts: map[string]any{"fallbacks": fallbacks}} + _, err := NewClient(t.Context(), cfg, env, tc.opts...) + if tc.wantErr == "" { + require.NoError(t, err) + continue + } + require.ErrorContains(t, err, tc.wantErr) + } + }) + } +} diff --git a/pkg/model/provider/anthropic/vertex/vertex.go b/pkg/model/provider/anthropic/vertex/vertex.go index acfa95cdcb..2e22e3e15d 100644 --- a/pkg/model/provider/anthropic/vertex/vertex.go +++ b/pkg/model/provider/anthropic/vertex/vertex.go @@ -62,6 +62,9 @@ func newClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro return nil, fmt.Errorf("%s requires the Claude API and is not available on Vertex AI", name) } } + if providerutil.HasProviderOpt(cfg.ProviderOpts, "fallbacks") { + return nil, errors.New("fallbacks requires the Claude API and is not available on Vertex AI") + } if env == nil { return nil, errors.New("environment provider is required") } diff --git a/pkg/model/provider/anthropic/vertex/vertex_test.go b/pkg/model/provider/anthropic/vertex/vertex_test.go index e0a9c94cf5..9ea77a91eb 100644 --- a/pkg/model/provider/anthropic/vertex/vertex_test.go +++ b/pkg/model/provider/anthropic/vertex/vertex_test.go @@ -191,13 +191,13 @@ func TestRejectClaudeAPIOnlyFeatures(t *testing.T) { } } -func TestNewClient_RejectsClaude5Fallbacks(t *testing.T) { +func TestNewClient_RejectsFallbacks(t *testing.T) { t.Parallel() - - cfg := &latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-8", ProviderOpts: map[string]any{"fallbacks": []any{"claude-fable-5"}}} - source := func(context.Context) (string, error) { return "token", nil } - _, err := NewClientWithTokenSource(t.Context(), cfg, environment.NewMapEnvProvider(nil), "test-project", "us-east5", source) - require.ErrorContains(t, err, "issues/4584") + for _, fallbacks := range []any{"default", []any{"claude-sonnet-4-6"}} { + cfg := &latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-8", ProviderOpts: map[string]any{"fallbacks": fallbacks}} + _, err := NewClient(t.Context(), cfg, environment.NewMapEnvProvider(nil), "project", "location") + require.ErrorContains(t, err, "requires the Claude API") + } } // Vertex cannot enable native compaction, so a block recorded on the Claude diff --git a/pkg/model/provider/bedrock/client.go b/pkg/model/provider/bedrock/client.go index ce2337aea4..44b1d63556 100644 --- a/pkg/model/provider/bedrock/client.go +++ b/pkg/model/provider/bedrock/client.go @@ -50,6 +50,12 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro return nil, err } + if providerutil.HasProviderOpt(cfg.ProviderOpts, "fallbacks") { + err := errors.New("amazon-bedrock: fallbacks requires the Claude API and is not available on Bedrock") + slog.ErrorContext(ctx, "Bedrock client creation failed", "error", err) + return nil, err + } + globalOptions := options.Apply(opts...) // Check for bearer token diff --git a/pkg/model/provider/bedrock/client_test.go b/pkg/model/provider/bedrock/client_test.go index b0f7ca9fd6..59f21514d0 100644 --- a/pkg/model/provider/bedrock/client_test.go +++ b/pkg/model/provider/bedrock/client_test.go @@ -1566,3 +1566,17 @@ func TestNewClient_RefusesMythos5(t *testing.T) { require.NoError(t, err) assert.NotNil(t, client) } + +func TestNewClient_RejectsFallbacks(t *testing.T) { + t.Parallel() + + env := environment.NewMapEnvProvider(map[string]string{"AWS_BEARER_TOKEN_BEDROCK": "token", "AWS_REGION": "us-east-1"}) + for _, fallbacks := range []any{"default", []any{"anthropic.claude-sonnet-4-6"}} { + _, err := NewClient(t.Context(), &latest.ModelConfig{Provider: "amazon-bedrock", Model: "anthropic.claude-opus-4-8", ProviderOpts: map[string]any{"fallbacks": fallbacks}}, env) + require.ErrorContains(t, err, "requires the Claude API") + } + + client, err := NewClient(t.Context(), &latest.ModelConfig{Provider: "amazon-bedrock", Model: "anthropic.claude-opus-4-8", ProviderOpts: map[string]any{"fallbacks": []any{}}}, env) + require.NoError(t, err, "an empty list is no fallback") + assert.NotNil(t, client) +} diff --git a/pkg/model/provider/providerutil/provider_opts.go b/pkg/model/provider/providerutil/provider_opts.go index d2bf7f168d..66b45dbcf6 100644 --- a/pkg/model/provider/providerutil/provider_opts.go +++ b/pkg/model/provider/providerutil/provider_opts.go @@ -146,3 +146,20 @@ var samplingProviderOptsKeys = []string{ func SamplingProviderOptsKeys() []string { return slices.Clone(samplingProviderOptsKeys) } + +// HasProviderOpt reports whether key is set to a non-empty value: absent, +// nil, "" and an empty list all count as unset. +func HasProviderOpt(opts map[string]any, key string) bool { + switch v := opts[key].(type) { + case nil: + return false + case string: + return v != "" + case []any: + return len(v) > 0 + case []string: + return len(v) > 0 + default: + return true + } +} diff --git a/pkg/model/provider/providerutil/provider_opts_test.go b/pkg/model/provider/providerutil/provider_opts_test.go index 5152cfa566..1b6063dd82 100644 --- a/pkg/model/provider/providerutil/provider_opts_test.go +++ b/pkg/model/provider/providerutil/provider_opts_test.go @@ -118,3 +118,15 @@ func TestGetProviderOptInt64(t *testing.T) { }) } } + +func TestHasProviderOpt(t *testing.T) { + t.Parallel() + for value, want := range map[any]bool{"default": true, "": false, 1: true} { + assert.Equal(t, want, HasProviderOpt(map[string]any{"k": value}, "k"), "%v", value) + } + assert.False(t, HasProviderOpt(nil, "k")) + assert.False(t, HasProviderOpt(map[string]any{"k": nil}, "k")) + assert.False(t, HasProviderOpt(map[string]any{"k": []any{}}, "k")) + assert.False(t, HasProviderOpt(map[string]any{"k": []string{}}, "k")) + assert.True(t, HasProviderOpt(map[string]any{"k": []any{"a"}}, "k")) +} From fd3fc9ec7b82576e89ab5a94e85b21c652a4a26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 21 Oct 2026 18:26:01 +0200 Subject: [PATCH 4/7] feat(anthropic): send server-side fallbacks with the July beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send fallbacks as "default" or a list of models under server-side-fallback-2026-07-01, which accepts both forms; June lists keep working unchanged. Requests with fallbacks go through the beta Messages API, whose types carry the fallback content block. Signed-off-by: Arnaud Héritier --- pkg/model/provider/anthropic/beta_client.go | 13 ++--- pkg/model/provider/anthropic/client.go | 11 ++-- pkg/model/provider/anthropic/fallbacks.go | 52 +++++++------------ .../provider/anthropic/fallbacks_test.go | 22 -------- .../providers/claude5_requests_test.go | 3 +- .../providers/fallback_requests_test.go | 45 ++++++++++++++++ 6 files changed, 75 insertions(+), 71 deletions(-) create mode 100644 pkg/model/provider/providers/fallback_requests_test.go diff --git a/pkg/model/provider/anthropic/beta_client.go b/pkg/model/provider/anthropic/beta_client.go index 730c74acea..864f05d007 100644 --- a/pkg/model/provider/anthropic/beta_client.go +++ b/pkg/model/provider/anthropic/beta_client.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/packages/param" "github.com/anthropics/anthropic-sdk-go/packages/ssestream" @@ -118,13 +117,9 @@ func (c *Client) createBetaStream( slog.DebugContext(ctx, "Anthropic Beta provider_opts: set top_k", "value", topK) } - // Enable server-side fallbacks when the fallbacks provider_opt is set; - // the beta is appended to params.Betas so the SDK composes a single - // anthropic-beta header. - var requestOpts []option.RequestOption - if fallbacks := fallbackModels(c.ModelConfig.ProviderOpts); len(fallbacks) > 0 { + if spec, _ := parseFallbacks(c.ModelConfig.ProviderOpts); spec.enabled() { params.Betas = append(params.Betas, serverSideFallbackBeta) - requestOpts = append(requestOpts, fallbacksBody(fallbacks)) + params.Fallbacks = spec.param() } configureCacheDiagnostics(¶ms, messages, c.ModelConfig.ProviderOpts) @@ -139,7 +134,7 @@ func (c *Client) createBetaStream( } requestContext = updates.RequestContext } - stream := client.Beta.Messages.NewStreaming(ctx, params, requestOpts...) + stream := client.Beta.Messages.NewStreaming(ctx, params) trackUsage := c.TrackUsageEnabled() ad := c.newBetaStreamAdapter(stream, trackUsage) ad.requestContext = requestContext @@ -159,7 +154,7 @@ func (c *Client) createBetaStream( slog.WarnContext(ctx, "Retrying with clamped max_tokens after context length error", "original", maxTokens, "clamped", newMaxTokens, "used", used) retryParams := params retryParams.MaxTokens = newMaxTokens - return client.Beta.Messages.NewStreaming(ctx, retryParams, requestOpts...) + return client.Beta.Messages.NewStreaming(ctx, retryParams) } slog.DebugContext(ctx, "Anthropic Beta API chat completion stream created successfully", "model", c.ModelConfig.Model) diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 6e9d1abf10..ede36c325f 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -262,11 +262,13 @@ func (c *Client) CreateChatCompletionStream( // - interleaved thinking // - structured output (requires beta header) // - task_budget (requires the task-budgets beta header) + // - server-side fallbacks (only the beta types carry fallback blocks) if c.interleavedThinkingEnabled() || c.ModelOptions.StructuredOutput() != nil || !c.ModelConfig.TaskBudget.IsZero() || c.thinkingBindingBehavior() != "" || c.progressUpdatesEnabled() || - cacheDiagnosticsEnabled(c.ModelConfig.ProviderOpts) || cachePreservingUpdatesEnabled(c.ModelConfig.ProviderOpts) || c.hasNativeCompaction(messages) { + cacheDiagnosticsEnabled(c.ModelConfig.ProviderOpts) || cachePreservingUpdatesEnabled(c.ModelConfig.ProviderOpts) || c.hasNativeCompaction(messages) || + fallbacksEnabled(c.ModelConfig.ProviderOpts) { return c.createBetaStream(ctx, client, messages, requestTools, maxTokens) } @@ -345,12 +347,7 @@ func (c *Client) CreateChatCompletionStream( // Add fine-grained tool streaming beta header; the standard Messages API // composes the anthropic-beta header manually (no params.Betas field). betas := "fine-grained-tool-streaming-2025-05-14" - var requestOpts []option.RequestOption - if fallbacks := fallbackModels(c.ModelConfig.ProviderOpts); len(fallbacks) > 0 { - betas += "," + serverSideFallbackBeta - requestOpts = append(requestOpts, fallbacksBody(fallbacks)) - } - requestOpts = append(requestOpts, option.WithHeader("anthropic-beta", betas)) + requestOpts := []option.RequestOption{option.WithHeader("anthropic-beta", betas)} stream := client.Messages.NewStreaming(ctx, params, requestOpts...) trackUsage := c.TrackUsageEnabled() diff --git a/pkg/model/provider/anthropic/fallbacks.go b/pkg/model/provider/anthropic/fallbacks.go index 6332aa8cc0..021023e2c5 100644 --- a/pkg/model/provider/anthropic/fallbacks.go +++ b/pkg/model/provider/anthropic/fallbacks.go @@ -8,17 +8,15 @@ import ( "slices" "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/option" + "github.com/anthropics/anthropic-sdk-go/shared/constant" "github.com/docker/docker-agent/pkg/config/latest" - "github.com/docker/docker-agent/pkg/model/provider/providerutil" "github.com/docker/docker-agent/pkg/modelinfo" ) -// serverSideFallbackBeta enables the `fallbacks` request parameter, which -// lets Anthropic reroute refused requests to backup models in a single round -// trip. -const serverSideFallbackBeta anthropic.AnthropicBeta = "server-side-fallback-2026-06-01" +// serverSideFallbackBeta enables the `fallbacks` request parameter. The July +// date accepts both "default" and explicit lists. +const serverSideFallbackBeta = anthropic.AnthropicBetaServerSideFallback2026_07_01 // maxFallbacks is the longest list the API accepts. const maxFallbacks = 3 @@ -32,6 +30,22 @@ type fallbackSpec struct { func (s fallbackSpec) enabled() bool { return s.Default || len(s.Models) > 0 } +func (s fallbackSpec) param() anthropic.BetaFallbacksParamUnion { + if s.Default { + return anthropic.BetaFallbacksParamUnion{OfDefault: constant.ValueOf[constant.Default]()} + } + list := make([]anthropic.BetaFallbackParam, len(s.Models)) + for i, m := range s.Models { + list[i] = anthropic.BetaFallbackParam{Model: m} + } + return anthropic.BetaFallbacksParamUnion{OfBetaFallbackArray: list} +} + +func fallbacksEnabled(opts map[string]any) bool { + spec, _ := parseFallbacks(opts) + return spec.enabled() +} + // claudeAPIHost is the only host that serves server-side fallback. const claudeAPIHost = "api.anthropic.com" @@ -120,32 +134,6 @@ func validateFallbackSpec(cfg *latest.ModelConfig) error { return nil } -// fallbackModels extracts the `fallbacks` provider_opt: a list of model IDs, -// in priority order. Returns nil when the option is absent or malformed. -// -// Fallback models receive the exact same request as the primary model -// (thinking configuration, task budget, beta features, ...), so they must -// accept the same request shape — Anthropic rejects the fallback attempt -// otherwise. -func fallbackModels(opts map[string]any) []string { - models, ok := providerutil.GetProviderOptStringSlice(opts, "fallbacks") - if !ok || len(models) == 0 { - return nil - } - slog.Debug("Anthropic provider_opts: enabling server-side fallbacks", "models", models) - return models -} - -// fallbacksBody returns the request option that injects the `fallbacks` body -// field. The wire shape is [{"model": "..."}, ...]. -func fallbacksBody(models []string) option.RequestOption { - fallbacks := make([]map[string]string, len(models)) - for i, m := range models { - fallbacks[i] = map[string]string{"model": m} - } - return option.WithJSONSet("fallbacks", fallbacks) -} - // validateFallbacks rejects server-side fallback when any model in the chain // is Claude 5.x: a fallback can change the serving model mid-response, which // the client does not handle yet. Malformed lists stay ignored, as in diff --git a/pkg/model/provider/anthropic/fallbacks_test.go b/pkg/model/provider/anthropic/fallbacks_test.go index 4aea1be37b..4c77e5529e 100644 --- a/pkg/model/provider/anthropic/fallbacks_test.go +++ b/pkg/model/provider/anthropic/fallbacks_test.go @@ -11,28 +11,6 @@ import ( "github.com/docker/docker-agent/pkg/model/provider/options" ) -func TestFallbackModels(t *testing.T) { - t.Parallel() - t.Run("absent", func(t *testing.T) { - assert.Nil(t, fallbackModels(nil)) - assert.Nil(t, fallbackModels(map[string]any{"top_k": 40})) - }) - - t.Run("empty list", func(t *testing.T) { - assert.Nil(t, fallbackModels(map[string]any{"fallbacks": []any{}})) - }) - - t.Run("invalid type", func(t *testing.T) { - assert.Nil(t, fallbackModels(map[string]any{"fallbacks": "claude-opus-4-8"})) - }) - - t.Run("configured", func(t *testing.T) { - assert.Equal(t, - []string{"claude-opus-4-8", "claude-sonnet-4-6"}, - fallbackModels(map[string]any{"fallbacks": []any{"claude-opus-4-8", "claude-sonnet-4-6"}})) - }) -} - func TestValidateFallbacks(t *testing.T) { t.Parallel() diff --git a/pkg/model/provider/providers/claude5_requests_test.go b/pkg/model/provider/providers/claude5_requests_test.go index 5c1da1ffba..0eeadce236 100644 --- a/pkg/model/provider/providers/claude5_requests_test.go +++ b/pkg/model/provider/providers/claude5_requests_test.go @@ -27,6 +27,7 @@ func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { type recorded struct { path string + query string header http.Header body map[string]any } @@ -58,7 +59,7 @@ func captureRequest(t *testing.T, history []chat.Message, build func(wrapper opt wrapper := options.WithHTTPTransportWrapper(func(http.RoundTripper) http.RoundTripper { return roundTripperFunc(func(req *http.Request) (*http.Response, error) { raw, _ := io.ReadAll(req.Body) - r := recorded{path: req.URL.Path, header: req.Header.Clone()} + r := recorded{path: req.URL.Path, query: req.URL.RawQuery, header: req.Header.Clone()} _ = json.Unmarshal(raw, &r.body) mu.Lock() reqs = append(reqs, r) diff --git a/pkg/model/provider/providers/fallback_requests_test.go b/pkg/model/provider/providers/fallback_requests_test.go new file mode 100644 index 0000000000..3ed10d9d89 --- /dev/null +++ b/pkg/model/provider/providers/fallback_requests_test.go @@ -0,0 +1,45 @@ +package providers_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/docker/docker-agent/pkg/config/latest" +) + +// Server-side fallback is a beta of the Messages API: the request goes +// through the beta client with the July header, which accepts both forms. +func TestServerSideFallbackRequests(t *testing.T) { + t.Parallel() + + env := map[string]string{"ANTHROPIC_API_KEY": "test-key"} + for name, tc := range map[string]struct { + fallbacks any + want any + }{ + "default": {fallbacks: "default", want: "default"}, + "list": {fallbacks: []any{"claude-sonnet-4-6"}, want: []any{map[string]any{"model": "claude-sonnet-4-6"}}}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + req := captureFinalRequest(t, latest.ModelConfig{ + Provider: "anthropic", Model: "claude-opus-4-8", + ProviderOpts: map[string]any{"fallbacks": tc.fallbacks}, + }, env) + assert.Equal(t, "beta=true", req.query, "beta Messages API") + assert.Contains(t, strings.Join(req.header.Values("anthropic-beta"), ","), "server-side-fallback-2026-07-01") + assert.Equal(t, tc.want, req.body["fallbacks"]) + }) + } + + t.Run("none", func(t *testing.T) { + t.Parallel() + req := captureFinalRequest(t, latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-8"}, env) + assert.NotContains(t, req.body, "fallbacks") + for _, beta := range req.header.Values("anthropic-beta") { + assert.NotContains(t, beta, "server-side-fallback") + } + }) +} From d9046ca0635f1a957364a58dc80bd118e5e89f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 21 Oct 2026 18:27:26 +0200 Subject: [PATCH 5/7] docs(anthropic): document July server-side fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover fallbacks: default and lists of up to three models, the July beta, the Claude API-only restriction and the Haiku 5.5 rules, in the provider docs, the schema description and the example. Signed-off-by: Arnaud Héritier --- agent-schema.json | 2 +- docs/providers/anthropic/index.md | 38 +++++++++++++++++++++++-------- examples/anthropic-fallbacks.yaml | 17 +++++++++++--- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index 1f74161acd..eab152f6ab 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -1756,7 +1756,7 @@ "additionalProperties": true } }, - "description": "Provider-specific options. Anthropic: thinking_prefix_mismatch (error or drop_block; defaults to drop_block on Claude models whose server checks thinking prefixes: Haiku 5.5, Opus 5.5, Sonnet 5.5 and Fable 5.1, plus Fable 5.2+ by name), strict_tools (true for eligible schemas or a tool-name array requiring strict mode), cache_diagnostics (boolean; Claude API only), native_compaction (boolean; synchronous whole-history signed compaction on Claude API), cache_preserving_updates (boolean; native system/tool/effort changes with turn-scoped reminders on supported models), thinking_display: updates (progress updates beta on Fable 5+ and Mythos 5.1). Sampling parameters: top_k (integer, supported by anthropic, google, amazon-bedrock, and custom OpenAI-compatible providers like vLLM/Ollama), repetition_penalty (float, forwarded to custom OpenAI-compatible providers), min_p (float, forwarded to custom providers), seed (integer, forwarded to OpenAI). Lifecycle: unload_api (string) overrides the unload endpoint inherited from the provider config (relative path resolved against base_url's scheme+host, or an absolute URL); used by the runtime's `unload` on_agent_switch builtin hook to release model resources between agent switches. Infrastructure options: http_headers (map of string to string, adds custom HTTP headers to every request; used for OpenAI-compatible providers like github-copilot which requires Copilot-Integration-Id). dmr: runtime_flags; supports_images (boolean) and supports_pdf (boolean) declare which document attachment types the local model accepts. DMR-hosted models are not in the models.dev catalog, so attachment capabilities cannot be detected automatically and default to text-only; set supports_images: true for a vision model so image attachments are forwarded instead of silently dropped. anthropic/amazon-bedrock (Claude): interleaved_thinking (boolean, default true). anthropic: thinking_display ('summarized', 'omitted', 'updates', or 'display') controls whether thinking blocks are returned in responses when thinking is enabled. Claude Opus 4.7 hides thinking by default ('omitted'); set thinking_display: summarized to receive thinking blocks. 'display' (full thinking blocks) is only accepted by pre-4.6 token-thinking models (e.g. Sonnet 4.5, Haiku 4.5); models from the adaptive-thinking generation onward (Opus/Sonnet 4.6+, Sonnet 5, Fable 5) reject it, and docker-agent fails fast with a configuration error (fallbacks models are validated too); fallbacks (array of model ID strings, in priority order; enables the server-side-fallback beta so refused requests are retried with the listed models in a single round trip; rejected when the primary or any fallback is a Claude 5.x model until issue #4584). OpenAI-compatible APIs: service_tier (string, forwarded unchanged to Chat Completions and Responses, including WebSocket and reranking; 'fast' or 'priority' enables Fast mode at premium pricing on supported models; omitted or empty leaves the API default, non-string values are ignored). openai/chatgpt: preserve_reasoning (boolean, default false) preserves ordered Responses output and encrypted reasoning for stateless continuation; native_tool_search also enables output replay. Any OpenAI-compatible destination: replay_reasoning (boolean) replays the reasoning stored on assistant turns back as a reasoning_content field on later Chat Completions requests, so a reasoning model sees its own earlier reasoning instead of re-deriving it after each tool call (distinct from preserve_reasoning, which replays encrypted reasoning items on the Responses API and does not apply to Chat Completions); default true for Chat Completions; set replay_reasoning to false for an endpoint that rejects or must not receive the non-standard field; a non-boolean value also disables replay. openai: cache_diagnostics (boolean, default false) logs Responses prompt-cache comparison results with --debug on GPT-5.6+; comparisons are scoped to the current session and resolved model; ignored for chatgpt and custom base_url endpoints. transport ('sse' or 'websocket') to choose between SSE and WebSocket streaming for the Responses API; native_tool_search (boolean, default false; also enables ordered output replay) declares the agent's deferred tools with defer_loading and a server-executed tool_search tool so OpenAI's hosted tool search discovers them (only openai models supporting deferred tools, gpt-5.4+; ignored on chatgpt, OpenAI-compatible endpoints including a custom base_url, and Chat Completions requests). openai/anthropic/google: rerank_prompt (string) to fully override the system prompt used for RAG reranking (advanced - prefer using results.reranking.criteria for domain-specific guidance). Google: google_search (boolean) enables Google Search grounding, google_maps (boolean) enables Google Maps grounding, code_execution (boolean) enables server-side code execution, url_context (boolean) enables reading public URLs during generation, output_dimensionality (positive integer) truncates Gemini embedding vectors to that size when the model is used as a RAG embedding_model; service_tier (string, forwarded unchanged as the top-level serviceTier of every generateContent request, including reranking, title generation, and compaction, on the Gemini API, Vertex AI, and the Docker models gateway; 'flex' for discounted, queued processing that may wait up to 15 minutes before the first byte, 'standard' for the default tier, 'priority' for premium processing; omitted or empty leaves the API default, non-string values are ignored; cost estimates are not tier-adjusted, set the model's cost override).", + "description": "Provider-specific options. Anthropic: thinking_prefix_mismatch (error or drop_block; defaults to drop_block on Claude models whose server checks thinking prefixes: Haiku 5.5, Opus 5.5, Sonnet 5.5 and Fable 5.1, plus Fable 5.2+ by name), strict_tools (true for eligible schemas or a tool-name array requiring strict mode), cache_diagnostics (boolean; Claude API only), native_compaction (boolean; synchronous whole-history signed compaction on Claude API), cache_preserving_updates (boolean; native system/tool/effort changes with turn-scoped reminders on supported models), thinking_display: updates (progress updates beta on Fable 5+ and Mythos 5.1). Sampling parameters: top_k (integer, supported by anthropic, google, amazon-bedrock, and custom OpenAI-compatible providers like vLLM/Ollama), repetition_penalty (float, forwarded to custom OpenAI-compatible providers), min_p (float, forwarded to custom providers), seed (integer, forwarded to OpenAI). Lifecycle: unload_api (string) overrides the unload endpoint inherited from the provider config (relative path resolved against base_url's scheme+host, or an absolute URL); used by the runtime's `unload` on_agent_switch builtin hook to release model resources between agent switches. Infrastructure options: http_headers (map of string to string, adds custom HTTP headers to every request; used for OpenAI-compatible providers like github-copilot which requires Copilot-Integration-Id). dmr: runtime_flags; supports_images (boolean) and supports_pdf (boolean) declare which document attachment types the local model accepts. DMR-hosted models are not in the models.dev catalog, so attachment capabilities cannot be detected automatically and default to text-only; set supports_images: true for a vision model so image attachments are forwarded instead of silently dropped. anthropic/amazon-bedrock (Claude): interleaved_thinking (boolean, default true). anthropic: thinking_display ('summarized', 'omitted', 'updates', or 'display') controls whether thinking blocks are returned in responses when thinking is enabled. Claude Opus 4.7 hides thinking by default ('omitted'); set thinking_display: summarized to receive thinking blocks. 'display' (full thinking blocks) is only accepted by pre-4.6 token-thinking models (e.g. Sonnet 4.5, Haiku 4.5); models from the adaptive-thinking generation onward (Opus/Sonnet 4.6+, Sonnet 5, Fable 5) reject it, and docker-agent fails fast with a configuration error (fallbacks models are validated too); fallbacks (\"default\" for the server's recommended routing, or an array of up to three distinct model ID strings in priority order; enables the server-side-fallback-2026-07-01 beta so refused requests are retried in a single round trip; Claude API only, rejected on Vertex AI, Bedrock, gateways and other base_url hosts; a list is rejected for Claude Haiku 5.5; rejected when the primary or any fallback is a Claude 5.x model until issue #4584). OpenAI-compatible APIs: service_tier (string, forwarded unchanged to Chat Completions and Responses, including WebSocket and reranking; 'fast' or 'priority' enables Fast mode at premium pricing on supported models; omitted or empty leaves the API default, non-string values are ignored). openai/chatgpt: preserve_reasoning (boolean, default false) preserves ordered Responses output and encrypted reasoning for stateless continuation; native_tool_search also enables output replay. Any OpenAI-compatible destination: replay_reasoning (boolean) replays the reasoning stored on assistant turns back as a reasoning_content field on later Chat Completions requests, so a reasoning model sees its own earlier reasoning instead of re-deriving it after each tool call (distinct from preserve_reasoning, which replays encrypted reasoning items on the Responses API and does not apply to Chat Completions); default true for Chat Completions; set replay_reasoning to false for an endpoint that rejects or must not receive the non-standard field; a non-boolean value also disables replay. openai: cache_diagnostics (boolean, default false) logs Responses prompt-cache comparison results with --debug on GPT-5.6+; comparisons are scoped to the current session and resolved model; ignored for chatgpt and custom base_url endpoints. transport ('sse' or 'websocket') to choose between SSE and WebSocket streaming for the Responses API; native_tool_search (boolean, default false; also enables ordered output replay) declares the agent's deferred tools with defer_loading and a server-executed tool_search tool so OpenAI's hosted tool search discovers them (only openai models supporting deferred tools, gpt-5.4+; ignored on chatgpt, OpenAI-compatible endpoints including a custom base_url, and Chat Completions requests). openai/anthropic/google: rerank_prompt (string) to fully override the system prompt used for RAG reranking (advanced - prefer using results.reranking.criteria for domain-specific guidance). Google: google_search (boolean) enables Google Search grounding, google_maps (boolean) enables Google Maps grounding, code_execution (boolean) enables server-side code execution, url_context (boolean) enables reading public URLs during generation, output_dimensionality (positive integer) truncates Gemini embedding vectors to that size when the model is used as a RAG embedding_model; service_tier (string, forwarded unchanged as the top-level serviceTier of every generateContent request, including reranking, title generation, and compaction, on the Gemini API, Vertex AI, and the Docker models gateway; 'flex' for discounted, queued processing that may wait up to 15 minutes before the first byte, 'standard' for the default tier, 'priority' for premium processing; omitted or empty leaves the API default, non-string values are ignored; cost estimates are not tier-adjusted, set the model's cost override).", "additionalProperties": true }, "track_usage": { diff --git a/docs/providers/anthropic/index.md b/docs/providers/anthropic/index.md index f49b1430a7..275bf70986 100644 --- a/docs/providers/anthropic/index.md +++ b/docs/providers/anthropic/index.md @@ -347,9 +347,10 @@ See the full schema on the [Model Configuration](../../configuration/models/inde ## Server-Side Fallbacks -When the primary model refuses a request (stop reason `refusal`), Anthropic -can retry the request with backup models in a single round trip. Set -`fallbacks` in `provider_opts` to a list of model IDs, in priority order: +When the primary model refuses a request (stop reason `refusal`), the Claude +API can retry it on a fallback model in a single round trip. Set `fallbacks` +in `provider_opts` to `default`, to use the fallback Anthropic recommends for +the refusal's category: ```yaml models: @@ -357,6 +358,12 @@ models: provider: anthropic model: claude-opus-4-8 provider_opts: + fallbacks: default +``` + +Or list up to three distinct models, tried in order: + +```yaml fallbacks: - claude-opus-4-7 - claude-sonnet-4-6 @@ -365,17 +372,28 @@ models: > [!WARNING] > **Not yet available with Claude 5.x** > -> A fallback can change the serving model mid-response, which Docker Agent does not handle yet. A `fallbacks` list is rejected at startup when the primary model or any fallback is a Claude 5.x model (Haiku 5.5, Opus 5/5.5, Sonnet 5/5.5, Fable 5/5.1, Mythos 5/5.1). Support is tracked in [#4584](https://github.com/docker/docker-agent/issues/4584). +> A fallback can change the serving model mid-response, which Docker Agent does not handle yet. `fallbacks` is rejected at startup when the primary model or any listed fallback is a Claude 5.x model (Haiku 5.5, Opus 5/5.5, Sonnet 5/5.5, Fable 5/5.1, Mythos 5/5.1). Support is tracked in [#4584](https://github.com/docker/docker-agent/issues/4584). -Docker Agent automatically attaches the required -`server-side-fallback-2026-06-01` beta header and forwards the option as -`fallbacks: [{"model": "..."}]`. The response's `model` field reports which -model actually served the request. +Docker Agent sends the `server-side-fallback-2026-07-01` beta header, which +accepts both forms, through the beta Messages API. The response's `model` +field reports which model actually served the request. + +Rules checked at startup: + +- A list holds at most three models, each distinct and different from the + primary model. Any other value is a configuration error. +- Server-side fallback is a Claude API feature: `fallbacks` is rejected on + Vertex AI, Bedrock, a gateway, or a `base_url` other than + `https://api.anthropic.com`. It isn't available on the Message Batches API + either. +- Claude Haiku 5.5 has no server-side fallback: a list is rejected, and + `default` only logs that it has no effect. Fallback models receive the exact same request as the primary model (thinking configuration, task budget, beta features, ...), so list only -models that accept the same request shape. Not available on Bedrock, Vertex -AI, or the Message Batches API. +models that accept the same request shape. + +See [Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#server-side-fallback). ## Native Compaction diff --git a/examples/anthropic-fallbacks.yaml b/examples/anthropic-fallbacks.yaml index a0bcf5a06c..a0d5217740 100644 --- a/examples/anthropic-fallbacks.yaml +++ b/examples/anthropic-fallbacks.yaml @@ -1,6 +1,8 @@ -# Demonstrates Anthropic's server-side fallbacks (beta): when the primary -# model refuses a request, the API retries with the listed fallback models in -# order, in a single round trip. +# Demonstrates Anthropic's server-side fallbacks (beta, Claude API only): +# when the primary model refuses a request, the API retries on a fallback +# model in a single round trip. `fallbacks: default` uses the model Anthropic +# recommends for the refusal's category; a list (up to three) is tried in +# order. # # Fallback chains that include a Claude 5.x model are rejected until # https://github.com/docker/docker-agent/issues/4584 lands. @@ -11,9 +13,18 @@ models: provider_opts: fallbacks: - claude-opus-4-7 + opus-default: + provider: anthropic + model: claude-opus-4-8 + provider_opts: + fallbacks: default agents: root: model: opus description: Assistant on Claude Opus 4.8 with a server-side fallback model instruction: You are a helpful assistant. + default-routing: + model: opus-default + description: Assistant whose refused requests use Anthropic's recommended fallback + instruction: You are a helpful assistant. From 4702ab2ef09dff017801f9294a33da974bf15423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 21 Oct 2026 18:32:27 +0200 Subject: [PATCH 6/7] fix(anthropic): tighten server-side fallback validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check ANTHROPIC_BASE_URL, which the SDK uses when base_url is unset, and compare the host case-insensitively. - Run the temporary Claude 5.x block before the fallback rules, so a Haiku 5.5 config gets one accurate error instead of a misleading warning and hint. - Document that default checks per-model options against the primary only. Signed-off-by: Arnaud Héritier --- docs/providers/anthropic/index.md | 15 +++++++++------ pkg/model/provider/anthropic/fallbacks.go | 19 +++++++++++++------ .../provider/anthropic/fallbacks_test.go | 17 ++++++++++++++++- .../provider/anthropic/thinking_binding.go | 6 ++++-- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/providers/anthropic/index.md b/docs/providers/anthropic/index.md index 275bf70986..559c08511b 100644 --- a/docs/providers/anthropic/index.md +++ b/docs/providers/anthropic/index.md @@ -383,15 +383,18 @@ Rules checked at startup: - A list holds at most three models, each distinct and different from the primary model. Any other value is a configuration error. - Server-side fallback is a Claude API feature: `fallbacks` is rejected on - Vertex AI, Bedrock, a gateway, or a `base_url` other than - `https://api.anthropic.com`. It isn't available on the Message Batches API - either. -- Claude Haiku 5.5 has no server-side fallback: a list is rejected, and - `default` only logs that it has no effect. + Vertex AI, Bedrock, a gateway, or a base URL (`base_url` or + `ANTHROPIC_BASE_URL`) whose host isn't `api.anthropic.com`. It isn't + available on the Message Batches API either. +- Claude Haiku 5.5 has no server-side fallback. Once Claude 5.x chains are + supported, a list will be rejected and `default` will only log that it has + no effect. Fallback models receive the exact same request as the primary model (thinking configuration, task budget, beta features, ...), so list only -models that accept the same request shape. +models that accept the same request shape. With `default` the server picks +the fallback, so per-model options such as `thinking_display` are checked +against the primary model only. See [Refusals and fallback](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#server-side-fallback). diff --git a/pkg/model/provider/anthropic/fallbacks.go b/pkg/model/provider/anthropic/fallbacks.go index 021023e2c5..66cb43fa7e 100644 --- a/pkg/model/provider/anthropic/fallbacks.go +++ b/pkg/model/provider/anthropic/fallbacks.go @@ -5,7 +5,9 @@ import ( "fmt" "log/slog" "net/url" + "os" "slices" + "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/shared/constant" @@ -58,9 +60,14 @@ func validateFallbackHost(cfg *latest.ModelConfig, gateway string) error { if gateway != "" { return errors.New("anthropic: fallbacks requires the Claude API and is not available through a gateway") } - if cfg.BaseURL != "" { - if u, err := url.Parse(cfg.BaseURL); err != nil || u.Hostname() != claudeAPIHost { - return fmt.Errorf("anthropic: fallbacks requires the Claude API and is not available through base_url %q", cfg.BaseURL) + // Without base_url, the SDK falls back to ANTHROPIC_BASE_URL. + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = os.Getenv("ANTHROPIC_BASE_URL") + } + if baseURL != "" { + if u, err := url.Parse(baseURL); err != nil || !strings.EqualFold(u.Hostname(), claudeAPIHost) { + return fmt.Errorf("anthropic: fallbacks requires the Claude API and is not available through base URL %q", baseURL) } } return nil @@ -125,7 +132,7 @@ func validateFallbackSpec(cfg *latest.ModelConfig) error { } if m, ok := modelinfo.LookupClaude5(cfg.Model); ok && m.NoServerSideFallback { if len(spec.Models) > 0 { - return fmt.Errorf("anthropic: %s has no server-side fallback; remove fallbacks or use \"default\"", cfg.Model) + return fmt.Errorf("anthropic: %s has no server-side fallback; remove the fallbacks list", cfg.Model) } if spec.Default { slog.Warn("Anthropic: fallbacks: default has no effect on this model", "model", cfg.Model) @@ -136,8 +143,8 @@ func validateFallbackSpec(cfg *latest.ModelConfig) error { // validateFallbacks rejects server-side fallback when any model in the chain // is Claude 5.x: a fallback can change the serving model mid-response, which -// the client does not handle yet. Malformed lists stay ignored, as in -// [fallbackModels]. +// the client does not handle yet. Malformed values are reported by +// [validateFallbackSpec]. func validateFallbacks(cfg *latest.ModelConfig) error { spec, _ := parseFallbacks(cfg.ProviderOpts) if !spec.Default && len(spec.Models) == 0 { diff --git a/pkg/model/provider/anthropic/fallbacks_test.go b/pkg/model/provider/anthropic/fallbacks_test.go index 4c77e5529e..acc3e5fe80 100644 --- a/pkg/model/provider/anthropic/fallbacks_test.go +++ b/pkg/model/provider/anthropic/fallbacks_test.go @@ -27,7 +27,6 @@ func TestValidateFallbacks(t *testing.T) { {name: "absent", model: "claude-fable-5-1"}, {name: "empty", model: "claude-fable-5-1", fallbacks: []any{}}, {name: "no 5.x model", model: "claude-opus-4-8", fallbacks: []any{"claude-opus-4-7"}}, - {name: "malformed is ignored", model: "claude-fable-5-1", fallbacks: "not-a-list"}, {name: "default on 5.x primary", model: "claude-fable-5-1", fallbacks: "default", wantModel: "claude-fable-5-1"}, {name: "default on legacy primary", model: "claude-opus-4-8", fallbacks: "default"}, } @@ -149,6 +148,7 @@ func TestNewClient_FallbacksOnlyOnClaudeAPI(t *testing.T) { }{ "claude api": {}, "explicit claude api": {baseURL: "https://api.anthropic.com"}, + "host case": {baseURL: "https://API.anthropic.com/"}, "custom base_url": {baseURL: "https://proxy.example.com", wantErr: "requires the Claude API"}, "gateway": {opts: []options.Opt{options.WithGateway("https://gateway.example.com")}, wantErr: "requires the Claude API"}, } { @@ -166,3 +166,18 @@ func TestNewClient_FallbacksOnlyOnClaudeAPI(t *testing.T) { }) } } + +// The SDK reads ANTHROPIC_BASE_URL when base_url is unset, so it must pass +// the same check. Not parallel: it sets the process environment. +func TestNewClient_FallbacksCheckEnvBaseURL(t *testing.T) { + t.Setenv("ANTHROPIC_BASE_URL", "https://litellm.internal") + + env := environment.NewMapEnvProvider(map[string]string{"ANTHROPIC_API_KEY": "k"}) + cfg := &latest.ModelConfig{Provider: "anthropic", Model: "claude-opus-4-8", ProviderOpts: map[string]any{"fallbacks": "default"}} + _, err := NewClient(t.Context(), cfg, env) + require.ErrorContains(t, err, `not available through base URL "https://litellm.internal"`) + + cfg.BaseURL = "https://api.anthropic.com" + _, err = NewClient(t.Context(), cfg, env) + require.NoError(t, err, "an explicit base_url takes precedence over the environment") +} diff --git a/pkg/model/provider/anthropic/thinking_binding.go b/pkg/model/provider/anthropic/thinking_binding.go index b814c972ae..0368b9b315 100644 --- a/pkg/model/provider/anthropic/thinking_binding.go +++ b/pkg/model/provider/anthropic/thinking_binding.go @@ -10,10 +10,12 @@ import ( ) func validateThinkingOptions(cfg *latest.ModelConfig) error { - if err := validateFallbackSpec(cfg); err != nil { + // The temporary Claude 5.x block goes first: it covers every rule + // validateFallbackSpec has for Claude 5.x models. + if err := validateFallbacks(cfg); err != nil { return err } - if err := validateFallbacks(cfg); err != nil { + if err := validateFallbackSpec(cfg); err != nil { return err } if err := validateThinkingDisplay(cfg); err != nil { From e27480514d3736e4bce788f968b21a0fc4a30d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Wed, 21 Oct 2026 19:07:51 +0200 Subject: [PATCH 7/7] fix(anthropic): keep the request shape when fallbacks route to beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fallbacks send every request through the beta Messages API, which never forwarded temperature and top_p and always enabled interleaved thinking. Forward sampling settings as the standard path does (thinking off, model accepts them) and honor an explicit interleaved_thinking: false. This also fixes sampling settings on other beta-routed requests, such as structured output. Signed-off-by: Arnaud Héritier --- pkg/model/provider/anthropic/beta_client.go | 19 ++++++++++-- .../providers/fallback_requests_test.go | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/pkg/model/provider/anthropic/beta_client.go b/pkg/model/provider/anthropic/beta_client.go index 864f05d007..85850e0f37 100644 --- a/pkg/model/provider/anthropic/beta_client.go +++ b/pkg/model/provider/anthropic/beta_client.go @@ -64,9 +64,11 @@ func (c *Client) createBetaStream( sys, transient := c.betaSystemContext(messages) - betas := []anthropic.AnthropicBeta{ - anthropic.AnthropicBetaInterleavedThinking2025_05_14, - "fine-grained-tool-streaming-2025-05-14", + betas := []anthropic.AnthropicBeta{"fine-grained-tool-streaming-2025-05-14"} + // Requests reach this path for other reasons (fallbacks, structured + // output, ...); an explicit interleaved_thinking: false still holds. + if _, set := c.ModelConfig.ProviderOpts["interleaved_thinking"]; !set || c.interleavedThinkingEnabled() { + betas = append([]anthropic.AnthropicBeta{anthropic.AnthropicBetaInterleavedThinking2025_05_14}, betas...) } params := anthropic.BetaMessageNewParams{ @@ -97,6 +99,17 @@ func (c *Client) createBetaStream( c.applyBetaThinkingConfig(¶ms, maxTokens) c.applyThinkingBinding(¶ms) + // As on the standard path: sampling only applies with thinking off. + thinkingEnabled := params.Thinking.OfEnabled != nil || params.Thinking.OfAdaptive != nil + if !thinkingEnabled && !rejectsSampling(c.ModelConfig.Model) { + if c.ModelConfig.Temperature != nil { + params.Temperature = param.NewOpt(*c.ModelConfig.Temperature) + } + if c.ModelConfig.TopP != nil { + params.TopP = param.NewOpt(*c.ModelConfig.TopP) + } + } + // Forward task_budget via `output_config.task_budget` (Anthropic // Opus 4.7+) and enable the corresponding beta header. Older Claude // models will reject the field — docker-agent does not gate by model. diff --git a/pkg/model/provider/providers/fallback_requests_test.go b/pkg/model/provider/providers/fallback_requests_test.go index 3ed10d9d89..b8f61f73b0 100644 --- a/pkg/model/provider/providers/fallback_requests_test.go +++ b/pkg/model/provider/providers/fallback_requests_test.go @@ -43,3 +43,34 @@ func TestServerSideFallbackRequests(t *testing.T) { } }) } + +// Fallbacks route legacy configurations through the beta Messages API, which +// must send the same request as the standard path did: sampling settings when +// thinking is off, and no interleaved thinking when it was opted out. +func TestServerFallbackKeepsLegacyRequestShape(t *testing.T) { + t.Parallel() + + env := map[string]string{"ANTHROPIC_API_KEY": "test-key"} + t.Run("sampling without thinking", func(t *testing.T) { + t.Parallel() + req := captureFinalRequest(t, latest.ModelConfig{ + Provider: "anthropic", Model: "claude-sonnet-4-5", Temperature: new(0.3), TopP: new(0.9), + ProviderOpts: map[string]any{"fallbacks": []any{"claude-haiku-4-5"}}, + }, env) + assert.Equal(t, "beta=true", req.query) + assert.Nil(t, req.body["thinking"]) + assert.InDelta(t, 0.3, req.body["temperature"], 1e-9) + assert.InDelta(t, 0.9, req.body["top_p"], 1e-9) + }) + + t.Run("interleaved thinking opt-out", func(t *testing.T) { + t.Parallel() + req := captureFinalRequest(t, latest.ModelConfig{ + Provider: "anthropic", Model: "claude-sonnet-4-5", ThinkingBudget: &latest.ThinkingBudget{Tokens: 2048}, + ProviderOpts: map[string]any{"fallbacks": []any{"claude-haiku-4-5"}, "interleaved_thinking": false}, + }, env) + assert.Equal(t, "beta=true", req.query) + assert.NotContains(t, strings.Join(req.header.Values("anthropic-beta"), ","), "interleaved-thinking") + assert.Nil(t, req.body["temperature"], "no sampling with thinking on") + }) +}