From a0cc065d4d3152e933bbbd5a8d27c501ea29f26c Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 29 Sep 2026 21:21:58 +0800 Subject: [PATCH] fix(claude): distinguish claude-sonnet-5-5 from sonnet-5 in model checks - Add `isClaudeSonnet55Model` helper and prevent Sonnet 5 matching on Sonnet 5.5 prefixes - Include Claude Sonnet 5.5 in progress display checks Closes: #6193 --- .../executor/claude_executor_request.go | 10 +- .../runtime/executor/claude_executor_test.go | 5 + .../executor/claude_issue_6193_test.go | 107 ++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 internal/runtime/executor/claude_issue_6193_test.go diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 414b788c3..43c3755e8 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -302,15 +302,23 @@ func isClaudeOpus55Model(model string) bool { return model == "claude-opus-5-5" || strings.HasPrefix(model, "claude-opus-5-5[") } +func isClaudeSonnet55Model(model string) bool { + model = claudeCanonicalModel(model) + return model == "claude-sonnet-5-5" || strings.HasPrefix(model, "claude-sonnet-5-5-") || strings.HasPrefix(model, "claude-sonnet-5-5[") +} + func isClaudeSonnet5Model(model string) bool { model = claudeCanonicalModel(model) + if isClaudeSonnet55Model(model) { + return false + } return model == "claude-sonnet-5" || strings.HasPrefix(model, "claude-sonnet-5-") || strings.HasPrefix(model, "claude-sonnet-5[") } // claudeModelUsesProgressDisplay reports the 2.1.280 interactive CLI models that // send thinking.display=updates unless the caller already chose a display mode. func claudeModelUsesProgressDisplay(model string) bool { - return isClaudeOpus55Model(model) || isClaudeFable51Model(model) || isClaudeSonnet5Model(model) + return isClaudeOpus55Model(model) || isClaudeFable51Model(model) || isClaudeSonnet5Model(model) || isClaudeSonnet55Model(model) } // applyClaudeCloakThinkingDisplay fills the latest CLI display only when the diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 3b3b901c7..082914b30 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -8139,6 +8139,11 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { body: `{"model":"claude-sonnet-5"}`, want: constants + ",mid-conversation-system-2026-04-07,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24", }, + { + name: "claude-sonnet-5-5 carries tool changes beta unlike sonnet-5", + body: `{"model":"claude-sonnet-5-5"}`, + want: constants + ",mid-conversation-system-2026-04-07,mid-conversation-tool-changes-2026-07-01,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24", + }, { name: "claude-opus-4-8 accepts role=system", body: `{"model":"claude-opus-4-8"}`, diff --git a/internal/runtime/executor/claude_issue_6193_test.go b/internal/runtime/executor/claude_issue_6193_test.go new file mode 100644 index 000000000..986e96327 --- /dev/null +++ b/internal/runtime/executor/claude_issue_6193_test.go @@ -0,0 +1,107 @@ +package executor + +import ( + "bytes" + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" +) + +// TestIssue6193_Sonnet55ToolChangeBeta verifies that Sonnet 5.5 receives the +// mid-conversation-tool-changes-2026-07-01 beta, while Sonnet 5 remains excluded, +// and that on the Claude OAuth / CLI-fingerprint path the beta reaches Anthropic. +func TestIssue6193_Sonnet55ToolChangeBeta(t *testing.T) { + // 1. In claudeCodeCLIBetas, Sonnet 5.5 (and dated/bracket variants) must include + // mid-conversation-tool-changes-2026-07-01, while Sonnet 5 must remain excluded. + sonnet55Variants := []string{ + "claude-sonnet-5-5", + "claude-sonnet-5-5-20261001", + "claude-sonnet-5-5[1m]", + } + for _, model := range sonnet55Variants { + betas := claudeCodeCLIBetas([]byte(`{"model":"`+model+`"}`), nil, false) + if !strings.Contains(betas, "mid-conversation-tool-changes-2026-07-01") { + t.Fatalf("claudeCodeCLIBetas for %s missing mid-conversation-tool-changes-2026-07-01, got: %s", model, betas) + } + if isClaudeSonnet5Model(model) { + t.Fatalf("isClaudeSonnet5Model(%q) = true, want false", model) + } + if !isClaudeSonnet55Model(model) { + t.Fatalf("isClaudeSonnet55Model(%q) = false, want true", model) + } + if !claudeModelUsesProgressDisplay(model) { + t.Fatalf("claudeModelUsesProgressDisplay(%q) = false, want true", model) + } + } + + sonnet5Variants := []string{ + "claude-sonnet-5", + "claude-sonnet-5-20260401", + "claude-sonnet-5[1m]", + } + for _, model := range sonnet5Variants { + betas := claudeCodeCLIBetas([]byte(`{"model":"`+model+`"}`), nil, false) + if strings.Contains(betas, "mid-conversation-tool-changes-2026-07-01") { + t.Fatalf("claudeCodeCLIBetas for %s unexpectedly includes mid-conversation-tool-changes-2026-07-01, got: %s", model, betas) + } + if !isClaudeSonnet5Model(model) { + t.Fatalf("isClaudeSonnet5Model(%q) = false, want true", model) + } + if isClaudeSonnet55Model(model) { + t.Fatalf("isClaudeSonnet55Model(%q) = true, want false", model) + } + if !claudeModelUsesProgressDisplay(model) { + t.Fatalf("claudeModelUsesProgressDisplay(%q) = false, want true", model) + } + } + + // 2. Outgoing Anthropic-Beta on Claude OAuth / CLI fingerprint path for claude-sonnet-5-5 + // must contain mid-conversation-tool-changes-2026-07-01, both when caller sends an explicit beta + // and when caller sends no client beta. + body := []byte(`{ + "model": "claude-sonnet-5-5", + "max_tokens": 64, + "tools": [{"name":"lookup_notes","input_schema":{"type":"object"}}], + "messages": [ + {"role":"user","content":"hello"}, + {"role":"system","content":[{"type":"tool_removal","tool":{"type":"tool_reference","name":"lookup_notes"}}]} + ] + }`) + + auth := &cliproxyauth.Auth{ + ID: "test-oauth", + Metadata: map[string]any{"access_token": "sk-ant-oat-issue-6193"}, + } + + for _, tc := range []struct { + name string + clientBetas string + }{ + {name: "with explicit legacy beta", clientBetas: "mid-conversation-tool-changes-2026-07-01"}, + {name: "without client beta", clientBetas: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + incomingHeaders := http.Header{} + if tc.clientBetas != "" { + incomingHeaders.Set("Anthropic-Beta", tc.clientBetas) + } + + req, err := http.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages", bytes.NewReader(body)) + if err != nil { + t.Fatalf("http.NewRequest failed: %v", err) + } + + if errApply := applyClaudeHeaders(req, auth, "sk-ant-oat-issue-6193", false, nil, body, &config.Config{}, incomingHeaders, false); errApply != nil { + t.Fatalf("applyClaudeHeaders failed: %v", errApply) + } + + upstreamBetas := req.Header.Get("Anthropic-Beta") + if !strings.Contains(upstreamBetas, "mid-conversation-tool-changes-2026-07-01") { + t.Fatalf("applyClaudeHeaders for claude-sonnet-5-5 missing mid-conversation-tool-changes-2026-07-01, got Anthropic-Beta: %s", upstreamBetas) + } + }) + } +}