Skip to content

Commit 69b950d

Browse files
committed
fix(executor): fix OAuth extra usage detection by Anthropic API
Three changes to avoid Anthropic's content-based system prompt validation: 1. Fix identity prefix: Use 'You are Claude Code, Anthropic's official CLI for Claude.' instead of the SDK agent prefix, matching real Claude Code. 2. Move user system instructions to user message: Only keep billing header + identity prefix in system[] array. User system instructions are prepended to the first user message as <system-reminder> blocks. 3. Enable cch signing for OAuth tokens by default: The xxHash64 cch integrity check was previously gated behind experimentalCCHSigning config flag. Now automatically enabled when using OAuth tokens. Related: router-for-me/CLIProxyAPI#2599
1 parent 343a2fc commit 69b950d

1 file changed

Lines changed: 82 additions & 31 deletions

File tree

internal/runtime/executor/claude_executor.go

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,13 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
157157
extraBetas, body = extractAndRemoveBetas(body)
158158
bodyForTranslation := body
159159
bodyForUpstream := body
160-
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
160+
oauthToken := isClaudeOAuthToken(apiKey)
161+
if oauthToken && !auth.ToolPrefixDisabled() {
161162
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
162163
}
163-
if experimentalCCHSigningEnabled(e.cfg, auth) {
164+
// Enable cch signing by default for OAuth tokens (not just experimental flag).
165+
// Claude Code always computes cch; missing or invalid cch is a detectable fingerprint.
166+
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
164167
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
165168
}
166169

@@ -325,10 +328,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
325328
extraBetas, body = extractAndRemoveBetas(body)
326329
bodyForTranslation := body
327330
bodyForUpstream := body
328-
if isClaudeOAuthToken(apiKey) && !auth.ToolPrefixDisabled() {
331+
oauthToken := isClaudeOAuthToken(apiKey)
332+
if oauthToken && !auth.ToolPrefixDisabled() {
329333
bodyForUpstream = applyClaudeToolPrefix(body, claudeToolPrefix)
330334
}
331-
if experimentalCCHSigningEnabled(e.cfg, auth) {
335+
// Enable cch signing by default for OAuth tokens (not just experimental flag).
336+
if oauthToken || experimentalCCHSigningEnabled(e.cfg, auth) {
332337
bodyForUpstream = signAnthropicMessagesBody(bodyForUpstream)
333338
}
334339

@@ -1291,55 +1296,101 @@ func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, exp
12911296
// Including any cache_control here creates an intra-system TTL ordering violation
12921297
// when the client's system blocks use ttl='1h' (prompt-caching-scope-2026-01-05 beta
12931298
// forbids 1h blocks after 5m blocks, and a no-TTL block defaults to 5m).
1294-
agentBlock := `{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK."}`
1295-
1296-
if strictMode {
1297-
// Strict mode: billing header + agent identifier only
1298-
result := "[" + billingBlock + "," + agentBlock + "]"
1299-
payload, _ = sjson.SetRawBytes(payload, "system", []byte(result))
1300-
return payload
1301-
}
1299+
// Use Claude Code identity prefix for interactive CLI mode.
1300+
// Real Claude Code uses "You are Claude Code, Anthropic's official CLI for Claude."
1301+
// when running in interactive mode (the most common case).
1302+
agentBlock := `{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}`
13021303

1303-
// Non-strict mode: billing header + agent identifier + user system messages
13041304
// Skip if already injected
13051305
firstText := gjson.GetBytes(payload, "system.0.text").String()
13061306
if strings.HasPrefix(firstText, "x-anthropic-billing-header:") {
13071307
return payload
13081308
}
13091309

1310-
result := "[" + billingBlock + "," + agentBlock
1310+
// system[] only keeps billing header + agent identifier.
1311+
// User system instructions are moved to the first user message to avoid
1312+
// Anthropic's content-based system prompt validation (extra usage detection).
1313+
systemResult := "[" + billingBlock + "," + agentBlock + "]"
1314+
payload, _ = sjson.SetRawBytes(payload, "system", []byte(systemResult))
1315+
1316+
// Collect user system instructions and prepend to first user message
1317+
var userSystemParts []string
13111318
if system.IsArray() {
13121319
system.ForEach(func(_, part gjson.Result) bool {
13131320
if part.Get("type").String() == "text" {
1314-
// Add cache_control to user system messages if not present.
1315-
// Do NOT add ttl — let it inherit the default (5m) to avoid
1316-
// TTL ordering violations with the prompt-caching-scope-2026-01-05 beta.
1317-
partJSON := part.Raw
1318-
if !part.Get("cache_control").Exists() {
1319-
updated, _ := sjson.SetBytes([]byte(partJSON), "cache_control.type", "ephemeral")
1320-
partJSON = string(updated)
1321+
txt := strings.TrimSpace(part.Get("text").String())
1322+
if txt != "" {
1323+
userSystemParts = append(userSystemParts, txt)
13211324
}
1322-
result += "," + partJSON
13231325
}
13241326
return true
13251327
})
1326-
} else if system.Type == gjson.String && system.String() != "" {
1327-
partJSON := `{"type":"text","cache_control":{"type":"ephemeral"}}`
1328-
updated, _ := sjson.SetBytes([]byte(partJSON), "text", system.String())
1329-
partJSON = string(updated)
1330-
result += "," + partJSON
1328+
} else if system.Type == gjson.String && strings.TrimSpace(system.String()) != "" {
1329+
userSystemParts = append(userSystemParts, strings.TrimSpace(system.String()))
1330+
}
1331+
1332+
if !strictMode && len(userSystemParts) > 0 {
1333+
combined := strings.Join(userSystemParts, "\n\n")
1334+
payload = prependToFirstUserMessage(payload, combined)
1335+
}
1336+
1337+
return payload
1338+
}
1339+
1340+
// prependToFirstUserMessage prepends text content to the first user message.
1341+
// This avoids putting non-Claude-Code system instructions in system[] which
1342+
// triggers Anthropic's extra usage billing for OAuth-proxied requests.
1343+
func prependToFirstUserMessage(payload []byte, text string) []byte {
1344+
messages := gjson.GetBytes(payload, "messages")
1345+
if !messages.Exists() || !messages.IsArray() {
1346+
return payload
1347+
}
1348+
1349+
// Find the first user message index
1350+
firstUserIdx := -1
1351+
messages.ForEach(func(idx, msg gjson.Result) bool {
1352+
if msg.Get("role").String() == "user" {
1353+
firstUserIdx = int(idx.Int())
1354+
return false
1355+
}
1356+
return true
1357+
})
1358+
1359+
if firstUserIdx < 0 {
1360+
return payload
1361+
}
1362+
1363+
prefixBlock := fmt.Sprintf(`<system-reminder>
1364+
As you answer the user's questions, you can use the following context from the system:
1365+
%s
1366+
1367+
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.
1368+
</system-reminder>
1369+
`, text)
1370+
1371+
contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx)
1372+
content := gjson.GetBytes(payload, contentPath)
1373+
1374+
if content.IsArray() {
1375+
newBlock := fmt.Sprintf(`{"type":"text","text":%q}`, prefixBlock)
1376+
existing := content.Raw
1377+
newArray := "[" + newBlock + "," + existing[1:]
1378+
payload, _ = sjson.SetRawBytes(payload, contentPath, []byte(newArray))
1379+
} else if content.Type == gjson.String {
1380+
newText := prefixBlock + content.String()
1381+
payload, _ = sjson.SetBytes(payload, contentPath, newText)
13311382
}
1332-
result += "]"
13331383

1334-
payload, _ = sjson.SetRawBytes(payload, "system", []byte(result))
13351384
return payload
13361385
}
13371386

13381387
// applyCloaking applies cloaking transformations to the payload based on config and client.
13391388
// Cloaking includes: system prompt injection, fake user ID, and sensitive word obfuscation.
13401389
func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, payload []byte, model string, apiKey string) []byte {
13411390
clientUserAgent := getClientUserAgent(ctx)
1342-
useExperimentalCCHSigning := experimentalCCHSigningEnabled(cfg, auth)
1391+
// Enable cch signing for OAuth tokens by default (not just experimental flag).
1392+
oauthToken := isClaudeOAuthToken(apiKey)
1393+
useCCHSigning := oauthToken || experimentalCCHSigningEnabled(cfg, auth)
13431394

13441395
// Get cloak config from ClaudeKey configuration
13451396
cloakCfg := resolveClaudeKeyCloakConfig(cfg, auth)
@@ -1376,7 +1427,7 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A
13761427
billingVersion := helps.DefaultClaudeVersion(cfg)
13771428
entrypoint := parseEntrypointFromUA(clientUserAgent)
13781429
workload := getWorkloadFromContext(ctx)
1379-
payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning, billingVersion, entrypoint, workload)
1430+
payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useCCHSigning, billingVersion, entrypoint, workload)
13801431
}
13811432

13821433
// Inject fake user ID

0 commit comments

Comments
 (0)