diff --git a/internal/modelgateway/anthropic.go b/internal/modelgateway/anthropic.go index 06e9f0c51..dfa634380 100644 --- a/internal/modelgateway/anthropic.go +++ b/internal/modelgateway/anthropic.go @@ -10,8 +10,9 @@ import ( const anthropicVersion = "2023-06-01" -// These protocol capabilities were observed with the pinned Claude 2.1.303 CLI and, for -// Claude models over Anthropic Messages, the pinned OpenCode 1.19.3 CLI. A CLI upgrade must +// These protocol capabilities were observed with the pinned Claude 2.1.303 CLI, including when +// it is started through Agent SDK 0.3.303 / ACP adapter 0.94.0, and, for Claude models over +// Anthropic Messages, the pinned OpenCode 1.19.3 CLI. A CLI, SDK or adapter upgrade must // exercise its requests before extending this set. var claudeBetaHeaders = map[string]bool{ "claude-code-20250219": true, diff --git a/internal/modelgateway/anthropic_test.go b/internal/modelgateway/anthropic_test.go index a0367fd5e..a1252487e 100644 --- a/internal/modelgateway/anthropic_test.go +++ b/internal/modelgateway/anthropic_test.go @@ -16,12 +16,16 @@ import ( const pinnedClaudeBetas = "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-tool-changes-2026-07-01,effort-2025-11-24" +// The first request the bundled Claude Code 2.1.303 sent for claude-haiku-5-5 when run directly +// with the adapter's alias and model environment, so it carries no Agent SDK additions. +const pinnedClaudeHaikuBetas = "interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,effort-2025-11-24" + // Each pinned CLI's exact first-request header for a Claude model is forwarded unchanged. func TestPinnedCLIClaudeModelBetas(t *testing.T) { for name, betas := range map[string]string{ // Claude Code 2.1.303 run directly with the adapter's alias and model environment, with // claude-haiku-5-5; the Agent SDK's own additions are covered separately below. - "claude": "interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,effort-2025-11-24", + "claude": pinnedClaudeHaikuBetas, // OpenCode 1.19.3 through @ai-sdk/anthropic, with claude-haiku-5-5. "opencode": "thinking-binding-controls-2026-08-01,structured-outputs-2025-11-13", } { @@ -73,6 +77,33 @@ func TestPinnedClaudeAgentSDKCapabilities(t *testing.T) { } } +// The documented production combination: the same CLI started through Agent SDK 0.3.303 / ACP +// adapter 0.94.0 for claude-haiku-5-5 declares the direct run's betas plus the SDK's additions. +// The header is composed from those two captured sets rather than captured end to end, so this +// pins that the gateway accepts and forwards the union unchanged, not the client's header order. +func TestPinnedClaudeACPHaikuBetaCombination(t *testing.T) { + betas := pinnedClaudeHaikuBetas + ",structured-outputs-2025-12-15,thinking-display-updates-2026-08-18" + var calls atomic.Int64 + service, _ := anthropicFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Header.Get("Anthropic-Beta") != betas || r.URL.RawQuery != "beta=true" { + t.Error("ACP and haiku betas changed while forwarding") + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, "{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"fixture-model\",\"content\":[{\"type\":\"text\",\"text\":\"{}\"}],\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}") + })) + request := httptest.NewRequest(http.MethodPost, "/runtime/anthropic/v1/messages?beta=true", strings.NewReader("{\"model\":\"fixture-model\",\"messages\":[],\"max_tokens\":16}")) + request.Header.Set("Authorization", "Bearer "+strings.Repeat("r", 40)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Anthropic-Version", anthropicVersion) + request.Header.Set("Anthropic-Beta", betas) + response := httptest.NewRecorder() + service.ModelHandler(response, request) + if response.Code != 200 || calls.Load() != 1 { + t.Fatalf("ACP haiku betas rejected: status=%d calls=%d", response.Code, calls.Load()) + } +} + // anthropicFixture keeps the upstream key separate from the workload's short-lived token. func anthropicFixture(t *testing.T, handler http.Handler) (*Service, *fakePolicy) { t.Helper()