Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ nonstream-keepalive-interval: 0
# Default headers for Claude API requests. Update when Claude Code releases new versions.
# These are used as fallbacks when the client does not send its own headers.
# claude-header-defaults:
# user-agent: "claude-cli/2.1.44 (external, sdk-cli)"
# version: "2.1.63"
# user-agent: "claude-cli/2.1.63 (external, cli)"
# package-version: "0.74.0"
# runtime-version: "v24.3.0"
# timeout: "600"
Expand Down
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ type Config struct {
// ClaudeHeaderDefaults configures default header values injected into Claude API requests
// when the client does not send them. Update these when Claude Code releases a new version.
type ClaudeHeaderDefaults struct {
Version string `yaml:"version" json:"version"`
UserAgent string `yaml:"user-agent" json:"user-agent"`
PackageVersion string `yaml:"package-version" json:"package-version"`
RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"`
Expand Down
52 changes: 38 additions & 14 deletions internal/runtime/executor/claude_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type ClaudeExecutor struct {
// claudeToolPrefix is empty to match real Claude Code behavior (no tool name prefix).
// Previously "proxy_" was used but this is a detectable fingerprint difference.
const claudeToolPrefix = ""
const defaultClaudeCodeVersion = "2.1.63"

func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor { return &ClaudeExecutor{cfg: cfg} }

Expand Down Expand Up @@ -471,7 +472,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
body, _ = sjson.SetBytes(body, "model", baseModel)

if !strings.HasPrefix(baseModel, "claude-3-5-haiku") {
body = checkSystemInstructions(body)
body = checkSystemInstructionsWithMode(body, false, e.cfg)
}

// Keep count_tokens requests compatible with Anthropic cache-control constraints too.
Expand Down Expand Up @@ -865,7 +866,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01")
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true")
misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli")
// Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).
// Fallback values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0")
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime-Version", hdrDefault(hd.RuntimeVersion, "v24.3.0"))
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Package-Version", hdrDefault(hd.PackageVersion, "0.74.0"))
Expand All @@ -884,7 +885,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
if isClaudeCodeClient(clientUA) {
r.Header.Set("User-Agent", clientUA)
} else {
r.Header.Set("User-Agent", hdrDefault(hd.UserAgent, "claude-cli/2.1.63 (external, cli)"))
r.Header.Set("User-Agent", hdrDefault(hd.UserAgent, defaultClaudeUserAgent(hd)))
}
r.Header.Set("Connection", "keep-alive")
if stream {
Expand Down Expand Up @@ -928,10 +929,6 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
return
}

func checkSystemInstructions(payload []byte) []byte {
return checkSystemInstructionsWithMode(payload, false)
}

func isClaudeOAuthToken(apiKey string) bool {
return strings.Contains(apiKey, "sk-ant-oat")
}
Expand Down Expand Up @@ -1203,10 +1200,33 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {
return payload
}

func claudeCodeVersion(hd config.ClaudeHeaderDefaults) string {
if version := strings.TrimSpace(hd.Version); version != "" {
return version
}
return defaultClaudeCodeVersion
}

func defaultClaudeUserAgent(hd config.ClaudeHeaderDefaults) string {
return fmt.Sprintf("claude-cli/%s (external, cli)", claudeCodeVersion(hd))
}

func buildClaudeTextSystemBlock(text string) string {
block, err := json.Marshal(map[string]string{
"type": "text",
"text": text,
})
if err != nil {
quoted, _ := json.Marshal(text)
return fmt.Sprintf(`{"type":"text","text":%s}`, quoted)
}
return string(block)
}

// generateBillingHeader creates the x-anthropic-billing-header text block that
// real Claude Code prepends to every system prompt array.
// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;
func generateBillingHeader(payload []byte) string {
func generateBillingHeader(payload []byte, hd config.ClaudeHeaderDefaults) string {
// Generate a deterministic cch hash from the payload content (system + messages + tools).
// Real Claude Code uses a 5-char hex hash that varies per request.
h := sha256.Sum256(payload)
Expand All @@ -1217,25 +1237,29 @@ func generateBillingHeader(payload []byte) string {
_, _ = rand.Read(buildBytes)
buildHash := hex.EncodeToString(buildBytes)[:3]

return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=%s;", claudeCodeVersion(hd), buildHash, cch)
}

// checkSystemInstructionsWithMode injects Claude Code-style system blocks:
//
// system[0]: billing header (no cache_control)
// system[1]: agent identifier (no cache_control)
// system[2..]: user system messages (cache_control added when missing)
func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
func checkSystemInstructionsWithMode(payload []byte, strictMode bool, cfg *config.Config) []byte {
system := gjson.GetBytes(payload, "system")

billingText := generateBillingHeader(payload)
billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
var hd config.ClaudeHeaderDefaults
if cfg != nil {
hd = cfg.ClaudeHeaderDefaults
}
billingText := generateBillingHeader(payload, hd)
billingBlock := buildClaudeTextSystemBlock(billingText)
// No cache_control on the agent block. It is a cloaking artifact with zero cache
// value (the last system block is what actually triggers caching of all system content).
// Including any cache_control here creates an intra-system TTL ordering violation
// when the client's system blocks use ttl='1h' (prompt-caching-scope-2026-01-05 beta
// forbids 1h blocks after 5m blocks, and a no-TTL block defaults to 5m).
agentBlock := `{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK."}`
agentBlock := buildClaudeTextSystemBlock("You are a Claude agent, built on Anthropic's Claude Agent SDK.")

if strictMode {
// Strict mode: billing header + agent identifier only
Expand Down Expand Up @@ -1325,7 +1349,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A

// Skip system instructions for claude-3-5-haiku models
if !strings.HasPrefix(model, "claude-3-5-haiku") {
payload = checkSystemInstructionsWithMode(payload, strictMode)
payload = checkSystemInstructionsWithMode(payload, strictMode, cfg)
}

// Inject fake user ID
Expand Down
169 changes: 164 additions & 5 deletions internal/runtime/executor/claude_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,85 @@ func TestClaudeExecutor_CountTokens_AppliesCacheControlGuards(t *testing.T) {
}
}

func TestClaudeExecutor_CountTokens_UsesConfiguredClaudeVersion(t *testing.T) {
var seenBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
seenBody = bytes.Clone(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"input_tokens":42}`))
}))
defer server.Close()

executor := NewClaudeExecutor(&config.Config{
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
Version: "2.2.0",
},
})
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"}]}]}`)

_, err := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{
Model: "claude-3-5-sonnet-20241022",
Payload: payload,
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")})
if err != nil {
t.Fatalf("CountTokens error: %v", err)
}

if len(seenBody) == 0 {
t.Fatal("expected count_tokens request body to be captured")
}
if got := gjson.GetBytes(seenBody, "system.0.text").String(); !strings.Contains(got, "cc_version=2.2.0.") {
t.Fatalf("count_tokens billing header should use configured Claude version, got %q", got)
}
}

func TestClaudeExecutor_CountTokens_EscapesConfiguredClaudeVersionInBillingHeader(t *testing.T) {
var seenBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
seenBody = bytes.Clone(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"input_tokens":42}`))
}))
defer server.Close()

configuredVersion := `2.2.0\"beta\\build`
executor := NewClaudeExecutor(&config.Config{
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
Version: configuredVersion,
},
})
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"}]}]}`)

_, err := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{
Model: "claude-3-5-sonnet-20241022",
Payload: payload,
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")})
if err != nil {
t.Fatalf("CountTokens error: %v", err)
}

if len(seenBody) == 0 {
t.Fatal("expected count_tokens request body to be captured")
}
if !gjson.ValidBytes(seenBody) {
t.Fatalf("count_tokens request body should remain valid JSON: %s", string(seenBody))
}
got := gjson.GetBytes(seenBody, "system.0.text").String()
if !strings.Contains(got, "cc_version="+configuredVersion+".") {
t.Fatalf("count_tokens billing header should preserve configured Claude version, got %q", got)
}
}

func hasTTLOrderingViolation(payload []byte) bool {
seen5m := false
violates := false
Expand Down Expand Up @@ -985,7 +1064,7 @@ func TestClaudeExecutor_ExecuteStream_GzipErrorBodyNoContentEncodingHeader(t *te
func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)

out := checkSystemInstructionsWithMode(payload, false)
out := checkSystemInstructionsWithMode(payload, false, nil)

system := gjson.GetBytes(out, "system")
if !system.IsArray() {
Expand All @@ -1000,6 +1079,9 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
if !strings.HasPrefix(blocks[0].Get("text").String(), "x-anthropic-billing-header:") {
t.Fatalf("blocks[0] should be billing header, got %q", blocks[0].Get("text").String())
}
if !strings.Contains(blocks[0].Get("text").String(), "cc_version=2.1.63.") {
t.Fatalf("blocks[0] should use default Claude version, got %q", blocks[0].Get("text").String())
}
if blocks[1].Get("text").String() != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
t.Fatalf("blocks[1] should be agent block, got %q", blocks[1].Get("text").String())
}
Expand All @@ -1015,7 +1097,7 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) {
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)

out := checkSystemInstructionsWithMode(payload, true)
out := checkSystemInstructionsWithMode(payload, true, nil)

blocks := gjson.GetBytes(out, "system").Array()
if len(blocks) != 2 {
Expand All @@ -1027,7 +1109,7 @@ func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) {
func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) {
payload := []byte(`{"system":"","messages":[{"role":"user","content":"hi"}]}`)

out := checkSystemInstructionsWithMode(payload, false)
out := checkSystemInstructionsWithMode(payload, false, nil)

blocks := gjson.GetBytes(out, "system").Array()
if len(blocks) != 2 {
Expand All @@ -1039,7 +1121,7 @@ func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T)
func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) {
payload := []byte(`{"system":[{"type":"text","text":"Be concise."}],"messages":[{"role":"user","content":"hi"}]}`)

out := checkSystemInstructionsWithMode(payload, false)
out := checkSystemInstructionsWithMode(payload, false, nil)

blocks := gjson.GetBytes(out, "system").Array()
if len(blocks) != 3 {
Expand All @@ -1054,7 +1136,7 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) {
func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) {
payload := []byte(`{"system":"Use <xml> tags & \"quotes\" in output.","messages":[{"role":"user","content":"hi"}]}`)

out := checkSystemInstructionsWithMode(payload, false)
out := checkSystemInstructionsWithMode(payload, false, nil)

blocks := gjson.GetBytes(out, "system").Array()
if len(blocks) != 3 {
Expand All @@ -1064,3 +1146,80 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) {
t.Fatalf("blocks[2] text mangled, got %q", blocks[2].Get("text").String())
}
}

func TestCheckSystemInstructionsWithMode_UsesConfiguredClaudeVersion(t *testing.T) {
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)
cfg := &config.Config{
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
Version: "2.2.0",
},
}

out := checkSystemInstructionsWithMode(payload, false, cfg)

billingHeader := gjson.GetBytes(out, "system.0.text").String()
if !strings.Contains(billingHeader, "cc_version=2.2.0.") {
t.Fatalf("billing header should use configured Claude version, got %q", billingHeader)
}
}

func TestCheckSystemInstructionsWithMode_EscapesConfiguredClaudeVersion(t *testing.T) {
configuredVersion := `2.2.0\"beta\\build`
payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`)
cfg := &config.Config{
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
Version: configuredVersion,
},
}

out := checkSystemInstructionsWithMode(payload, false, cfg)

if !gjson.ValidBytes(out) {
t.Fatalf("payload should remain valid JSON: %s", string(out))
}
system := gjson.GetBytes(out, "system").Array()
if len(system) != 3 {
t.Fatalf("expected 3 system blocks, got %d", len(system))
}
billingHeader := system[0].Get("text").String()
if !strings.Contains(billingHeader, "cc_version="+configuredVersion+".") {
t.Fatalf("billing header should preserve configured Claude version, got %q", billingHeader)
}
if got := system[1].Get("text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
t.Fatalf("system[1] should remain the agent block, got %q", got)
}
if got := system[2].Get("text").String(); got != "You are a helpful assistant." {
t.Fatalf("system[2] should remain the user system prompt, got %q", got)
}
}

func TestApplyClaudeHeaders_UsesConfiguredClaudeVersionForFallbackUserAgent(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "https://example.com/v1/messages", nil)
cfg := &config.Config{
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
Version: "2.2.0",
},
}

applyClaudeHeaders(req, nil, "key-123", false, nil, cfg)

if got := req.Header.Get("User-Agent"); got != "claude-cli/2.2.0 (external, cli)" {
t.Fatalf("User-Agent = %q, want %q", got, "claude-cli/2.2.0 (external, cli)")
}
}

func TestApplyClaudeHeaders_UsesConfiguredUserAgent(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "https://example.com/v1/messages", nil)
cfg := &config.Config{
ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
Version: "2.3.4",
UserAgent: "claude-cli/custom-build (external, cli)",
},
}

applyClaudeHeaders(req, nil, "key-123", false, nil, cfg)

if got := req.Header.Get("User-Agent"); got != "claude-cli/custom-build (external, cli)" {
t.Fatalf("User-Agent = %q, want %q", got, "claude-cli/custom-build (external, cli)")
}
}
Loading