mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
215
internal/runtime/executor/claude_executor_beta_policy_test.go
Normal file
215
internal/runtime/executor/claude_executor_beta_policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user