fix(claude): scope Anthropic beta and count_tokens policies

Claude Code builds Anthropic-Beta per request instead of sending a fixed list.
Captured from an isolated 2.1.220 profile pointed at api.anthropic.com through
a local proxy, over two rounds covering 11 model IDs and the [1m] variants:

  constant  claude-code, interleaved-thinking, redact-thinking,
            thinking-token-count, context-management, prompt-caching-scope
  tools     advanced-tool-use-2025-11-20 only when tools are declared
  model     mid-conversation-system-2026-04-07 only on models that accept a
            role=system turn
  [1m]      context-1m-2025-08-07, directly after claude-code-20250219 rather
            than at the end
  trailing  effort-2025-11-24, then server-side-fallback-2026-06-01

claude-sonnet-5 emits mid-conversation-system-2026-04-07, so it accepts a
role=system turn and must not sit in the legacy reminder whitelist.

count_tokens does not reuse the inference fingerprint. Running /context in an
interactive session issues 37 identical calls, which made the endpoint
observable for the first time: four betas only, and 21 headers rather than 22
because X-Stainless-Timeout is absent. The profile is selected from the request
path so no call site has to thread another flag.
This commit is contained in:
sususu
2026-08-02 11:01:17 +08:00
parent afdd251cca
commit a2933c7737
9 changed files with 882 additions and 87 deletions

View File

@@ -423,6 +423,15 @@ nonstream-keepalive-interval: 0
# experimental-cch-signing: false # deprecated compatibility field; CCH is generated automatically
# # all Claude OAuth requests sign, including custom gateways; direct Anthropic/Vertex paths also sign
# Anthropic-Beta is assembled per request rather than sent as a fixed list, matching
# Claude Code 2.1.220: context-1m sits right after claude-code, mid-conversation-system
# is added only for models that accept a role=system turn, advanced-tool-use only when
# the request declares tools, and server-side-fallback / fallback-credit /
# structured-outputs trail effort. On direct api.anthropic.com a caller may only ask for
# betas real Claude Code also sends, and they are placed at their observed positions;
# anything else is dropped so the outgoing set stays one a real client could produce.
# Other Anthropic-compatible upstreams still forward caller betas verbatim.
#
# Default headers for Claude API requests. Update when Claude Code releases new versions.
# Unconfirmed clients use this minimum CLI baseline; verified native Claude Code CLI,
# sdk-cli, and VSCode requests preserve or may upgrade their real software fingerprint. In legacy mode,

View File

@@ -178,7 +178,7 @@ func (e *ClaudeExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Au
return nil
}
useAPIKey := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != ""
isAnthropicBase := req.URL != nil && strings.EqualFold(req.URL.Scheme, "https") && strings.EqualFold(req.URL.Host, "api.anthropic.com")
isAnthropicBase := isAnthropicUpstreamURL(req.URL)
if isAnthropicBase && useAPIKey {
req.Header.Del("Authorization")
req.Header.Set("x-api-key", apiKey)

View File

@@ -0,0 +1,215 @@
package executor
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
const claudeRaceProbeOAuthKey = "sk-ant-oat-beta-policy"
func claudeOAuthAuthForBetaPolicy() *cliproxyauth.Auth {
return &cliproxyauth.Auth{
ID: "claude-beta-policy",
Metadata: map[string]any{"access_token": claudeRaceProbeOAuthKey},
}
}
// A confirmed native client authenticates to CPA with the user's configured key
// and cannot know CPA will pick an OAuth credential upstream, so its header never
// carries the OAuth betas. Passing it through verbatim produced a Bearer request
// declaring neither of them.
func TestApplyClaudeHeaders_ConfirmedClientKeepsOAuthCredentialBetas(t *testing.T) {
incoming := http.Header{}
incoming.Set("Anthropic-Beta", claudeCodeBeta+",interleaved-thinking-2025-05-14,"+claudeEffortBeta)
req := newClaudeHeaderTestRequest(t, nil)
if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil,
[]byte(`{"model":"claude-opus-5"}`), nil, incoming, true); err != nil {
t.Fatalf("applyClaudeHeaders() error = %v", err)
}
got := req.Header.Get("Anthropic-Beta")
parts := strings.Split(got, ",")
if len(parts) < 2 || parts[0] != claudeCodeBeta || parts[1] != claudeOAuthBeta {
t.Fatalf("Anthropic-Beta = %q, want %s at position 2", got, claudeOAuthBeta)
}
if parts[len(parts)-1] != claudeExtendedCacheTTLBeta {
t.Fatalf("Anthropic-Beta = %q, want %s last", got, claudeExtendedCacheTTLBeta)
}
// The caller's own betas survive the restoration.
for _, want := range []string{"interleaved-thinking-2025-05-14", claudeEffortBeta} {
if !strings.Contains(got, want) {
t.Fatalf("Anthropic-Beta = %q, want caller beta %s preserved", got, want)
}
}
}
func TestApplyClaudeHeaders_ConfirmedAPIKeyClientKeepsPurePassthrough(t *testing.T) {
incoming := http.Header{}
incoming.Set("Anthropic-Beta", claudeCodeBeta+","+claudeEffortBeta)
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-passthrough"}}
req := newClaudeHeaderTestRequest(t, nil)
if err := applyClaudeHeaders(req, auth, "key-passthrough", false, nil,
[]byte(`{"model":"claude-opus-5"}`), nil, incoming, true); err != nil {
t.Fatalf("applyClaudeHeaders() error = %v", err)
}
if got, want := req.Header.Get("Anthropic-Beta"), claudeCodeBeta+","+claudeEffortBeta; got != want {
t.Fatalf("Anthropic-Beta = %q, want untouched passthrough %q", got, want)
}
}
// Betas lifted out of the body must obey the same policy as header-supplied ones.
// Anthropic rejects an unknown beta outright, so letting the body bypass the gate
// turned a caller-controlled field into a guaranteed 400.
func TestApplyClaudeHeaders_UnknownBodyBetaDroppedOnAnthropic(t *testing.T) {
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-body-beta"}}
req := newClaudeHeaderTestRequest(t, nil)
if err := applyClaudeHeaders(req, auth, "key-body-beta", false, []string{"unknown-body-probe-2099-01-01"},
[]byte(`{"model":"claude-opus-5"}`), nil, nil, false); err != nil {
t.Fatalf("applyClaudeHeaders() error = %v", err)
}
if got := req.Header.Get("Anthropic-Beta"); strings.Contains(got, "unknown-body-probe-2099-01-01") {
t.Fatalf("Anthropic-Beta = %q, want the unknown body beta dropped", got)
}
}
func TestApplyClaudeHeaders_KnownBodyBetaStillPlacedOnAnthropic(t *testing.T) {
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-known-body-beta"}}
req := newClaudeHeaderTestRequest(t, nil)
if err := applyClaudeHeaders(req, auth, "key-known-body-beta", false, []string{claudeContext1MBeta},
[]byte(`{"model":"claude-opus-5"}`), nil, nil, false); err != nil {
t.Fatalf("applyClaudeHeaders() error = %v", err)
}
got := req.Header.Get("Anthropic-Beta")
parts := strings.Split(got, ",")
if len(parts) < 2 || parts[1] != claudeContext1MBeta {
t.Fatalf("Anthropic-Beta = %q, want %s honored at its captured position", got, claudeContext1MBeta)
}
}
// Custom credential headers run after the whole header set is assembled, so they
// could rewrite the reconstructed identity on Anthropic itself.
func TestApplyClaudeHeaders_CustomHeadersCannotOverrideAnthropicIdentity(t *testing.T) {
auth := &cliproxyauth.Auth{Attributes: map[string]string{
"api_key": "key-custom-headers",
"header:Anthropic-Beta": "attacker-controlled-2099-01-01",
"header:Accept-Encoding": "identity",
}}
for _, stream := range []bool{false, true} {
req := newClaudeHeaderTestRequest(t, nil)
if err := applyClaudeHeaders(req, auth, "key-custom-headers", stream, nil,
[]byte(`{"model":"claude-opus-5"}`), nil, nil, false); err != nil {
t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err)
}
if got := req.Header.Get("Anthropic-Beta"); got == "attacker-controlled-2099-01-01" {
t.Fatalf("stream=%v: custom header overrode Anthropic-Beta", stream)
}
if got := req.Header.Get("Accept-Encoding"); got != "gzip, deflate, br, zstd" {
t.Fatalf("stream=%v: Accept-Encoding = %q, want the negotiated transport", stream, got)
}
}
}
// Kimi rewrites base_url to api.kimi.com and custom gateways set their own host,
// yet both delegate to ClaudeExecutor and are therefore cloaked. Keying the
// context_management injection on the cloaked flag alone leaked a Claude Code
// field into their traffic.
func TestClaudeExecutor_ContextManagementNeverLeaksToOtherUpstreams(t *testing.T) {
var upstreamBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
upstreamBody = bytes.Clone(body)
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)
}))
defer server.Close()
executor := NewClaudeExecutor(&config.Config{})
auth := &cliproxyauth.Auth{
ID: "claude-non-anthropic-upstream",
Attributes: map[string]string{"api_key": "sk-ant-oat-non-anthropic", "base_url": server.URL},
}
payload := []byte(`{"model":"claude-opus-5","system":"p","messages":[{"role":"user","content":"hi"}]}`)
if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
Model: "claude-opus-5",
Payload: payload,
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
t.Fatalf("non-Anthropic upstream received context_management = %s", got.Raw)
}
}
func TestIsAnthropicUpstreamBase(t *testing.T) {
cases := map[string]bool{
"https://api.anthropic.com": true,
"https://API.Anthropic.com": true,
"https://api.kimi.com": false,
"http://api.anthropic.com": false,
"https://api.anthropic.com.evil": false,
"https://gateway.example.com": false,
"": false,
}
for base, want := range cases {
if got := isAnthropicUpstreamBase(base); got != want {
t.Fatalf("isAnthropicUpstreamBase(%q) = %v, want %v", base, got, want)
}
}
}
// Streaming previously never reached the fast-mode derivation, so speed:"fast"
// produced a 400 on every streamed request.
func TestApplyClaudeHeaders_FastModeBetaMatchesAcrossStreamModes(t *testing.T) {
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-fast-parity"}}
body := []byte(`{"model":"claude-opus-5","speed":"fast"}`)
var seen []string
for _, stream := range []bool{false, true} {
req := newClaudeHeaderTestRequest(t, nil)
if err := applyClaudeHeaders(req, auth, "key-fast-parity", stream, nil, body, nil, nil, false); err != nil {
t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err)
}
got := req.Header.Get("Anthropic-Beta")
if !strings.Contains(got, claudeFastModeBeta) {
t.Fatalf("stream=%v: Anthropic-Beta = %q, want %s", stream, got, claudeFastModeBeta)
}
seen = append(seen, got)
}
if seen[0] != seen[1] {
t.Fatalf("stream and non-stream disagree:\n non-stream %q\n stream %q", seen[0], seen[1])
}
}
// extended-cache-ttl is the one measured trailing invariant; fast-mode has no
// captured position and must not displace it.
func TestApplyClaudeHeaders_FastModePrecedesOAuthTrailer(t *testing.T) {
req := newClaudeHeaderTestRequest(t, nil)
if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, true, nil,
[]byte(`{"model":"claude-opus-5","speed":"fast"}`), nil, nil, false); err != nil {
t.Fatalf("applyClaudeHeaders() error = %v", err)
}
got := req.Header.Get("Anthropic-Beta")
parts := strings.Split(got, ",")
if parts[len(parts)-1] != claudeExtendedCacheTTLBeta {
t.Fatalf("Anthropic-Beta = %q, want %s last", got, claudeExtendedCacheTTLBeta)
}
if parts[len(parts)-2] != claudeFastModeBeta {
t.Fatalf("Anthropic-Beta = %q, want %s immediately before the OAuth trailer", got, claudeFastModeBeta)
}
}

View File

@@ -263,7 +263,6 @@ var claudeLegacySystemReminderModels = map[string]struct{}{
"claude-sonnet-4-5": {},
"claude-sonnet-4-5-20250929": {},
"claude-sonnet-4-6": {},
"claude-sonnet-5": {},
}
func claudeUsesLegacySystemReminder(payload []byte) bool {
@@ -528,6 +527,28 @@ func injectClaudeCodeCurrentDate(payload []byte, now time.Time) []byte {
return payload
}
// claudeCodeContextManagement is the context_management object Claude Code
// 2.1.220 sends on every Messages request, captured 2026-08-01 from an isolated
// profile talking to api.anthropic.com. keep:"all" retains every thinking block,
// so replicating the client's exact value cannot produce upstream behaviour the
// real client does not already get.
const claudeCodeContextManagement = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}`
// injectClaudeCodeContextManagement supplies context_management when the caller
// omitted it. CPA already claims context-management-2025-06-27 in Anthropic-Beta,
// so a missing body field is an observable inconsistency with the real client. A
// caller that sent its own object keeps it untouched.
func injectClaudeCodeContextManagement(payload []byte) []byte {
if gjson.GetBytes(payload, "context_management").Exists() {
return payload
}
updated, err := sjson.SetRawBytes(payload, "context_management", []byte(claudeCodeContextManagement))
if err != nil {
return payload
}
return updated
}
func withEphemeralCacheControl(rawBlock string) string {
updated, err := sjson.SetRawBytes([]byte(rawBlock), "cache_control", []byte(`{"type":"ephemeral"}`))
if err != nil {

View File

@@ -76,6 +76,11 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
if err != nil {
return resp, err
}
// Only the Messages endpoint on Anthropic itself was captured; count_tokens
// keeps its own shape and other gateways never see this field.
if cloaked && isAnthropicUpstreamBase(baseURL) {
body = injectClaudeCodeContextManagement(body)
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
@@ -106,7 +111,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
// Extract betas from body and convert to header
var extraBetas []string
extraBetas, body = extractAndRemoveBetas(body)
extraBetas = appendClaudeFastModeBeta(body, extraBetas)
bodyForTranslation := body
bodyForUpstream := body
var oauthToolNamesReverseMap map[string]string
@@ -133,7 +137,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
if err != nil {
return resp, err
}
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, upstreamStream, extraBetas, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil {
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, upstreamStream, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil {
return resp, errHeaders
}
var authID, authLabel, authType, authValue string

View File

@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/andybalholm/brotli"
@@ -26,11 +27,202 @@ import (
)
const (
defaultClaudeCodeCLIBetas = "claude-code-20250219,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,mid-conversation-system-2026-04-07,effort-2025-11-24,fallback-credit-2026-06-01"
claudeTokenCountingBeta = "token-counting-2024-11-01"
claudeFastModeBeta = "fast-mode-2026-02-01"
claudeTokenCountingBeta = "token-counting-2024-11-01"
claudeFastModeBeta = "fast-mode-2026-02-01"
claudeOAuthBeta = "oauth-2025-04-20"
claudeCodeBeta = "claude-code-20250219"
claudeContext1MBeta = "context-1m-2025-08-07"
claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07"
claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20"
claudeEffortBeta = "effort-2025-11-24"
claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01"
claudeFallbackCreditBeta = "fallback-credit-2026-06-01"
claudeStructuredOutputsBeta = "structured-outputs-2025-12-15"
claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11"
)
// claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every
// /v1/messages request from the "cli" entrypoint, in wire order, excluding the
// leading claude-code-20250219.
//
// redact-thinking-2026-02-12 belongs here because cloaked requests always claim
// cc_entrypoint=cli; the "sdk-cli" entrypoint omits it.
var claudeCodeCLIConstantBetas = []string{
"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",
}
// claudeCodeTrailingBetas are caller-supplied betas that real Claude Code emits
// after effort-2025-11-24, in that relative order. They are forwarded when the
// caller asks for them and dropped otherwise.
var claudeCodeTrailingBetas = []string{
claudeServerSideFallbackBeta,
claudeFallbackCreditBeta,
claudeStructuredOutputsBeta,
}
// claudeCodeCLIBetas assembles the Anthropic-Beta baseline the way Claude Code
// 2.1.220 does: the list is per-request, not a fixed string. requested holds the
// betas the caller asked for, which decide the capability flags below.
//
// Verified 2026-08-01 against api.anthropic.com with isolated 2.1.220 profiles on
// both the API-key and OAuth paths, across 11 model IDs and the [1m] variants.
// The full observed order is:
//
// 1 claude-code-20250219
// 2 oauth-2025-04-20 OAuth credentials only
// 3 context-1m-2025-08-07 [1m] model variants only
// 4 interleaved-thinking-2025-05-14
// 5 redact-thinking-2026-02-12 cli entrypoint only
// 6 thinking-token-count-2026-05-13
// 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 declaring tools
// 11 effort-2025-11-24
// 12 server-side-fallback-2026-06-01
// 13 fallback-credit-2026-06-01
// 14 extended-cache-ttl-2025-04-11 OAuth credentials only, always last
//
// fast-mode-2026-02-01 has no captured position; it is emitted just before the
// OAuth trailer so the one measured invariant, extended-cache-ttl last, holds.
//
// An empty body keeps the optimistic role=system default, matching the cloaking
// policy for unknown and future model IDs.
func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) string {
betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+6)
betas = append(betas, claudeCodeBeta)
if oauthToken {
betas = append(betas, claudeOAuthBeta)
}
if requested[claudeContext1MBeta] {
betas = append(betas, claudeContext1MBeta)
}
betas = append(betas, claudeCodeCLIConstantBetas...)
if !claudeUsesLegacySystemReminder(body) {
betas = append(betas, claudeMidConvSystemBeta)
}
if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 {
betas = append(betas, claudeAdvancedToolUseBeta)
}
betas = append(betas, claudeEffortBeta)
for _, beta := range claudeCodeTrailingBetas {
if requested[beta] {
betas = append(betas, beta)
}
}
if claudeRequestUsesFastMode(body, requested) {
betas = append(betas, claudeFastModeBeta)
}
if oauthToken {
betas = append(betas, claudeExtendedCacheTTLBeta)
}
return strings.Join(betas, ",")
}
// claudeRequestUsesFastMode reports whether the request selects the fast service
// tier. Anthropic rejects the body's speed field with "Extra inputs are not
// permitted" unless fast-mode-2026-02-01 is declared, so the beta has to follow
// the body. Deriving it here rather than at the call sites is deliberate: the
// streaming and non-streaming paths previously disagreed and streaming silently
// dropped the beta, turning every fast request into a 400.
func claudeRequestUsesFastMode(body []byte, requested map[string]bool) bool {
if requested[claudeFastModeBeta] {
return true
}
speed := gjson.GetBytes(body, "speed")
return speed.Type == gjson.String && strings.EqualFold(strings.TrimSpace(speed.String()), "fast")
}
// claudeCountTokensBetas is the fixed profile Claude Code 2.1.220 sends to
// /v1/messages/count_tokens. It is far smaller than the inference baseline:
// redact-thinking, thinking-token-count, prompt-caching-scope, effort and every
// conditional beta are absent. Verified identical across 37 captured calls.
var claudeCountTokensBetas = []string{
claudeCodeBeta,
"interleaved-thinking-2025-05-14",
"context-management-2025-06-27",
claudeTokenCountingBeta,
}
// withClaudeOAuthCredentialBetas restores the two betas that describe the
// upstream credential rather than the caller's capabilities.
//
// A confirmed native client authenticates to CPA with whatever key the user
// configured and cannot know that CPA will select an OAuth credential upstream,
// so its header never carries the OAuth betas. Passing it through verbatim ships
// a Bearer request that declares neither oauth-2025-04-20 nor
// extended-cache-ttl-2025-04-11, which no real OAuth client ever does. Passthrough
// governs what the caller expressed; the credential is CPA's own choice and has to
// be described accurately.
//
// Betas already present are left exactly where the caller put them.
func withClaudeOAuthCredentialBetas(betas string) string {
parts := make([]string, 0, 16)
seen := make(map[string]bool)
for _, beta := range strings.Split(betas, ",") {
if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] {
parts = append(parts, beta)
seen[beta] = true
}
}
if !seen[claudeOAuthBeta] {
// Captured position 2, directly after claude-code-20250219.
insertAt := 0
if len(parts) > 0 && parts[0] == claudeCodeBeta {
insertAt = 1
}
parts = append(parts, "")
copy(parts[insertAt+1:], parts[insertAt:])
parts[insertAt] = claudeOAuthBeta
}
if !seen[claudeExtendedCacheTTLBeta] {
parts = append(parts, claudeExtendedCacheTTLBeta)
}
return strings.Join(parts, ",")
}
// claudeRequestedBetas collects every beta the caller asked for, from the
// Anthropic-Beta header and from betas lifted out of the request body.
func claudeRequestedBetas(incomingBetas string, extraBetas []string) map[string]bool {
requested := make(map[string]bool)
for _, beta := range strings.Split(incomingBetas, ",") {
if beta = strings.TrimSpace(beta); beta != "" {
requested[beta] = true
}
}
for _, beta := range extraBetas {
if beta = strings.TrimSpace(beta); beta != "" {
requested[beta] = true
}
}
return requested
}
// isAnthropicUpstreamURL reports whether a resolved request targets Anthropic's
// first-party API.
//
// Every rule that reconstructs Claude Code's identity must key on this rather
// than on the cloaked flag. Kimi rewrites base_url to api.kimi.com and custom
// gateways set their own host, yet both delegate to ClaudeExecutor and are
// therefore cloaked; a cloak-keyed rule silently rewrites their traffic too.
func isAnthropicUpstreamURL(u *url.URL) bool {
return u != nil && strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "api.anthropic.com")
}
// isAnthropicUpstreamBase reports whether a configured base URL targets Anthropic's
// first-party API. Used before the outgoing request exists.
func isAnthropicUpstreamBase(baseURL string) bool {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
return isAnthropicUpstreamURL(parsed)
}
// extractAndRemoveBetas extracts the "betas" array from the body and removes it.
// Returns the extracted betas as a string slice and the modified body.
func extractAndRemoveBetas(body []byte) ([]string, []byte) {
@@ -52,19 +244,6 @@ func extractAndRemoveBetas(body []byte) ([]string, []byte) {
return betas, body
}
func appendClaudeFastModeBeta(body []byte, betas []string) []string {
speed := gjson.GetBytes(body, "speed")
if speed.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(speed.String()), "fast") {
return betas
}
for _, beta := range betas {
if strings.TrimSpace(beta) == claudeFastModeBeta {
return betas
}
}
return append(betas, claudeFastModeBeta)
}
// disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.
// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations
@@ -227,7 +406,7 @@ func decodeResponseBody(body io.ReadCloser, contentEncoding string) (io.ReadClos
return body, nil
}
func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, cfg *config.Config, incomingHeaders http.Header, confirmedClaudeCode bool, sessionIDs ...string) error {
func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool, extraBetas []string, body []byte, cfg *config.Config, incomingHeaders http.Header, confirmedClaudeCode bool, sessionIDs ...string) error {
if r == nil {
return nil
}
@@ -246,7 +425,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
hasAPIKeyAttr := auth != nil && auth.Attributes != nil && strings.TrimSpace(auth.Attributes["api_key"]) != ""
oauthToken := isClaudeOAuthToken(apiKey) || !hasAPIKeyAttr
useAPIKey := !oauthToken
isAnthropicBase := r.URL != nil && strings.EqualFold(r.URL.Scheme, "https") && strings.EqualFold(r.URL.Host, "api.anthropic.com")
isAnthropicBase := isAnthropicUpstreamURL(r.URL)
if isAnthropicBase && useAPIKey {
r.Header.Del("Authorization")
r.Header.Set("x-api-key", apiKey)
@@ -270,10 +449,17 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
}
}
baseBetas := defaultClaudeCodeCLIBetas
incomingBetas := strings.TrimSpace(strings.Join(incomingHeaders.Values("Anthropic-Beta"), ","))
countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens")
baseBetas := claudeCodeCLIBetas(body, claudeRequestedBetas(incomingBetas, extraBetas), oauthToken)
if countTokens {
baseBetas = strings.Join(claudeCountTokensBetas, ",")
}
if confirmedClaudeCode && incomingBetas != "" {
baseBetas = incomingBetas
if oauthToken && !countTokens {
baseBetas = withClaudeOAuthCredentialBetas(baseBetas)
}
}
existingSet := make(map[string]bool)
for _, beta := range strings.Split(baseBetas, ",") {
@@ -289,16 +475,32 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
baseBetas += "," + beta
existingSet[beta] = true
}
if !confirmedClaudeCode && incomingBetas != "" {
// On direct Anthropic an unconfirmed caller's own betas are dropped: appending
// them to the official baseline produces a combination real Claude Code never
// sends, which defeats the identity the rest of this path reconstructs. Other
// Anthropic-compatible upstreams (Kimi, custom gateways) run no such check, so
// caller betas stay functional there. This matches the CCH signing gate, which
// is likewise limited to api.anthropic.com.
if !confirmedClaudeCode && incomingBetas != "" && !isAnthropicBase {
for _, beta := range strings.Split(incomingBetas, ",") {
appendBeta(beta)
}
}
if oauthToken {
appendBeta("oauth-2025-04-20")
// The OAuth betas have known positions on /v1/messages and are placed by
// claudeCodeCLIBetas. count_tokens was only captured over an API key, so its
// OAuth shape keeps the previous appended form until it can be measured.
if oauthToken && countTokens {
appendBeta(claudeOAuthBeta)
}
for _, beta := range extraBetas {
appendBeta(beta)
// Betas lifted out of the body follow the same policy as header-supplied ones.
// Known betas already reached the assembled baseline through the requested map,
// which places them at their captured positions; anything left over is unknown
// to Claude Code and Anthropic rejects it outright. Forwarding those verbatim
// here was letting the body bypass the gate the header path enforces.
if !isAnthropicBase {
for _, beta := range extraBetas {
appendBeta(beta)
}
}
r.Header.Set("Anthropic-Beta", baseBetas)
@@ -316,7 +518,15 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
identityHeader("X-Stainless-Retry-Count", "0")
identityHeader("X-Stainless-Runtime", "node")
identityHeader("X-Stainless-Lang", "js")
identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
// Claude Code omits X-Stainless-Timeout on count_tokens; only a confirmed
// native client that sent one of its own keeps it there.
if !countTokens {
identityHeader("X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
} else if confirmedClaudeCode {
if incomingTimeout := incomingHeaders.Get("X-Stainless-Timeout"); incomingTimeout != "" {
r.Header.Set("X-Stainless-Timeout", incomingTimeout)
}
}
// Selected-credential OAuth identity is an explicit native passthrough
// exception. Callers pass the same agent-conversation UUID written to
// metadata.user_id; legacy paths retain their previous cached fallback.
@@ -342,16 +552,26 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
identityHeader("x-client-request-id", uuid.New().String())
}
r.Header.Set("Connection", "keep-alive")
if stream {
r.Header.Set("Accept", "text/event-stream")
// SSE streams must not be compressed: the downstream scanner reads
// line-delimited text and cannot parse compressed bytes. Using
// "identity" tells the upstream to send an uncompressed stream.
r.Header.Set("Accept-Encoding", "identity")
} else {
// Claude Code negotiates transport identically for streaming and non-streaming
// requests: Accept stays application/json and full compression is offered even
// when the body sets stream:true, because Anthropic selects SSE from the body
// rather than from Accept. Verified across every captured 2.1.220 stream.
// Forcing text/event-stream plus identity here would otherwise mark every
// streaming request, which is nearly all traffic. decodeResponseBody already
// wraps the success path, so a compressed SSE body is decoded transparently.
applyTransportNegotiation := func() {
if stream && !isAnthropicBase {
// Other Anthropic-compatible upstreams (Kimi, custom gateways) may select
// SSE from Accept and need not compress predictably, so they keep the
// conservative contract.
r.Header.Set("Accept", "text/event-stream")
r.Header.Set("Accept-Encoding", "identity")
return
}
r.Header.Set("Accept", "application/json")
r.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
}
applyTransportNegotiation()
// Confirmed Claude Code requests may contribute their real software profile.
// Unconfirmed clients always receive the CLI baseline instead of being
// allowed to populate or reuse another client's software profile.
@@ -369,11 +589,19 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
attrs = auth.Attributes
}
util.ApplyCustomHeadersFromAttrs(r, attrs)
// Re-enforce the SSE transport contract after custom headers. A custom Accept
// value can disable event negotiation, while compressed SSE breaks line parsing.
if stream {
r.Header.Set("Accept", "text/event-stream")
r.Header.Set("Accept-Encoding", "identity")
// Custom credential headers are a configuration escape hatch for third-party
// gateways, so they keep the last word there. On api.anthropic.com they must
// not rewrite the reconstructed identity: an overridden Anthropic-Beta yields a
// combination real Claude Code never sends and the API rejects, and an
// overridden Accept-Encoding contradicts the negotiated transport. Both were
// reachable because this ran after the whole header set was assembled.
if isAnthropicBase {
r.Header.Set("Anthropic-Beta", baseBetas)
applyTransportNegotiation()
} else if stream {
// Elsewhere only streaming is protected, so an Accept override cannot
// silently disable event negotiation.
applyTransportNegotiation()
}
return nil
}

View File

@@ -76,6 +76,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if err != nil {
return nil, err
}
// Only the Messages endpoint on Anthropic itself was captured; count_tokens
// keeps its own shape and other gateways never see this field.
if cloaked && isAnthropicUpstreamBase(baseURL) {
body = injectClaudeCodeContextManagement(body)
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
@@ -126,7 +131,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if err != nil {
return nil, err
}
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil {
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil {
return nil, errHeaders
}
var authID, authLabel, authType, authValue string

View File

@@ -71,8 +71,9 @@ func assertClaudeFingerprint(t *testing.T, headers http.Header, userAgent, pkgVe
}
func TestApplyClaudeHeaders_FastModeBetaIsConditional(t *testing.T) {
const betasWithoutFastMode = defaultClaudeCodeCLIBetas
const betasWithFastMode = defaultClaudeCodeCLIBetas + "," + claudeFastModeBeta
baseline := claudeCodeCLIBetas([]byte(`{"model":"claude-opus-5"}`), nil, false)
betasWithoutFastMode := baseline
betasWithFastMode := baseline + "," + claudeFastModeBeta
tests := []struct {
name string
@@ -100,9 +101,8 @@ func TestApplyClaudeHeaders_FastModeBetaIsConditional(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
extraBetas, body := extractAndRemoveBetas([]byte(tt.body))
extraBetas = appendClaudeFastModeBeta(body, extraBetas)
req := newClaudeHeaderTestRequest(t, nil)
if errApply := applyClaudeHeaders(req, auth, "key-fast-mode-beta", false, extraBetas, nil, nil, false); errApply != nil {
if errApply := applyClaudeHeaders(req, auth, "key-fast-mode-beta", false, extraBetas, body, nil, nil, false); errApply != nil {
t.Fatalf("applyClaudeHeaders() error = %v", errApply)
}
if got := req.Header.Get("Anthropic-Beta"); got != tt.want {
@@ -142,6 +142,27 @@ func assertClaudeCredentialIdentity(t *testing.T, body []byte, headers http.Head
}
}
// assertClaudeCountTokensIdentity pins the count_tokens shape captured from real
// Claude Code 2.1.220: the endpoint carries no metadata whatsoever. Anthropic
// rejects the field there with "metadata: Extra inputs are not permitted", so the
// credential identity travels only on the header and on the Messages endpoint.
func assertClaudeCountTokensIdentity(t *testing.T, body []byte, headers http.Header) {
t.Helper()
if got := gjson.GetBytes(body, "metadata"); got.Exists() {
t.Fatalf("count_tokens metadata = %s, want it absent", got.Raw)
}
if got := headers.Get("X-Claude-Code-Session-Id"); got == "" {
t.Fatal("count_tokens is missing X-Claude-Code-Session-Id")
}
resigned, errResign := finalizeAnthropicMessagesBodyCCH(body, "")
if errResign != nil {
t.Fatalf("re-finalize Claude CCH: %v", errResign)
}
if !bytes.Equal(resigned, body) {
t.Fatal("count_tokens CCH was calculated before the final body rewrite")
}
}
func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) {
resetClaudeDeviceProfileCache()
stabilize := true
@@ -176,7 +197,7 @@ func TestApplyClaudeHeaders_UsesConfiguredBaselineFingerprint(t *testing.T) {
}
req := newClaudeHeaderTestRequest(t, incoming)
applyClaudeHeaders(req, auth, "key-baseline", false, nil, cfg, nil, false)
applyClaudeHeaders(req, auth, "key-baseline", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, req.Header, "evil-client/9.9", "9.9.9", "v24.5.0", "Linux", "x64")
if got := req.Header.Get("X-Stainless-Timeout"); got != "900" {
@@ -212,7 +233,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) {
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, cfg, nil, true)
applyClaudeHeaders(firstReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "MacOS", "arm64")
thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -222,7 +243,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) {
"X-Stainless-Os": []string{"Windows"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(thirdPartyReq, auth, "key-upgrade", false, nil, cfg, nil, false)
applyClaudeHeaders(thirdPartyReq, auth, "key-upgrade", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "MacOS", "arm64")
higherReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -232,7 +253,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) {
"X-Stainless-Os": []string{"MacOS"},
"X-Stainless-Arch": []string{"arm64"},
})
applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, cfg, nil, true)
applyClaudeHeaders(higherReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, higherReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64")
lowerReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -242,7 +263,7 @@ func TestApplyClaudeHeaders_TracksHighestClaudeCLIFingerprint(t *testing.T) {
"X-Stainless-Os": []string{"Windows"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, cfg, nil, true)
applyClaudeHeaders(lowerReq, auth, "key-upgrade", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.63 (external, cli)", "0.75.0", "v24.4.0", "MacOS", "arm64")
}
@@ -274,7 +295,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(olderClaudeReq, auth, "key-baseline-floor", false, nil, cfg, nil, true)
applyClaudeHeaders(olderClaudeReq, auth, "key-baseline-floor", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, olderClaudeReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64")
newerClaudeReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -284,7 +305,7 @@ func TestApplyClaudeHeaders_DoesNotDowngradeConfiguredBaselineOnFirstClaudeClien
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, cfg, nil, true)
applyClaudeHeaders(newerClaudeReq, auth, "key-baseline-floor", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, newerClaudeReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64")
}
@@ -326,7 +347,7 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, oldCfg, nil, true)
applyClaudeHeaders(officialReq, auth, "key-baseline-reload", false, nil, nil, oldCfg, nil, true)
assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.71 (external, cli)", "0.81.0", "v24.6.0", "MacOS", "arm64")
thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -336,7 +357,7 @@ func TestApplyClaudeHeaders_UpgradesCachedSoftwareFingerprintWhenBaselineAdvance
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(thirdPartyReq, auth, "key-baseline-reload", false, nil, newCfg, nil, false)
applyClaudeHeaders(thirdPartyReq, auth, "key-baseline-reload", false, nil, nil, newCfg, nil, false)
assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64")
}
@@ -368,7 +389,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(thirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil, false)
applyClaudeHeaders(thirdPartyReq, auth, "key-custom-baseline-learning", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, thirdPartyReq.Header, "my-gateway/1.0", "custom-pkg", "custom-runtime", "MacOS", "arm64")
officialReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -378,7 +399,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil, true)
applyClaudeHeaders(officialReq, auth, "key-custom-baseline-learning", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64")
postLearningThirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -388,7 +409,7 @@ func TestApplyClaudeHeaders_LearnsOfficialFingerprintAfterCustomBaselineFallback
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(postLearningThirdPartyReq, auth, "key-custom-baseline-learning", false, nil, cfg, nil, false)
applyClaudeHeaders(postLearningThirdPartyReq, auth, "key-custom-baseline-learning", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, postLearningThirdPartyReq.Header, "my-gateway/1.0", "custom-pkg", "custom-runtime", "MacOS", "arm64")
}
@@ -520,7 +541,7 @@ func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlat
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(thirdPartyReq, auth, "key-third-party-then-official", false, nil, cfg, nil, false)
applyClaudeHeaders(thirdPartyReq, auth, "key-third-party-then-official", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.70 (external, cli)", "0.80.0", "v24.5.0", "MacOS", "arm64")
officialReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -530,7 +551,7 @@ func TestApplyClaudeHeaders_ThirdPartyBaselineThenOfficialUpgradeKeepsPinnedPlat
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, cfg, nil, true)
applyClaudeHeaders(officialReq, auth, "key-third-party-then-official", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, officialReq.Header, "claude-cli/2.1.77 (external, cli)", "0.87.0", "v24.8.0", "MacOS", "arm64")
}
@@ -562,7 +583,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) {
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(firstReq, auth, "key-disable-stability", false, nil, cfg, nil, true)
applyClaudeHeaders(firstReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, firstReq.Header, "claude-cli/2.1.62 (external, cli)", "0.74.0", "v24.3.0", "Linux", "x64")
thirdPartyReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -572,7 +593,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) {
"X-Stainless-Os": []string{"Windows"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(thirdPartyReq, auth, "key-disable-stability", false, nil, cfg, nil, false)
applyClaudeHeaders(thirdPartyReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, thirdPartyReq.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", helps.MapStainlessOS(), helps.MapStainlessArch())
lowerReq := newClaudeHeaderTestRequest(t, http.Header{
@@ -582,7 +603,7 @@ func TestApplyClaudeHeaders_DisableDeviceProfileStabilization(t *testing.T) {
"X-Stainless-Os": []string{"Windows"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(lowerReq, auth, "key-disable-stability", false, nil, cfg, nil, true)
applyClaudeHeaders(lowerReq, auth, "key-disable-stability", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, lowerReq.Header, "claude-cli/2.1.61 (external, cli)", "0.73.0", "v24.2.0", "Windows", "x64")
}
@@ -613,7 +634,7 @@ func TestApplyClaudeHeaders_LegacyModePreservesConfiguredUserAgentOverrideForCla
"X-Stainless-Os": []string{"Linux"},
"X-Stainless-Arch": []string{"x64"},
})
applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, cfg, nil, true)
applyClaudeHeaders(req, auth, "key-legacy-ua-override", false, nil, nil, cfg, nil, true)
assertClaudeFingerprint(t, req.Header, "config-ua/1.0", "0.74.0", "v24.3.0", "Linux", "x64")
}
@@ -642,7 +663,7 @@ func TestApplyClaudeHeaders_LegacyThirdPartyUsesStableConfiguredOSArch(t *testin
req := newClaudeHeaderTestRequest(t, http.Header{
"User-Agent": []string{"curl/8.7.1"},
})
applyClaudeHeaders(req, auth, "key-legacy-runtime-os-arch", false, nil, cfg, nil, false)
applyClaudeHeaders(req, auth, "key-legacy-runtime-os-arch", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "Windows", "x64")
}
@@ -669,7 +690,7 @@ func TestApplyClaudeHeaders_UnsetStabilizationUsesStableConfiguredOSArch(t *test
req := newClaudeHeaderTestRequest(t, http.Header{
"User-Agent": []string{"curl/8.7.1"},
})
applyClaudeHeaders(req, auth, "key-unset-runtime-os-arch", false, nil, cfg, nil, false)
applyClaudeHeaders(req, auth, "key-unset-runtime-os-arch", false, nil, nil, cfg, nil, false)
assertClaudeFingerprint(t, req.Header, "claude-cli/2.1.60 (external, cli)", "0.70.0", "v22.0.0", "Linux", "x64")
}
@@ -677,7 +698,7 @@ func TestApplyClaudeHeaders_UnsetStabilizationUsesStableConfiguredOSArch(t *test
func TestApplyClaudeHeaders_UsesOAuthAuthorizationAndBrowserFingerprint(t *testing.T) {
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-header-test"}}
req := newClaudeHeaderTestRequest(t, nil)
if errHeaders := applyClaudeHeaders(req, auth, "sk-ant-oat-header-test", false, nil, &config.Config{}, nil, false, "11111111-2222-4333-8444-555555555555"); errHeaders != nil {
if errHeaders := applyClaudeHeaders(req, auth, "sk-ant-oat-header-test", false, nil, nil, &config.Config{}, nil, false, "11111111-2222-4333-8444-555555555555"); errHeaders != nil {
t.Fatalf("applyClaudeHeaders() error = %v", errHeaders)
}
if got := req.Header.Get("Authorization"); got != "Bearer sk-ant-oat-header-test" {
@@ -724,8 +745,8 @@ func TestClaudeExecutor_NonClaudeRequestUsesClaudeCode220CLIFingerprint(t *testi
if got := seenHeaders.Get("X-App"); got != "cli" {
t.Fatalf("X-App = %q, want cli", got)
}
if got := seenHeaders.Get("Anthropic-Beta"); got != defaultClaudeCodeCLIBetas {
t.Fatalf("Anthropic-Beta = %q, want %q", got, defaultClaudeCodeCLIBetas)
if want := claudeCodeCLIBetas(payload, nil, false); seenHeaders.Get("Anthropic-Beta") != want {
t.Fatalf("Anthropic-Beta = %q, want %q", seenHeaders.Get("Anthropic-Beta"), want)
}
system := gjson.GetBytes(seenBody, "system").Array()
@@ -1735,6 +1756,70 @@ func TestClaudeExecutor_ExecuteStreamDirectPassthroughEmitsCompleteSSEEvents(t *
}
}
// TestClaudeExecutor_ExecuteStreamDecodesCompressedSSE guards the dependency that
// lets CPA advertise the real client's Accept-Encoding on streaming requests:
// once compression is offered the upstream may compress the SSE body, so the
// streaming success path must decode it and still emit event boundaries intact.
func TestClaudeExecutor_ExecuteStreamDecodesCompressedSSE(t *testing.T) {
firstData := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`
secondData := `{"type":"message_stop"}`
upstreamStream := "event: content_block_delta\n" +
"data: " + firstData + "\n" +
"\n" +
"event: message_stop\n" +
"data: " + secondData + "\n" +
"\n"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Encoding", "gzip")
gzipWriter := gzip.NewWriter(w)
if _, errWrite := gzipWriter.Write([]byte(upstreamStream)); errWrite != nil {
t.Errorf("gzip write: %v", errWrite)
}
if errClose := gzipWriter.Close(); errClose != nil {
t.Errorf("gzip close: %v", errClose)
}
}))
defer server.Close()
executor := NewClaudeExecutor(&config.Config{})
auth := &cliproxyauth.Auth{Attributes: map[string]string{
"api_key": "key-123",
"base_url": server.URL,
}}
payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
result, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
Model: "claude-3-5-sonnet-20241022",
Payload: payload,
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")})
if err != nil {
t.Fatalf("ExecuteStream() error = %v", err)
}
var payloads []string
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("unexpected chunk error: %v", chunk.Err)
}
payloads = append(payloads, string(chunk.Payload))
}
want := []string{
"event: content_block_delta\n" + "data: " + firstData + "\n\n",
"event: message_stop\n" + "data: " + secondData + "\n\n",
}
if len(payloads) != len(want) {
t.Fatalf("payload count = %d, want %d: %#v", len(payloads), len(want), payloads)
}
for i := range want {
if payloads[i] != want[i] {
t.Fatalf("payload[%d] = %q, want %q", i, payloads[i], want[i])
}
}
}
func TestClaudeExecutor_CountTokensExcludesInvalidOpenAIThinking(t *testing.T) {
executor := NewClaudeExecutor(&config.Config{})
countTokens := func(payload []byte) int64 {
@@ -1823,10 +1908,15 @@ func TestClaudeExecutor_CountTokensOAuthUsesUpstreamCLIShape(t *testing.T) {
if got := upstreamHeaders.Get("User-Agent"); got != "claude-cli/2.1.220 (external, cli)" {
t.Fatalf("count_tokens User-Agent = %q, want CLI identity", got)
}
wantBetas := defaultClaudeCodeCLIBetas + ",oauth-2025-04-20," + claudeTokenCountingBeta
// count_tokens carries its own much smaller profile, not the inference baseline.
wantBetas := strings.Join(claudeCountTokensBetas, ",") + "," + claudeOAuthBeta
if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas {
t.Fatalf("count_tokens Anthropic-Beta = %q, want %q", got, wantBetas)
}
// Claude Code omits X-Stainless-Timeout on count_tokens.
if got := upstreamHeaders.Get("X-Stainless-Timeout"); got != "" {
t.Fatalf("count_tokens X-Stainless-Timeout = %q, want it absent", got)
}
if got := gjson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
t.Fatalf("count_tokens system.1.text = %q, want official CLI identity", got)
}
@@ -1846,7 +1936,7 @@ func TestClaudeExecutor_CountTokensOAuthUsesUpstreamCLIShape(t *testing.T) {
if _, ok := claudeBillingCCHDigitsOffset(upstreamBody); !ok {
t.Fatalf("count_tokens Claude OAuth custom BaseURL body is missing CCH: %s", upstreamBody)
}
assertClaudeCredentialIdentity(t, upstreamBody, upstreamHeaders, deviceIDs, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
assertClaudeCountTokensIdentity(t, upstreamBody, upstreamHeaders)
if got := gjson.GetBytes(resp.Payload, "input_tokens").Int(); got != 7 {
t.Fatalf("input_tokens = %d, want 7", got)
}
@@ -1875,7 +1965,7 @@ func TestClaudeExecutor_LegacySystemReminderAcrossMessagesStreamAndCountTokens(t
_, _ = w.Write([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"))
default:
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"msg_legacy","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
_, _ = w.Write([]byte(`{"id":"msg_legacy","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
}
}))
defer server.Close()
@@ -1899,21 +1989,21 @@ func TestClaudeExecutor_LegacySystemReminderAcrossMessagesStreamAndCountTokens(t
if stream {
streamField = `,"stream":true`
}
return []byte(`{"model":"claude-sonnet-5","system":"legacy-system-prompt","messages":[{"role":"user","content":` + fmt.Sprintf("%q", userText) + `}]` + streamField + `}`)
return []byte(`{"model":"claude-opus-4-6","system":"legacy-system-prompt","messages":[{"role":"user","content":` + fmt.Sprintf("%q", userText) + `}]` + streamField + `}`)
}
if _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
Model: "claude-sonnet-5", Payload: makePayload("messages-user", false),
Model: "claude-opus-4-6", Payload: makePayload("messages-user", false),
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
if _, errCount := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{
Model: "claude-sonnet-5", Payload: makePayload("count-user", false),
Model: "claude-opus-4-6", Payload: makePayload("count-user", false),
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); errCount != nil {
t.Fatalf("CountTokens() error = %v", errCount)
}
streamResult, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
Model: "claude-sonnet-5", Payload: makePayload("stream-user", true),
Model: "claude-opus-4-6", Payload: makePayload("stream-user", true),
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
if errStream != nil {
t.Fatalf("ExecuteStream() error = %v", errStream)
@@ -1986,7 +2076,7 @@ func TestClaudeExecutor_CountTokensUpstreamCloakNeverPreservesCustomTool(t *test
if got := gjson.GetBytes(upstreamBody, "tools.0.name").String(); got != "search_web" {
t.Fatalf("count_tokens tool name = %q, want cloak=never passthrough", got)
}
assertClaudeCredentialIdentity(t, upstreamBody, upstreamHeaders, deviceIDs, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
assertClaudeCountTokensIdentity(t, upstreamBody, upstreamHeaders)
}
func TestClaudeExecutor_CountTokensUpstreamConfirmedVSCodePreservesCustomTool(t *testing.T) {
@@ -3376,7 +3466,7 @@ func TestClaudeUsesLegacySystemReminder(t *testing.T) {
tests := map[string]bool{
"claude-opus-4-6": true,
"claude-opus-4-7": true,
"claude-sonnet-5": true,
"claude-sonnet-5": false,
"prefix/claude-sonnet-4-6": true,
"claude-3-5-haiku-latest": true,
"claude-opus-5": false,
@@ -4373,7 +4463,7 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) {
if got := upstreamHeaders.Get("User-Agent"); got != "claude-cli/2.1.220 (external, cli)" {
t.Fatalf("Messages User-Agent = %q, want CLI identity", got)
}
wantBetas := defaultClaudeCodeCLIBetas + ",oauth-2025-04-20"
wantBetas := claudeCodeCLIBetas(payload, nil, true)
if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas {
t.Fatalf("Messages Anthropic-Beta = %q, want %q", got, wantBetas)
}
@@ -4450,7 +4540,7 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing
if got := upstreamHeaders.Get("User-Agent"); got != "claude-cli/2.1.220 (external, cli)" {
t.Fatalf("streaming User-Agent = %q, want CLI identity", got)
}
wantBetas := defaultClaudeCodeCLIBetas + ",oauth-2025-04-20"
wantBetas := claudeCodeCLIBetas(payload, nil, true)
if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas {
t.Fatalf("streaming Anthropic-Beta = %q, want %q", got, wantBetas)
}
@@ -4575,3 +4665,226 @@ func TestInsertClaudeMidConversationSystemMessage_IsIdempotent(t *testing.T) {
t.Fatalf("mid-conversation system insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second)
}
}
// TestClaudeCodeCLIBetas_MatchesObservedClientMatrix pins the Anthropic-Beta
// baseline to the Claude Code 2.1.220 behavior captured on 2026-08-01 against
// api.anthropic.com with an isolated profile.
func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) {
const constants = "claude-code-20250219,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"
tests := []struct {
name string
body string
requested map[string]bool
oauth bool
want string
}{
{
name: "legacy model without tools omits both conditional betas",
body: `{"model":"claude-opus-4-6"}`,
want: constants + ",effort-2025-11-24",
},
{
name: "context 1m sits right after claude-code, not at the end",
body: `{"model":"claude-opus-4-6"}`,
requested: map[string]bool{claudeContext1MBeta: true},
want: "claude-code-20250219,context-1m-2025-08-07," +
"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,effort-2025-11-24",
},
{
name: "opus-5 1m variant reproduces the full observed order",
body: `{"model":"claude-opus-5","tools":[{"name":"Read"}]}`,
requested: map[string]bool{
claudeContext1MBeta: true,
claudeServerSideFallbackBeta: true,
claudeFallbackCreditBeta: true,
},
want: "claude-code-20250219,context-1m-2025-08-07," +
"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,mid-conversation-system-2026-04-07," +
"advanced-tool-use-2025-11-20,effort-2025-11-24," +
"server-side-fallback-2026-06-01,fallback-credit-2026-06-01",
},
{
name: "structured outputs trails effort",
body: `{"model":"claude-opus-4-6"}`,
requested: map[string]bool{claudeStructuredOutputsBeta: true},
want: constants + ",effort-2025-11-24,structured-outputs-2025-12-15",
},
{
name: "unknown caller beta is not smuggled into the baseline",
body: `{"model":"claude-opus-4-6"}`,
requested: map[string]bool{"totally-made-up-2030-01-01": true},
want: constants + ",effort-2025-11-24",
},
{
name: "claude-sonnet-5 accepts role=system",
body: `{"model":"claude-sonnet-5"}`,
want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24",
},
{
name: "claude-opus-4-8 accepts role=system",
body: `{"model":"claude-opus-4-8"}`,
want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24",
},
{
name: "claude-fable-5 accepts role=system",
body: `{"model":"claude-fable-5"}`,
want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24",
},
{
name: "claude-opus-4-7 stays on the reminder path",
body: `{"model":"claude-opus-4-7"}`,
want: constants + ",effort-2025-11-24",
},
{
name: "oauth sits second and extended-cache-ttl last",
body: `{"model":"claude-opus-4-6","tools":[{"name":"Read"}]}`,
oauth: true,
want: "claude-code-20250219,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,advanced-tool-use-2025-11-20," +
"effort-2025-11-24,extended-cache-ttl-2025-04-11",
},
{
name: "oauth precedes context-1m",
body: `{"model":"claude-opus-5","tools":[{"name":"Read"}]}`,
oauth: true,
requested: map[string]bool{
claudeContext1MBeta: true,
claudeServerSideFallbackBeta: true,
claudeFallbackCreditBeta: true,
},
want: "claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07," +
"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,mid-conversation-system-2026-04-07," +
"advanced-tool-use-2025-11-20,effort-2025-11-24," +
"server-side-fallback-2026-06-01,fallback-credit-2026-06-01," +
"extended-cache-ttl-2025-04-11",
},
{
name: "api key path sends neither oauth beta",
body: `{"model":"claude-opus-4-6"}`,
want: constants + ",effort-2025-11-24",
},
{
name: "claude-haiku-4-5-20251001 stays on the reminder path",
body: `{"model":"claude-haiku-4-5-20251001"}`,
want: constants + ",effort-2025-11-24",
},
{
name: "legacy model with tools adds advanced tool use only",
body: `{"model":"claude-sonnet-4-6","tools":[{"name":"Read"}]}`,
want: constants + ",advanced-tool-use-2025-11-20,effort-2025-11-24",
},
{
name: "role=system model without tools adds mid conversation system only",
body: `{"model":"claude-opus-5"}`,
want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24",
},
{
name: "role=system model with tools adds both in wire order",
body: `{"model":"claude-opus-5","tools":[{"name":"Read"}]}`,
want: constants + ",mid-conversation-system-2026-04-07,advanced-tool-use-2025-11-20,effort-2025-11-24",
},
{
name: "empty tools array does not add advanced tool use",
body: `{"model":"claude-opus-4-6","tools":[]}`,
want: constants + ",effort-2025-11-24",
},
{
name: "unknown future model keeps the optimistic role=system default",
body: `{"model":"claude-future-9"}`,
want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := claudeCodeCLIBetas([]byte(tt.body), tt.requested, tt.oauth); got != tt.want {
t.Fatalf("claudeCodeCLIBetas() = %q, want %q", got, tt.want)
}
})
}
}
// TestApplyClaudeHeaders_StreamTransportNegotiation pins the observed 2.1.220
// behaviour: a streaming request to api.anthropic.com negotiates exactly like a
// non-streaming one, because Anthropic selects SSE from the body. Other
// Anthropic-compatible upstreams keep the conservative SSE contract.
func TestApplyClaudeHeaders_StreamTransportNegotiation(t *testing.T) {
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-stream-accept"}}
body := []byte(`{"model":"claude-opus-4-6","stream":true}`)
directReq := newClaudeHeaderTestRequest(t, http.Header{})
if errApply := applyClaudeHeaders(directReq, auth, "key-stream-accept", true, nil, body, nil, http.Header{}, false); errApply != nil {
t.Fatalf("applyClaudeHeaders() error = %v", errApply)
}
if got, want := directReq.Header.Get("Accept"), "application/json"; got != want {
t.Fatalf("streaming Accept = %q, want %q to match the real client", got, want)
}
if got, want := directReq.Header.Get("Accept-Encoding"), "gzip, deflate, br, zstd"; got != want {
t.Fatalf("streaming Accept-Encoding = %q, want %q to match the real client", got, want)
}
gatewayReq := httptest.NewRequest(http.MethodPost, "https://api.kimi.com/coding/v1/messages", nil)
gatewayReq = gatewayReq.WithContext(directReq.Context())
if errApply := applyClaudeHeaders(gatewayReq, auth, "key-stream-accept", true, nil, body, nil, http.Header{}, false); errApply != nil {
t.Fatalf("applyClaudeHeaders() error = %v", errApply)
}
if got, want := gatewayReq.Header.Get("Accept"), "text/event-stream"; got != want {
t.Fatalf("gateway streaming Accept = %q, want %q", got, want)
}
if got, want := gatewayReq.Header.Get("Accept-Encoding"), "identity"; got != want {
t.Fatalf("gateway streaming Accept-Encoding = %q, want %q", got, want)
}
}
func TestApplyClaudeHeaders_CallerBetasScopedByUpstream(t *testing.T) {
incoming := http.Header{"Anthropic-Beta": []string{"caller-only-beta"}}
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-caller-betas"}}
body := []byte(`{"model":"claude-opus-4-6"}`)
// Direct Anthropic must not echo a beta real Claude Code never sends.
directReq := newClaudeHeaderTestRequest(t, incoming)
if errApply := applyClaudeHeaders(directReq, auth, "key-caller-betas", false, nil, body, nil, incoming, false); errApply != nil {
t.Fatalf("applyClaudeHeaders() error = %v", errApply)
}
if got := directReq.Header.Get("Anthropic-Beta"); strings.Contains(got, "caller-only-beta") {
t.Fatalf("Anthropic-Beta = %q, want caller beta dropped on api.anthropic.com", got)
}
if got, want := directReq.Header.Get("Anthropic-Beta"), claudeCodeCLIBetas(body, nil, false); got != want {
t.Fatalf("Anthropic-Beta = %q, want exactly the CLI baseline %q", got, want)
}
// Other Anthropic-compatible upstreams keep caller betas functional.
gatewayReq := httptest.NewRequest(http.MethodPost, "https://api.kimi.com/coding/v1/messages", nil)
gatewayReq = gatewayReq.WithContext(directReq.Context())
if errApply := applyClaudeHeaders(gatewayReq, auth, "key-caller-betas", false, nil, body, nil, incoming, false); errApply != nil {
t.Fatalf("applyClaudeHeaders() error = %v", errApply)
}
if got := gatewayReq.Header.Get("Anthropic-Beta"); !strings.Contains(got, "caller-only-beta") {
t.Fatalf("Anthropic-Beta = %q, want caller beta preserved on non-Anthropic upstream", got)
}
}
// TestInjectClaudeCodeContextManagement pins the captured 2.1.220 object and the
// rule that a caller's own context_management is never overwritten.
func TestInjectClaudeCodeContextManagement(t *testing.T) {
const captured = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}`
got := injectClaudeCodeContextManagement([]byte(`{"model":"claude-opus-4-6"}`))
if diff := gjson.GetBytes(got, "context_management").Raw; diff != captured {
t.Fatalf("context_management = %s, want the captured object %s", diff, captured)
}
callerOwned := []byte(`{"model":"claude-opus-4-6","context_management":{"edits":[]}}`)
if got := injectClaudeCodeContextManagement(callerOwned); !bytes.Equal(got, callerOwned) {
t.Fatalf("caller context_management was modified: %s", got)
}
}

View File

@@ -14,6 +14,7 @@ import (
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
@@ -179,12 +180,11 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases)
}
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel)
if oauthToken {
var errIdentity error
body, _, errIdentity = helps.ApplyClaudeCredentialMetadata(body, auth, claudeSessionID)
if errIdentity != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("apply Claude credential metadata: %w", errIdentity)
}
// Claude Code never sends metadata on count_tokens, and Anthropic rejects the
// field outright there ("metadata: Extra inputs are not permitted"). The
// Messages path still carries the credential identity; this endpoint must not.
if isAnthropicUpstreamBase(baseURL) {
body, _ = sjson.DeleteBytes(body, "metadata")
}
if cchSigning {
fallbackBilling := claudeCCHFallbackBillingHeader(ctx, e.cfg, body, claudeCodeDetection.Entrypoint)
@@ -199,7 +199,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
if err != nil {
return cliproxyexecutor.Response{}, err
}
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil {
if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, false, extraBetas, body, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil {
return cliproxyexecutor.Response{}, errHeaders
}
var authID, authLabel, authType, authValue string