From b917ad0793577f5959b46af1c71ec0fcd50a3dd8 Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 14:21:44 +0000 Subject: [PATCH 1/2] fix(model-gateway): allow the per-turn-control beta the pinned Claude CLI sends Claude Code 2.1.303, driven through the pinned ACP adapter with claude-haiku-5-5, adds per-turn-control-2026-07-01 to its first Messages request. The gateway refused it as an unknown Anthropic extension, so every such run ended with agent_turn_failed before any upstream call. --- docs/models/model-gateway.en.md | 3 ++- docs/models/model-gateway.md | 3 ++- internal/modelgateway/anthropic.go | 1 + internal/modelgateway/anthropic_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/models/model-gateway.en.md b/docs/models/model-gateway.en.md index 8ed49f50..96ea4afe 100644 --- a/docs/models/model-gateway.en.md +++ b/docs/models/model-gateway.en.md @@ -81,7 +81,8 @@ browser-access marker never reach the supplier. The same pinned CLI also declares `structured-outputs-2025-12-15` and `thinking-display-updates-2026-08-18` when queries are created through Agent SDK 0.3.303 / -ACP adapter 0.94.0. Both are included in the bounded whitelist. An upgrade must verify its +ACP adapter 0.94.0, and `per-turn-control-2026-07-01` on its first request for +`claude-haiku-5-5`. All three are included in the bounded whitelist. An upgrade must verify its observed protocol surface before extending the policy. | Method and path | Query | Behavior | diff --git a/docs/models/model-gateway.md b/docs/models/model-gateway.md index 21391b54..5c60b4da 100644 --- a/docs/models/model-gateway.md +++ b/docs/models/model-gateway.md @@ -53,7 +53,8 @@ Claude Code 2.1.303 的 Messages 兼容面如下;允许的 beta 标记由 客户端诊断头、会话标识及 browser-access 标记不会转发到供应商。 同一固定 CLI 通过 Agent SDK 0.3.303 / ACP adapter 0.94.0 创建查询时还会声明 -`structured-outputs-2025-12-15` 与 `thinking-display-updates-2026-08-18`;它们同样在受限白名单中。 +`structured-outputs-2025-12-15` 与 `thinking-display-updates-2026-08-18`,对 `claude-haiku-5-5` 的首个请求还会带 +`per-turn-control-2026-07-01`;它们同样在受限白名单中。 升级版本仍须先验证实际协议面,不能按客户端传入内容自动放开。 | 方法与路径 | 查询 | 行为 | diff --git a/internal/modelgateway/anthropic.go b/internal/modelgateway/anthropic.go index 456299ea..b21ed228 100644 --- a/internal/modelgateway/anthropic.go +++ b/internal/modelgateway/anthropic.go @@ -22,6 +22,7 @@ var claudeBetaHeaders = map[string]bool{ "effort-2025-11-24": true, "structured-outputs-2025-12-15": true, "thinking-display-updates-2026-08-18": true, + "per-turn-control-2026-07-01": true, } // runtimeModelRoute accepts only inference and the frozen binding's local model listing. diff --git a/internal/modelgateway/anthropic_test.go b/internal/modelgateway/anthropic_test.go index 87c4a902..8dee122f 100644 --- a/internal/modelgateway/anthropic_test.go +++ b/internal/modelgateway/anthropic_test.go @@ -16,6 +16,30 @@ 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 same pinned CLI adds per-turn control on its first request for claude-haiku-5-5. +func TestPinnedClaudePerTurnControl(t *testing.T) { + betas := "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" + 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 { + t.Error("per-turn control 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("X-Api-Key", 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("pinned per-turn control rejected: status=%d calls=%d", response.Code, calls.Load()) + } +} + // Agent SDK query creation enables these additional capabilities on the same pinned native CLI. func TestPinnedClaudeAgentSDKCapabilities(t *testing.T) { betas := pinnedClaudeBetas + ",structured-outputs-2025-12-15,thinking-display-updates-2026-08-18" From b4c5e23097f4215490ee6427ffeb28f0c247825a Mon Sep 17 00:00:00 2001 From: "Ruihao (Ray) Zhang" Date: Tue, 20 Oct 2026 14:53:50 +0000 Subject: [PATCH 2/2] fix(model-gateway): allow the betas OpenCode sends for Claude models The pinned OpenCode 1.19.3 declares thinking-binding-controls-2026-08-01 and structured-outputs-2025-11-13 when it requests a claude-* model over Anthropic Messages through @ai-sdk/anthropic. Both were refused as unknown extensions, failing the turn before any upstream attempt. The test now replays each pinned CLI's exact first-request header. --- docs/models/model-gateway.en.md | 4 +- docs/models/model-gateway.md | 3 +- internal/modelgateway/anthropic.go | 7 ++- internal/modelgateway/anthropic_test.go | 74 ++++++++++--------------- 4 files changed, 39 insertions(+), 49 deletions(-) diff --git a/docs/models/model-gateway.en.md b/docs/models/model-gateway.en.md index 96ea4afe..1c0c71ea 100644 --- a/docs/models/model-gateway.en.md +++ b/docs/models/model-gateway.en.md @@ -82,7 +82,9 @@ browser-access marker never reach the supplier. The same pinned CLI also declares `structured-outputs-2025-12-15` and `thinking-display-updates-2026-08-18` when queries are created through Agent SDK 0.3.303 / ACP adapter 0.94.0, and `per-turn-control-2026-07-01` on its first request for -`claude-haiku-5-5`. All three are included in the bounded whitelist. An upgrade must verify its +`claude-haiku-5-5`. The pinned OpenCode 1.19.3 declares `thinking-binding-controls-2026-08-01` +and `structured-outputs-2025-11-13` when it requests a Claude model through +`@ai-sdk/anthropic`. All of these are included in the bounded whitelist. An upgrade must verify its observed protocol surface before extending the policy. | Method and path | Query | Behavior | diff --git a/docs/models/model-gateway.md b/docs/models/model-gateway.md index 5c60b4da..3462134c 100644 --- a/docs/models/model-gateway.md +++ b/docs/models/model-gateway.md @@ -54,7 +54,8 @@ Claude Code 2.1.303 的 Messages 兼容面如下;允许的 beta 标记由 同一固定 CLI 通过 Agent SDK 0.3.303 / ACP adapter 0.94.0 创建查询时还会声明 `structured-outputs-2025-12-15` 与 `thinking-display-updates-2026-08-18`,对 `claude-haiku-5-5` 的首个请求还会带 -`per-turn-control-2026-07-01`;它们同样在受限白名单中。 +`per-turn-control-2026-07-01`。固定的 OpenCode 1.19.3 经 `@ai-sdk/anthropic` 请求 Claude 模型时声明 +`thinking-binding-controls-2026-08-01` 与 `structured-outputs-2025-11-13`。它们同样在受限白名单中。 升级版本仍须先验证实际协议面,不能按客户端传入内容自动放开。 | 方法与路径 | 查询 | 行为 | diff --git a/internal/modelgateway/anthropic.go b/internal/modelgateway/anthropic.go index b21ed228..06e9f0c5 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. -// A CLI upgrade must exercise its requests before extending this set. +// 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 +// exercise its requests before extending this set. var claudeBetaHeaders = map[string]bool{ "claude-code-20250219": true, "interleaved-thinking-2025-05-14": true, @@ -23,6 +24,8 @@ var claudeBetaHeaders = map[string]bool{ "structured-outputs-2025-12-15": true, "thinking-display-updates-2026-08-18": true, "per-turn-control-2026-07-01": true, + "thinking-binding-controls-2026-08-01": true, + "structured-outputs-2025-11-13": true, } // runtimeModelRoute accepts only inference and the frozen binding's local model listing. diff --git a/internal/modelgateway/anthropic_test.go b/internal/modelgateway/anthropic_test.go index 8dee122f..9e2b883d 100644 --- a/internal/modelgateway/anthropic_test.go +++ b/internal/modelgateway/anthropic_test.go @@ -16,51 +16,35 @@ 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 same pinned CLI adds per-turn control on its first request for claude-haiku-5-5. -func TestPinnedClaudePerTurnControl(t *testing.T) { - betas := "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" - 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 { - t.Error("per-turn control 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("X-Api-Key", 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("pinned per-turn control rejected: status=%d calls=%d", response.Code, calls.Load()) - } -} - -// Agent SDK query creation enables these additional capabilities on the same pinned native CLI. -func TestPinnedClaudeAgentSDKCapabilities(t *testing.T) { - betas := pinnedClaudeBetas + ",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("SDK capabilities 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("pinned SDK request rejected: status=%d calls=%d", response.Code, calls.Load()) +// 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 through the pinned ACP adapter, with claude-haiku-5-5. + "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", + // 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", + } { + t.Run(name, func(t *testing.T) { + 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 { + t.Error("pinned 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", strings.NewReader("{\"model\":\"fixture-model\",\"messages\":[],\"max_tokens\":16}")) + request.Header.Set("X-Api-Key", 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("pinned %s betas rejected: status=%d calls=%d", name, response.Code, calls.Load()) + } + }) } }