From 5ff4a31eaf820aa834ce186e4bdf046291ac63e7 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 29 Aug 2026 14:47:51 +0800 Subject: [PATCH] feat(claude): support advisor tool beta header - Add `advisor-tool-2026-03-01` beta header support when advisor server tools are declared or explicitly requested. - Ensure proper ordering of the advisor tool beta before advanced tool use and effort betas. - Inject and preserve advisor tool beta across token counting and fingerprint preservation paths. Closes: #5330 --- .../claude_executor_beta_policy_test.go | 232 +++++++++++++++++- .../executor/claude_executor_request.go | 84 ++++++- .../runtime/executor/claude_executor_test.go | 11 + internal/util/nocopy_invariant_test.go | 2 +- 4 files changed, 319 insertions(+), 10 deletions(-) diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go index 18686f36..0893623b 100644 --- a/internal/runtime/executor/claude_executor_beta_policy_test.go +++ b/internal/runtime/executor/claude_executor_beta_policy_test.go @@ -49,7 +49,7 @@ func TestApplyClaudeHeaders_ConfirmedClientKeepsOAuthCredentialBetas(t *testing. t.Fatalf("Anthropic-Beta = %q, want OAuth cache trailer %s", got, claudeExtendedCacheTTLBeta) } if strings.Contains(got, "advisor-tool-2026-03-01") { - t.Fatalf("Anthropic-Beta = %q, contains stale OAuth tool beta", got) + t.Fatalf("Anthropic-Beta = %q, contains unrequested advisor tool beta", got) } if strings.Contains(got, claudeCacheDiagnosisBeta) { t.Fatalf("Anthropic-Beta = %q, contains %s without a diagnostics body", got, claudeCacheDiagnosisBeta) @@ -293,3 +293,233 @@ func TestClassifyClaudeUpstreamError_OtherStatusesUnaffected(t *testing.T) { t.Fatal("non-429 status was misclassified as request-scoped") } } + +func TestApplyClaudeHeaders_AdvisorToolBetaPreservedWhenRequested(t *testing.T) { + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24") + + body := []byte(`{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`) + + for _, stream := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, stream, nil, + body, nil, incoming, false); err != nil { + t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err) + } + + got := req.Header.Get("Anthropic-Beta") + if !strings.Contains(got, "advisor-tool-2026-03-01") { + t.Fatalf("stream=%v: Anthropic-Beta = %q, want advisor-tool-2026-03-01 preserved", stream, got) + } + + parts := strings.Split(got, ",") + advIdx := -1 + midIdx := -1 + toolIdx := -1 + for i, part := range parts { + switch strings.TrimSpace(part) { + case "advisor-tool-2026-03-01": + advIdx = i + case "mid-conversation-system-2026-04-07": + midIdx = i + case "advanced-tool-use-2025-11-20": + toolIdx = i + } + } + + if advIdx == -1 { + t.Fatalf("stream=%v: advisor-tool-2026-03-01 missing from %q", stream, got) + } + if midIdx != -1 && advIdx < midIdx { + t.Fatalf("stream=%v: advisor-tool at %d should follow mid-conversation-system at %d in %q", stream, advIdx, midIdx, got) + } + if toolIdx != -1 && advIdx > toolIdx { + t.Fatalf("stream=%v: advisor-tool at %d should precede advanced-tool-use at %d in %q", stream, advIdx, toolIdx, got) + } + } +} + +func TestApplyClaudeHeaders_AdvisorToolBetaInjectedWhenBodyHasTool_ConfirmedClient(t *testing.T) { + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "claude-code-20250219,interleaved-thinking-2025-05-14,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24") + + body := []byte(`{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`) + + for _, stream := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, stream, nil, + body, nil, incoming, true); err != nil { + t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err) + } + + got := req.Header.Get("Anthropic-Beta") + if !strings.Contains(got, "advisor-tool-2026-03-01") { + t.Fatalf("stream=%v: Anthropic-Beta = %q, want advisor-tool-2026-03-01 injected for confirmed client", stream, got) + } + + parts := strings.Split(got, ",") + advIdx := -1 + midIdx := -1 + toolIdx := -1 + for i, part := range parts { + switch strings.TrimSpace(part) { + case "advisor-tool-2026-03-01": + advIdx = i + case "mid-conversation-system-2026-04-07": + midIdx = i + case "advanced-tool-use-2025-11-20": + toolIdx = i + } + } + + if advIdx == -1 { + t.Fatalf("stream=%v: advisor-tool-2026-03-01 missing from %q", stream, got) + } + if midIdx != -1 && advIdx < midIdx { + t.Fatalf("stream=%v: advisor-tool at %d should follow mid-conversation-system at %d in %q", stream, advIdx, midIdx, got) + } + if toolIdx != -1 && advIdx > toolIdx { + t.Fatalf("stream=%v: advisor-tool at %d should precede advanced-tool-use at %d in %q", stream, advIdx, toolIdx, got) + } + } +} + +func TestApplyClaudeHeaders_AdvisorToolBetaInjectedWhenBodyHasTool_APIKeyPassthrough(t *testing.T) { + incoming := http.Header{} + incoming.Set("Anthropic-Beta", claudeCodeBeta+",mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,"+claudeEffortBeta) + + body := []byte(`{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`) + + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-passthrough"}} + for _, stream := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, auth, "key-passthrough", stream, nil, + body, nil, incoming, false); err != nil { + t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err) + } + + got := req.Header.Get("Anthropic-Beta") + if !strings.Contains(got, "advisor-tool-2026-03-01") { + t.Fatalf("stream=%v: Anthropic-Beta = %q, want advisor-tool-2026-03-01 injected in API key passthrough", stream, got) + } + + parts := strings.Split(got, ",") + advIdx := -1 + midIdx := -1 + toolIdx := -1 + for i, part := range parts { + switch strings.TrimSpace(part) { + case "advisor-tool-2026-03-01": + advIdx = i + case "mid-conversation-system-2026-04-07": + midIdx = i + case "advanced-tool-use-2025-11-20": + toolIdx = i + } + } + if advIdx == -1 || (midIdx != -1 && advIdx < midIdx) || (toolIdx != -1 && advIdx > toolIdx) { + t.Fatalf("stream=%v: Anthropic-Beta = %q, want advisor-tool-2026-03-01 between mid-conversation-system and advanced-tool-use", stream, got) + } + } +} + +func TestApplyClaudeHeaders_AdvisorToolBetaPreservedWhenLiftedFromBodyBetas_ConfirmedClient(t *testing.T) { + incoming := http.Header{} + incoming.Set("Anthropic-Beta", claudeCodeBeta+",interleaved-thinking-2025-05-14,"+claudeEffortBeta) + + body := []byte(`{"model":"claude-opus-5"}`) + extraBetas := []string{"advisor-tool-2026-03-01"} + + for _, stream := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, stream, extraBetas, + body, nil, incoming, true); err != nil { + t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err) + } + + got := req.Header.Get("Anthropic-Beta") + if !strings.Contains(got, "advisor-tool-2026-03-01") { + t.Fatalf("stream=%v: Anthropic-Beta = %q, want body-lifted advisor-tool-2026-03-01 preserved for confirmed client", stream, got) + } + } +} + +func TestApplyClaudeHeaders_AdvisorToolBetaPreservedWhenCountTokens(t *testing.T) { + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,token-counting-2024-11-01") + + body := []byte(`{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`) + + req := httptest.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages/count_tokens", bytes.NewReader(body)) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil, + body, nil, incoming, false); err != nil { + t.Fatalf("applyClaudeHeaders(count_tokens) error = %v", err) + } + + got := req.Header.Get("Anthropic-Beta") + if !strings.Contains(got, "advisor-tool-2026-03-01") { + t.Fatalf("count_tokens Anthropic-Beta = %q, want advisor-tool-2026-03-01 preserved when advisor tools declared", got) + } +} + +func TestApplyClaudeHeaders_AdvisorToolBetaRepositionedWhenOutOfOrder(t *testing.T) { + // Incoming header has advisor-tool at trailing position after effort + incoming := http.Header{} + incoming.Set("Anthropic-Beta", "claude-code-20250219,mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24,advisor-tool-2026-03-01") + + body := []byte(`{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`) + + for _, confirmed := range []bool{false, true} { + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil, + body, nil, incoming, confirmed); err != nil { + t.Fatalf("confirmed=%v: applyClaudeHeaders() error = %v", confirmed, err) + } + + got := req.Header.Get("Anthropic-Beta") + parts := strings.Split(got, ",") + advIdx := -1 + midIdx := -1 + toolIdx := -1 + for i, part := range parts { + switch strings.TrimSpace(part) { + case "advisor-tool-2026-03-01": + advIdx = i + case "mid-conversation-system-2026-04-07": + midIdx = i + case "advanced-tool-use-2025-11-20": + toolIdx = i + } + } + + if advIdx == -1 { + t.Fatalf("confirmed=%v: advisor-tool missing from %q", confirmed, got) + } + if midIdx != -1 && advIdx < midIdx { + t.Fatalf("confirmed=%v: advisor-tool at %d should follow mid-conversation-system at %d in %q", confirmed, advIdx, midIdx, got) + } + if toolIdx != -1 && advIdx > toolIdx { + t.Fatalf("confirmed=%v: advisor-tool at %d should precede advanced-tool-use at %d in %q", confirmed, advIdx, toolIdx, got) + } + } +} + +func TestApplyClaudeHeaders_StructuredHelperBetaOrderPreservedWithAdvisor(t *testing.T) { + // Exact beta profile from measured structured Haiku helper with advisor + helperBeta := claudeCodeBeta + ",oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,structured-outputs-2025-12-15,cache-diagnosis-2026-04-07" + incoming := http.Header{} + incoming.Set("Anthropic-Beta", helperBeta) + + body := []byte(`{"model":"claude-haiku-4-5-20251001","tools":[]}`) + + req := newClaudeHeaderTestRequest(t, nil) + if err := applyClaudeHeadersWithNativeProfile(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil, + body, nil, incoming, true, true); err != nil { + t.Fatalf("applyClaudeHeadersWithNativeProfile() error = %v", err) + } + + got := req.Header.Get("Anthropic-Beta") + if got != helperBeta { + t.Fatalf("helper Anthropic-Beta =\n got: %q\n want: %q", got, helperBeta) + } +} diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 1bc2d6da..068ce45e 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -39,6 +39,7 @@ const ( claudeCodeBeta = "claude-code-20250219" claudeContext1MBeta = "context-1m-2025-08-07" claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07" + claudeAdvisorToolBeta = "advisor-tool-2026-03-01" claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20" claudeEffortBeta = "effort-2025-11-24" claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01" @@ -91,13 +92,14 @@ var claudeCodeTrailingBetas = []string{ // 7 context-management-2025-06-27 // 8 prompt-caching-scope-2026-01-05 // 9 mid-conversation-system-2026-04-07 models accepting a role=system turn -// 10 advanced-tool-use-2025-11-20 requests with tools -// 11 effort-2025-11-24 -// 12 server-side-fallback-2026-06-01 -// 13 fallback-credit-2026-06-01 -// 14 fast-mode-2026-02-01 speed:fast requests only -// 15 extended-cache-ttl-2025-04-11 OAuth credentials only -// 16 cache-diagnosis-2026-04-07 requests with diagnostics only +// 10 advisor-tool-2026-03-01 requests declaring advisor tools or requesting advisor beta +// 11 advanced-tool-use-2025-11-20 requests with tools +// 12 effort-2025-11-24 +// 13 server-side-fallback-2026-06-01 +// 14 fallback-credit-2026-06-01 +// 15 fast-mode-2026-02-01 speed:fast requests only +// 16 extended-cache-ttl-2025-04-11 OAuth credentials only +// 17 cache-diagnosis-2026-04-07 requests with diagnostics only // // An empty body keeps the optimistic role=system default, matching the cloaking // policy for unknown and future model IDs. @@ -120,6 +122,9 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) if !claudeUsesLegacySystemReminder(body) { betas = append(betas, claudeMidConvSystemBeta) } + if requested[claudeAdvisorToolBeta] || claudeBodyHasAdvisorTool(body) { + betas = append(betas, claudeAdvisorToolBeta) + } if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { betas = append(betas, claudeAdvancedToolUseBeta) } @@ -144,6 +149,22 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) return strings.Join(betas, ",") } +// claudeBodyHasAdvisorTool reports whether the request body declares an +// advisor server tool. +func claudeBodyHasAdvisorTool(body []byte) bool { + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return false + } + for _, tool := range tools.Array() { + toolType := strings.ToLower(strings.TrimSpace(tool.Get("type").String())) + if strings.HasPrefix(toolType, "advisor_") { + return true + } + } + return false +} + // claudeThinkingDisplaySet reports whether the request carries a thinking.display // value. Claude Code 2.1.220 and redact-thinking-2026-02-12 are mutually // exclusive by construction: the beta is only appended while thinking summaries @@ -253,6 +274,41 @@ func withClaudeOAuthCredentialBetas(betas string) string { return strings.Join(parts, ",") } +// withClaudeAdvisorToolBeta ensures advisor-tool-2026-03-01 is present when +// the body declares an advisor server tool, placed at the observed wire position +// before advanced-tool-use-2025-11-20 or effort-2025-11-24. +func withClaudeAdvisorToolBeta(betas string) string { + if strings.TrimSpace(betas) == "" { + return claudeAdvisorToolBeta + } + parts := make([]string, 0, 16) + seen := make(map[string]bool) + for _, beta := range strings.Split(betas, ",") { + if beta = strings.TrimSpace(beta); beta != "" && beta != claudeAdvisorToolBeta && !seen[beta] { + parts = append(parts, beta) + seen[beta] = true + } + } + insertAt := len(parts) + for index, beta := range parts { + if beta == claudeAdvancedToolUseBeta || + beta == claudeEffortBeta || + beta == claudeServerSideFallbackBeta || + beta == claudeFallbackCreditBeta || + beta == claudeStructuredOutputsBeta || + beta == claudeFastModeBeta || + beta == claudeExtendedCacheTTLBeta || + beta == claudeCacheDiagnosisBeta { + insertAt = index + break + } + } + parts = append(parts, "") + copy(parts[insertAt+1:], parts[insertAt:]) + parts[insertAt] = claudeAdvisorToolBeta + return strings.Join(parts, ",") +} + // claudeEntitlementError marks an upstream refusal that is a property of the // request shape combined with the account's entitlements, not of the credential's // health. The auth manager must neither rotate nor cool down on these. @@ -729,15 +785,24 @@ func applyClaudeHeadersWithNativeProfile( incomingBetas := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ",")) countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens") + requestedMap := claudeRequestedBetas(incomingBetas, extraBetas) + advisorNeeded := requestedMap[claudeAdvisorToolBeta] || claudeBodyHasAdvisorTool(body) + baseBetas := incomingBetas if !preserveCallerFingerprint { - baseBetas = claudeCodeCLIBetas(body, claudeRequestedBetas(incomingBetas, extraBetas), useOAuthBetas) + baseBetas = claudeCodeCLIBetas(body, requestedMap, useOAuthBetas) if countTokens { baseBetas = claudeCountTokensBetasForCredential(useOAuthBetas) + if advisorNeeded { + baseBetas = withClaudeAdvisorToolBeta(baseBetas) + } } } if confirmedClaudeCode && incomingBetas != "" { baseBetas = incomingBetas + if advisorNeeded { + baseBetas = withClaudeAdvisorToolBeta(baseBetas) + } // Measured Haiku helper requests already carry the exact credential // beta profile and intentionally omit extended-cache-ttl. if useOAuthBetas && !helperProfile { @@ -748,6 +813,9 @@ func applyClaudeHeadersWithNativeProfile( } } } + if preserveCallerFingerprint && advisorNeeded { + baseBetas = withClaudeAdvisorToolBeta(baseBetas) + } existingSet := make(map[string]bool) for _, beta := range strings.Split(baseBetas, ",") { if beta = strings.TrimSpace(beta); beta != "" { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 477388d3..0c9b859c 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -5596,6 +5596,17 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { body: `{"model":"claude-opus-4-6","thinking":{"type":"adaptive","display":" "}}`, want: constants + ",effort-2025-11-24", }, + { + name: "advisor tool beta requested placed before advanced-tool-use", + body: `{"model":"claude-opus-5","tools":[{"name":"Read"}]}`, + requested: map[string]bool{"advisor-tool-2026-03-01": true}, + want: constants + ",mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24", + }, + { + name: "body with advisor server tool automatically adds advisor-tool beta", + body: `{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`, + want: constants + ",mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24", + }, } for _, tt := range tests { diff --git a/internal/util/nocopy_invariant_test.go b/internal/util/nocopy_invariant_test.go index c04d3e50..bd25eab0 100644 --- a/internal/util/nocopy_invariant_test.go +++ b/internal/util/nocopy_invariant_test.go @@ -106,7 +106,7 @@ type reviewedInPlaceByteWrite struct { var reviewedInPlaceByteWrites = map[string]reviewedInPlaceByteWrite{ "internal/runtime/executor/claude_signing.go": {2, "writes CCH digits into bytes.Clone(body); the caller's body is never touched"}, "internal/runtime/executor/claude_executor_cloaking.go": {1, "shifts []string headers to prepend a block; no byte of any payload is rewritten"}, - "internal/runtime/executor/claude_executor_request.go": {2, "shifts []string headers to insert a part; no byte of any payload is rewritten"}, + "internal/runtime/executor/claude_executor_request.go": {3, "shifts []string headers to insert a part; no byte of any payload is rewritten"}, "internal/runtime/executor/helps/claude_mcp_alias.go": {1, "copies an HMAC sum into a local fixed-size digest array"}, "internal/client/codex/live/tcp_proxy.go": {1, "copies header and payload into a freshly allocated frame"}, "internal/home/client.go": {1, "zeroes a secret buffer after json.Unmarshal has copied every value out"}, -- 2.54.0