diff --git a/config.example.yaml b/config.example.yaml index bd0b5460..65fca364 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -510,7 +510,7 @@ nonstream-keepalive-interval: 0 # - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) # - "*-thinking" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) # - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) -# rebuild-mid-system-message: false # optional: default is false; when true, keep caller instructions in the top-level Claude system field, including after cloaking +# rebuild-mid-system-message: false # optional: default is false; when true, move caller-supplied messages with role "system" into the top-level Claude system field # cloak: # optional: explicitly enable request cloaking for non-Claude-Code clients # mode: "auto" # "auto" (default inside this block): cloak only when client is not Claude Code # # "always": cloak every unconfirmed client; confirmed native Claude Code still passes through @@ -520,8 +520,8 @@ nonstream-keepalive-interval: 0 # # "cloak_strict_mode" / "cloak_sensitive_words" / "cloak_cache_user_id". OAuth token # # JSON also accepts "rebuild_mid_system_message": "true" for the option above. The top-level # # "disable-claude-cloak-mode: true" disables cloaking for all Claude credentials at once. -# strict-mode: false # false (default): legacy-model whitelist uses a user system-reminder; -# # all other and future models use messages[].role=system +# strict-mode: false # false (default): preserve caller instructions in top-level system; +# # cloaking never inserts them into conversation messages # # true: strip caller prompts and keep only Claude Code billing and identity blocks # sensitive-words: # optional: words to obfuscate with zero-width characters # - "API" diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 37084582..ee7da9ff 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -333,7 +333,7 @@ type CloakConfig struct { Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // StrictMode controls how caller system prompts are handled when cloaking. - // - false (default): legacy-model whitelist uses a user reminder; all other models use a mid-conversation system message + // - false (default): preserve caller instructions in top-level system without inserting conversation turns // - true: strip caller system prompts and keep only the Claude Code billing and identity blocks StrictMode bool `yaml:"strict-mode,omitempty" json:"strict-mode,omitempty"` diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index 761dbc0b..56367dee 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -250,11 +250,8 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { return checkSystemInstructionsWithSigningMode(payload, strictMode, false, "2.1.258", "cli", "") } -// checkSystemInstructionsWithSigningMode keeps the top-level system in Claude -// Code's minimal CLI shape. Each caller system block is preserved as a separate -// mid-conversation system message after the first user turn, where supported -// Claude models give it operator-level authority without changing the cached -// top-level prefix. +// checkSystemInstructionsWithSigningMode adds CLI attribution while keeping +// caller instructions top-level. Cloaking must not manufacture conversation turns. func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cchSigning bool, version, entrypoint, workload string) []byte { return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, time.Now(), false, "", "") } @@ -302,70 +299,34 @@ func checkSystemInstructionsWithSigningModeAt( } forwardedSystemBlocks := collectForwardedClaudeSystemPromptBlocks(system) - if len(forwardedSystemBlocks) == 0 { - return injectClaudeCodeCurrentDate(payload, now) - } - if claudeHistoryHasAdvisorCallOrResult(payload) { - for _, block := range forwardedSystemBlocks { - systemBlocks = append(systemBlocks, buildTextBlock(block, nil)) + if len(forwardedSystemBlocks) > 0 { + last := len(forwardedSystemBlocks) - 1 + if !gjson.Get(forwardedSystemBlocks[last], "cache_control").Exists() { + forwardedSystemBlocks[last] = withEphemeralCacheControl(forwardedSystemBlocks[last]) } + systemBlocks = append(systemBlocks, forwardedSystemBlocks...) payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]")) - return injectClaudeCodeCurrentDate(payload, now) - } - if claudeUsesLegacySystemReminder(payload) { - payload = prependClaudeSystemRemindersToFirstUserMessage(payload, forwardedSystemBlocks) - } else { - // Unknown and future model IDs optimistically use the authoritative - // mid-conversation system role. Only empirically unsupported legacy IDs - // stay on the user-reminder compatibility path. - payload = insertClaudeMidConversationSystemMessages(payload, forwardedSystemBlocks) } return injectClaudeCodeCurrentDate(payload, now) } -// relocateClaudeSystemPromptForCountTokens keeps a cloaked count_tokens request -// in Claude Code's measured shape, which carries only model, messages and tools. -// The Claude Code system blocks are therefore not installed here, but each caller -// system block still has to be accounted for, so it is relocated into messages -// using the same positional mapping as the Messages path. That keeps the counted -// tokens aligned with the request the caller is about to send while preventing a -// third-party system prompt from reaching Anthropic in the system slot. -func relocateClaudeSystemPromptForCountTokens(payload []byte, strictMode bool) []byte { +// prepareClaudeSystemPromptForCountTokens preserves caller instructions in the +// same top-level slot as Messages without adding CLI identity or conversation turns. +func prepareClaudeSystemPromptForCountTokens(payload []byte, strictMode bool) []byte { system := gjson.GetBytes(payload, "system") if !system.Exists() { return payload } - // Strict mode drops caller prompts on the Messages path, so it must not - // reintroduce them here either. - var forwardedSystemBlocks []string + var blocks []string if !strictMode { - forwardedSystemBlocks = collectForwardedClaudeSystemPromptBlocks(system) + blocks = collectForwardedClaudeSystemPromptBlocks(system) } - if len(forwardedSystemBlocks) == 0 { + if len(blocks) == 0 { updated, _ := sjson.DeleteBytes(payload, "system") return updated } - if claudeHistoryHasAdvisorCallOrResult(payload) { - // When an advisor call or result is present, the Messages path preserves - // caller system blocks in top-level system to prevent shifting message - // positions. Keep them in top-level system here as well so token counting - // remains aligned with the Messages path without splicing messages[]. - blocks := make([]string, 0, len(forwardedSystemBlocks)) - for _, block := range forwardedSystemBlocks { - blocks = append(blocks, buildTextBlock(block, nil)) - } - updated, _ := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(blocks, ",")+"]")) - return updated - } - updated, errDelete := sjson.DeleteBytes(payload, "system") - if errDelete != nil { - return payload - } - payload = updated - if claudeUsesLegacySystemReminder(payload) { - return prependClaudeSystemRemindersToFirstUserMessage(payload, forwardedSystemBlocks) - } - return insertClaudeMidConversationSystemMessages(payload, forwardedSystemBlocks) + updated, _ := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(blocks, ",")+"]")) + return updated } // claudeLegacySystemReminderModels lists the official Anthropic model IDs and @@ -439,11 +400,8 @@ func (claudeMidSystemMessageModelError) IsRequestScoped() bool { return true } -// The turn is not always the caller's. CPA normally reconciles a cloaked turn -// when a payload rule changes the model to legacy, but it deliberately gives up -// if the rule also rewrites the tracked messages and their provenance is no -// longer exact. The wording therefore states the model's requirement instead of -// assuming the caller created the turn. +// Both caller input and payload overrides may introduce a system turn, so the +// error describes the final model's requirement without assuming its origin. func newClaudeMidSystemMessageModelError(model string) error { if model == "" { model = "unknown" @@ -504,9 +462,9 @@ func validateClaudeMidSystemMessageModel(payload []byte, confirmedClaudeCode, fi // top-level system field answers "system..type: Input should be 'text'" for // image, document and unknown block types, and a role=system message answers // "role 'system' supports text, tool_addition, and tool_removal blocks only". -// Cloaking relocates caller blocks into one of those two slots, so a non-text -// block has no destination. Failing here keeps the caller's instructions from -// being silently dropped, and costs no upstream attempt. +// Cloaking keeps caller blocks top-level, so a non-text block has no destination. +// Failing here keeps the caller's instructions from being silently dropped, and +// costs no upstream attempt. func validateClaudeCallerSystemBlocks(system gjson.Result) error { if !system.IsArray() { // A string system prompt is text by definition. @@ -527,22 +485,21 @@ func validateClaudeCallerSystemBlocks(system gjson.Result) error { func collectForwardedClaudeSystemPromptBlocks(system gjson.Result) []string { var blocks []string - appendText := func(text string) { + appendBlock := func(text, raw string) { if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) || text == claudeCodeCLIIdentity { return } - blocks = append(blocks, text) + blocks = append(blocks, raw) } - if system.IsArray() { system.ForEach(func(_, part gjson.Result) bool { if part.Get("type").String() == "text" { - appendText(part.Get("text").String()) + appendBlock(part.Get("text").String(), part.Raw) } return true }) } else if system.Type == gjson.String { - appendText(system.String()) + appendBlock(system.String(), buildTextBlock(system.String(), nil)) } return blocks } @@ -570,278 +527,6 @@ func marshalJSONStringWithoutHTMLEscape(value string) string { return strings.TrimSuffix(encoded.String(), "\n") } -func prependClaudeSystemRemindersToFirstUserMessage(payload []byte, texts []string) []byte { - firstUserIdx := firstClaudeUserMessageIndex(payload) - if firstUserIdx < 0 || len(texts) == 0 { - return payload - } - - reminderTexts := make([]string, 0, len(texts)) - for _, text := range texts { - reminderTexts = append(reminderTexts, claudeCallerSystemReminder(text)) - } - - contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx) - content := gjson.GetBytes(payload, contentPath) - if content.IsArray() { - blocks := content.Array() - existing := make(map[string]int, len(blocks)) - for _, block := range blocks { - if block.Get("type").String() == "text" { - existing[block.Get("text").String()]++ - } - } - - reminderBlocks := make([]string, 0, len(reminderTexts)) - for _, reminderText := range reminderTexts { - if existing[reminderText] > 0 { - existing[reminderText]-- - continue - } - reminderBlocks = append(reminderBlocks, buildTextBlock(reminderText, nil)) - } - if len(reminderBlocks) == 0 { - return payload - } - - insertAt := 0 - for insertAt < len(blocks) && blocks[insertAt].Get("type").String() == "tool_result" { - insertAt++ - } - rawBlocks := make([]string, 0, len(blocks)+len(reminderBlocks)) - for idx, block := range blocks { - if idx == insertAt { - rawBlocks = append(rawBlocks, reminderBlocks...) - } - rawBlocks = append(rawBlocks, block.Raw) - } - if insertAt == len(blocks) { - rawBlocks = append(rawBlocks, reminderBlocks...) - } - payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) - } else if content.Type == gjson.String { - rawBlocks := make([]string, 0, len(reminderTexts)+1) - for _, reminderText := range reminderTexts { - rawBlocks = append(rawBlocks, buildTextBlock(reminderText, nil)) - } - rawBlocks = append(rawBlocks, buildTextBlock(content.String(), nil)) - payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) - } - return payload -} - -func claudeCallerSystemReminder(text string) string { - var reminder strings.Builder - reminder.WriteString("\n") - reminder.WriteString(text) - if !strings.HasSuffix(text, "\n") { - reminder.WriteByte('\n') - } - reminder.WriteString("") - return reminder.String() -} - -// claudeHistoryHasAdvisorCallOrResult reports whether messages contains an advisor -// tool invocation (server_tool_use / tool_use) or advisor result (advisor_tool_result / -// advisor_redacted_result). Anthropic cryptographically binds the encrypted -// advisor result to the conversation layout; any mid-conversation system splice -// shifts message indices and causes upstream 400 errors. -func claudeHistoryHasAdvisorCallOrResult(payload []byte) bool { - messages := gjson.GetBytes(payload, "messages") - if !messages.IsArray() { - return false - } - for _, msg := range messages.Array() { - content := msg.Get("content") - if content.IsArray() { - for _, block := range content.Array() { - blockType := block.Get("type").String() - switch blockType { - case "advisor_tool_result", "advisor_redacted_result": - return true - case "server_tool_use", "tool_use": - if block.Get("name").String() == "advisor" { - return true - } - case "tool_result": - inner := block.Get("content") - if inner.IsArray() { - for _, b := range inner.Array() { - if b.Get("type").String() == "advisor_redacted_result" { - return true - } - } - } else if inner.IsObject() { - if inner.Get("type").String() == "advisor_redacted_result" { - return true - } - } - } - } - } else if content.IsObject() { - blockType := content.Get("type").String() - if blockType == "advisor_tool_result" || blockType == "advisor_redacted_result" { - return true - } - } - } - return false -} - -func insertClaudeMidConversationSystemMessages(payload []byte, texts []string) []byte { - firstUserIdx := firstClaudeUserMessageIndex(payload) - if firstUserIdx < 0 || len(texts) == 0 { - return payload - } - - messages := gjson.GetBytes(payload, "messages") - if !messages.IsArray() { - return payload - } - messageBlocks := messages.Array() - insertAt := firstUserIdx + 1 - for insertAt < len(messageBlocks) && messageBlocks[insertAt].Get("role").String() == "user" { - insertAt++ - } - if len(messageBlocks)-insertAt >= len(texts) { - matches := true - for idx, text := range texts { - message := messageBlocks[insertAt+idx] - if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != text { - matches = false - break - } - } - if matches { - return payload - } - } - - systemMessages := make([]string, 0, len(texts)) - for _, text := range texts { - content := "[" + buildTextBlock(text, &claudeCodeCacheControl) + "]" - systemMessages = append(systemMessages, `{"role":"system","content":`+content+"}") - } - rawMessages := make([]string, 0, len(messageBlocks)+len(systemMessages)) - for idx, message := range messageBlocks { - if idx == insertAt { - rawMessages = append(rawMessages, systemMessages...) - } - rawMessages = append(rawMessages, message.Raw) - } - if insertAt == len(messageBlocks) { - rawMessages = append(rawMessages, systemMessages...) - } - payload, _ = sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(rawMessages, ",")+"]")) - return payload -} - -func claudeMessageContentText(content gjson.Result) string { - if content.Type == gjson.String { - return content.String() - } - if !content.IsArray() { - return "" - } - var parts []string - content.ForEach(func(_, block gjson.Result) bool { - if block.Get("type").String() == "text" { - parts = append(parts, block.Get("text").String()) - } - return true - }) - return strings.Join(parts, "\n\n") -} - -// claudeCodeSystemPlacementState identifies only the role=system turns that CPA -// itself inserted while cloaking. Caller-owned turns are deliberately excluded: -// if one is paired with a legacy model, validateClaudeMidSystemMessageModel must -// still return 400 instead of silently rewriting the caller's wire. -type claudeCodeSystemPlacementState struct { - insertAt int - insertedRaw []string - texts []string -} - -// captureClaudeCodeSystemPlacement records CPA's modern-model system placement -// immediately after cloaking. The message-count increase is part of the proof: -// insertClaudeMidConversationSystemMessages returns without inserting when the -// same turns already exist, and those pre-existing turns belong to the caller. -func captureClaudeCodeSystemPlacement(before, after []byte, cloaked bool) claudeCodeSystemPlacementState { - if !cloaked || claudeUsesLegacySystemReminder(before) { - return claudeCodeSystemPlacementState{} - } - texts := collectForwardedClaudeSystemPromptBlocks(gjson.GetBytes(before, "system")) - if len(texts) == 0 { - return claudeCodeSystemPlacementState{} - } - - beforeMessages := gjson.GetBytes(before, "messages").Array() - afterMessages := gjson.GetBytes(after, "messages").Array() - if len(afterMessages) != len(beforeMessages)+len(texts) { - return claudeCodeSystemPlacementState{} - } - firstUserIdx := firstClaudeUserMessageIndex(before) - if firstUserIdx < 0 { - return claudeCodeSystemPlacementState{} - } - insertAt := firstUserIdx + 1 - for insertAt < len(beforeMessages) && beforeMessages[insertAt].Get("role").String() == "user" { - insertAt++ - } - if insertAt+len(texts) > len(afterMessages) { - return claudeCodeSystemPlacementState{} - } - - insertedRaw := make([]string, len(texts)) - for idx, text := range texts { - message := afterMessages[insertAt+idx] - if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != text { - return claudeCodeSystemPlacementState{} - } - insertedRaw[idx] = message.Raw - } - return claudeCodeSystemPlacementState{ - insertAt: insertAt, - insertedRaw: insertedRaw, - texts: append([]string(nil), texts...), - } -} - -// reconcileClaudeCodeSystemPlacementAfterPayload repairs an otherwise stale -// placement decision when payload rules change the final model from modern to -// legacy. It removes only the exact contiguous turns captured above and replays -// their text through the existing legacy path. If any payload -// rule also changed those messages, reconciliation fails closed and leaves the -// final validation guard to return 400. -func reconcileClaudeCodeSystemPlacementAfterPayload(payload []byte, state claudeCodeSystemPlacementState) []byte { - if len(state.insertedRaw) == 0 || !claudeUsesLegacySystemReminder(payload) { - return payload - } - messages := gjson.GetBytes(payload, "messages").Array() - if state.insertAt < 0 || state.insertAt+len(state.insertedRaw) > len(messages) { - return payload - } - for idx, raw := range state.insertedRaw { - if messages[state.insertAt+idx].Raw != raw { - return payload - } - } - - rawMessages := make([]string, 0, len(messages)-len(state.insertedRaw)) - for idx, message := range messages { - if idx >= state.insertAt && idx < state.insertAt+len(state.insertedRaw) { - continue - } - rawMessages = append(rawMessages, message.Raw) - } - updated, errSet := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(rawMessages, ",")+"]")) - if errSet != nil { - return payload - } - return prependClaudeSystemRemindersToFirstUserMessage(updated, state.texts) -} - type claudeCodeFableState struct { injectedFallbacks bool injectedDisplay bool diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index a91b5ed0..9be09721 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -102,13 +102,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { return resp, err } - // Cloaking may introduce role=system turns after the caller-requested rebuild - // ran. Apply the same policy to the finished cloaked layout so the option - // actually keeps stable instructions in the top-level system field. - if rebuildMidSystemMessageEnabled(e.cfg, auth) { - body = rebuildMidSystemMessagesToTopLevel(body) - } - systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) fableState := captureClaudeCodeFableState(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. @@ -160,7 +153,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r "diagnostics", ) contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] - body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) wasProbeOrHelper := isProbeOrHelper isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) if isProbeOrHelper { diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 02e5d0d6..a3ccc6c8 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -105,13 +105,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { return nil, err } - // Cloaking may introduce role=system turns after the caller-requested rebuild - // ran. Apply the same policy to the finished cloaked layout so the option - // actually keeps stable instructions in the top-level system field. - if rebuildMidSystemMessageEnabled(e.cfg, auth) { - body = rebuildMidSystemMessagesToTopLevel(body) - } - systemPlacementState := captureClaudeCodeSystemPlacement(bodyBeforeCloaking, body, cloaked) fableState := captureClaudeCodeFableState(bodyBeforeCloaking, body, cloaked) // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. @@ -163,7 +156,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A "diagnostics", ) contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] - body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) wasProbeOrHelper := isProbeOrHelper isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) if isProbeOrHelper { diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 9edfb7b0..7e2507e0 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1101,8 +1101,8 @@ func TestClaudeExecutor_CopiedVSCodeAgentSDKHeadersWithoutMetadataAreCloaked(t * if got := seenHeaders.Get("User-Agent"); got != "claude-cli/2.1.258 (external, cli)" { t.Fatalf("User-Agent = %q, want CLI cloak", got) } - if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 2 { - t.Fatalf("system block count = %d, want billing and CLI identity only", got) + if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 3 { + t.Fatalf("system block count = %d, want billing, CLI identity and caller", got) } content := gjson.GetBytes(seenBody, "messages.0.content").Array() if len(content) != 2 { @@ -1110,7 +1110,7 @@ func TestClaudeExecutor_CopiedVSCodeAgentSDKHeadersWithoutMetadataAreCloaked(t * } assertClaudeCodeCurrentDateBlock(t, content[0]) assertEphemeralUserTextBlock(t, content[1], "x", "") - assertClaudeMidConversationSystemMessage(t, seenBody, 1, "spoofed-system", "") + assertClaudeTopLevelCallerSystem(t, seenBody, 1, "spoofed-system", "") } func TestClaudeExecutor_AgentSDKEntrypointWithStrongSignalsUsesCLICloak(t *testing.T) { @@ -2166,7 +2166,7 @@ func TestShouldUseClaudeUpstreamTokenCount(t *testing.T) { } } -func TestClaudeExecutor_LegacySystemReminderAcrossMessagesAndStream(t *testing.T) { +func TestClaudeExecutor_LegacySystemPreservedAcrossMessagesAndStream(t *testing.T) { var mu sync.Mutex captured := make(map[string][]byte) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -2253,7 +2253,7 @@ func TestClaudeExecutor_LegacySystemReminderAcrossMessagesAndStream(t *testing.T if len(body) == 0 { t.Fatalf("missing %s upstream capture", kind) } - assertClaudeLegacySystemReminderLayout(t, body, "legacy-system-prompt", wantUser, "1h") + assertClaudePreservedSystemLayout(t, body, "legacy-system-prompt", wantUser, "1h") if _, ok := claudeBillingCCHDigitsOffset(body); !ok { t.Fatalf("%s body is missing final CCH", kind) } @@ -2377,21 +2377,17 @@ func TestClaudeExecutor_CountTokensCloakMatchesMeasuredDirectAnthropicShape(t *t } } -// TestClaudeExecutor_CountTokensCloakRelocatesCallerSystemAndObfuscates asserts -// that a cloaked direct-Anthropic count_tokens request keeps Claude Code's -// measured shape (no system field) while still accounting for the caller's -// system prompt and honouring sensitive-word obfuscation. -func TestClaudeExecutor_CountTokensCloakRelocatesCallerSystemAndObfuscates(t *testing.T) { +// Count-token cloaking preserves the caller system slot and sensitive-word policy. +func TestClaudeExecutor_CountTokensCloakPreservesCallerSystemAndObfuscates(t *testing.T) { const callerSystem = "third party ACMECORP orchestrator rules" const sensitiveWord = "ACMECORP" testCases := []struct { - name string - model string - wantSystemMsg bool + name string + model string }{ - {name: "mid conversation system role", model: "claude-opus-5", wantSystemMsg: true}, - {name: "legacy system reminder", model: "claude-sonnet-4-5", wantSystemMsg: false}, + {name: "opus 5", model: "claude-opus-5"}, + {name: "legacy sonnet", model: "claude-sonnet-4-5"}, } for _, testCase := range testCases { @@ -2425,34 +2421,12 @@ func TestClaudeExecutor_CountTokensCloakRelocatesCallerSystemAndObfuscates(t *te t.Fatalf("countTokensUpstream() error = %v", errCount) } - // Claude Code's count_tokens never carries a system field. - if got := gjson.GetBytes(upstreamBody, "system"); got.Exists() { - t.Fatalf("cloaked count system = %s, want absent", got.Raw) - } - // The caller's system prompt must still be counted, relocated into messages. - // Compare decoded text so JSON escaping does not affect the assertions. - var decodedTexts []string - sawSystemRole := false - gjson.GetBytes(upstreamBody, "messages").ForEach(func(_, message gjson.Result) bool { - if message.Get("role").String() == "system" { - sawSystemRole = true - } - message.Get("content").ForEach(func(_, block gjson.Result) bool { - decodedTexts = append(decodedTexts, block.Get("text").String()) - return true - }) - return true - }) - joinedTexts := strings.Join(decodedTexts, "\n") + joinedTexts := gjson.GetBytes(upstreamBody, "system.0.text").String() if !strings.Contains(joinedTexts, "orchestrator rules") { - t.Fatalf("caller system prompt was dropped from the counted body: %s", upstreamBody) + t.Fatalf("caller system prompt missing from top-level system: %s", upstreamBody) } - if testCase.wantSystemMsg { - if !sawSystemRole { - t.Fatalf("expected a mid-conversation system message, got %s", upstreamBody) - } - } else if !strings.Contains(joinedTexts, "") { - t.Fatalf("expected a legacy system reminder, got %s", upstreamBody) + if got := gjson.GetBytes(upstreamBody, "messages").Raw; got != gjson.GetBytes(payload, "messages").Raw { + t.Fatalf("count_tokens modified conversation messages: %s", upstreamBody) } // Sensitive words must not reach Anthropic verbatim on this endpoint either. if strings.Contains(joinedTexts, sensitiveWord) { @@ -3739,50 +3713,39 @@ func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity } } -// assertClaudeMidConversationSystemMessage checks a forwarded caller system prompt. +// assertClaudeTopLevelCallerSystem checks a forwarded caller system prompt. // wantTTL is "" for the native default marker and "1h" once // upgradeClaudeCacheControlTTL has run, which only happens for OAuth credentials. -func assertClaudeMidConversationSystemMessage(t *testing.T, body []byte, messageIndex int, wantText, wantTTL string) { +func assertClaudeTopLevelCallerSystem(t *testing.T, body []byte, callerIndex int, wantText, wantTTL string) { t.Helper() - messagePath := fmt.Sprintf("messages.%d", messageIndex) - if got := gjson.GetBytes(body, messagePath+".role").String(); got != "system" { - t.Fatalf("%s.role = %q, want system", messagePath, got) - } - content := gjson.GetBytes(body, messagePath+".content").Array() - if len(content) != 1 { - t.Fatalf("%s.content has %d blocks, want 1", messagePath, len(content)) - } - if got := content[0].Get("text").String(); got != wantText { - t.Fatalf("%s.content.0.text lost caller prompt: got len %d, want len %d", messagePath, len(got), len(wantText)) + path := fmt.Sprintf("system.%d", callerIndex+1) + block := gjson.GetBytes(body, path) + if got := block.Get("text").String(); got != wantText { + t.Fatalf("%s.text lost caller prompt: got len %d, want len %d", path, len(got), len(wantText)) } - if got := content[0].Get("cache_control.type").String(); got != "ephemeral" { - t.Fatalf("%s.content.0.cache_control.type = %q, want ephemeral", messagePath, got) + if wantTTL != "" && block.Get("cache_control.ttl").String() != wantTTL { + t.Fatalf("%s.cache_control.ttl = %q, want %q", path, block.Get("cache_control.ttl").String(), wantTTL) } - if got := content[0].Get("cache_control.ttl").String(); got != wantTTL { - t.Fatalf("%s.content.0.cache_control.ttl = %q, want %q: %s", messagePath, got, wantTTL, content[0].Raw) + if gjson.GetBytes(body, `messages.#(role=="system")`).Exists() { + t.Fatal("cloaking inserted a system turn into messages") } } -func assertClaudeLegacySystemReminderLayout(t *testing.T, body []byte, wantSystem, wantUser, wantTTL string) { +func assertClaudePreservedSystemLayout(t *testing.T, body []byte, wantSystem, wantUser, wantTTL string) { t.Helper() - if got := gjson.GetBytes(body, "system.#").Int(); got != 2 { - t.Fatalf("top-level system block count = %d, want billing and identity only", got) + if got := gjson.GetBytes(body, "system.#").Int(); got != 3 { + t.Fatalf("top-level system block count = %d, want billing, identity and caller", got) } if got := gjson.GetBytes(body, "messages.#").Int(); got != 1 { - t.Fatalf("message count = %d, want one user turn and no role=system", got) + t.Fatalf("message count = %d, want one user turn", got) } content := gjson.GetBytes(body, "messages.0.content").Array() - if len(content) != 3 { - t.Fatalf("user content has %d blocks, want currentDate, caller reminder, and user text", len(content)) + if len(content) != 2 { + t.Fatalf("user content has %d blocks, want currentDate and user text", len(content)) } assertClaudeCodeCurrentDateBlock(t, content[0]) - if got := content[1].Get("text").String(); got != claudeCallerSystemReminder(wantSystem) { - t.Fatalf("caller reminder lost system prompt: got len %d, want len %d", len(got), len(wantSystem)) - } - if content[1].Get("cache_control").Exists() { - t.Fatalf("caller reminder unexpectedly has cache_control: %s", content[1].Raw) - } - assertEphemeralUserTextBlock(t, content[2], wantUser, wantTTL) + assertEphemeralUserTextBlock(t, content[1], wantUser, wantTTL) + assertClaudeTopLevelCallerSystem(t, body, 1, wantSystem, wantTTL) } func assertClaudeCodeCurrentDateBlock(t *testing.T, block gjson.Result) { @@ -3983,8 +3946,7 @@ func TestInjectClaudeCodeCurrentDateFollowsAllLeadingToolResults(t *testing.T) { assertClaudeCodeCurrentDateBlockAt(t, content[2], fixed) } -// Test case 1: String system prompt becomes an authoritative mid-conversation -// system message after the first user turn. +// String caller instructions stay top-level alongside CLI attribution. func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { payload := []byte(`{"model":"claude-opus-5","system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) @@ -3995,8 +3957,8 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { t.Fatalf("system should be an array, got %s", system.Type) } blocks := system.Array() - if len(blocks) != 2 { - t.Fatalf("expected billing and identity blocks only, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("expected billing, identity and caller blocks, got %d", len(blocks)) } if got := blocks[0].Get("text").String(); !strings.Contains(got, "cc_entrypoint=cli;") { t.Fatalf("blocks[0] should use CLI billing attribution, got %q", got) @@ -4016,7 +3978,7 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) { } assertClaudeCodeCurrentDateBlock(t, content[0]) assertEphemeralUserTextBlock(t, content[1], "hi", "") - assertClaudeMidConversationSystemMessage(t, out, 1, "You are a helpful assistant.", "") + assertClaudeTopLevelCallerSystem(t, out, 1, "You are a helpful assistant.", "") } func TestClaudeUsesLegacySystemReminder(t *testing.T) { @@ -4042,70 +4004,6 @@ func TestClaudeUsesLegacySystemReminder(t *testing.T) { } } -func TestCheckSystemInstructionsWithMode_FutureModelDefaultsToMidSystem(t *testing.T) { - payload := []byte(`{"model":"claude-opus-6","system":"future instructions","messages":[{"role":"user","content":"hi"}]}`) - - out := checkSystemInstructionsWithMode(payload, false) - if got := gjson.GetBytes(out, "system.#").Int(); got != 2 { - t.Fatalf("top-level system block count = %d, want 2", got) - } - content := gjson.GetBytes(out, "messages.0.content").Array() - if len(content) != 2 { - t.Fatalf("user content has %d blocks, want currentDate and user text", len(content)) - } - assertClaudeCodeCurrentDateBlock(t, content[0]) - assertEphemeralUserTextBlock(t, content[1], "hi", "") - assertClaudeMidConversationSystemMessage(t, out, 1, "future instructions", "") -} - -func TestCheckSystemInstructionsWithMode_LegacyModelUsesSystemReminder(t *testing.T) { - payload := []byte(`{"model":"claude-opus-4-6","system":"legacy instructions","messages":[{"role":"user","content":"hi"}]}`) - - out := checkSystemInstructionsWithMode(payload, false) - if got := gjson.GetBytes(out, "system.#").Int(); got != 2 { - t.Fatalf("top-level system block count = %d, want billing and identity only", got) - } - if got := gjson.GetBytes(out, "messages.#").Int(); got != 1 { - t.Fatalf("message count = %d, want no role=system insertion", got) - } - content := gjson.GetBytes(out, "messages.0.content").Array() - if len(content) != 3 { - t.Fatalf("user content has %d blocks, want currentDate, caller reminder, and user text", len(content)) - } - assertClaudeCodeCurrentDateBlock(t, content[0]) - if got := content[1].Get("text").String(); got != claudeCallerSystemReminder("legacy instructions") { - t.Fatalf("caller system reminder = %q", got) - } - if content[1].Get("cache_control").Exists() { - t.Fatalf("caller system reminder unexpectedly has cache_control: %s", content[1].Raw) - } - assertEphemeralUserTextBlock(t, content[2], "hi", "") -} - -func TestCheckSystemInstructionsWithMode_LegacyModelKeepsSystemBlocksSeparate(t *testing.T) { - payload := []byte(`{"model":"claude-opus-4-6","system":[` + - `{"type":"text","text":"first guidance","cache_control":{"type":"ephemeral","ttl":"1h"}},` + - `{"type":"text","text":"second guidance"}],` + - `"messages":[{"role":"user","content":"hi"}]}`) - - out := checkSystemInstructionsWithMode(payload, false) - content := gjson.GetBytes(out, "messages.0.content").Array() - if len(content) != 4 { - t.Fatalf("user content has %d blocks, want currentDate, two caller reminders, and user text: %s", len(content), out) - } - assertClaudeCodeCurrentDateBlock(t, content[0]) - for idx, want := range []string{"first guidance", "second guidance"} { - block := content[idx+1] - if got := block.Get("text").String(); got != claudeCallerSystemReminder(want) { - t.Fatalf("content[%d].text = %q, want separate caller reminder %q", idx+1, got, want) - } - if block.Get("cache_control").Exists() { - t.Fatalf("content[%d] caller reminder unexpectedly has cache_control: %s", idx+1, block.Raw) - } - } - assertEphemeralUserTextBlock(t, content[3], "hi", "") -} - // Test case 2: Strict mode keeps only the injected Claude Code system blocks. func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) { payload := []byte(`{"system":"You are a helpful assistant.","messages":[{"role":"user","content":"hi"}]}`) @@ -4142,15 +4040,15 @@ func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T) assertEphemeralUserTextBlock(t, content[1], "hi", "") } -// Test case 4: Array system prompt becomes one mid-conversation system message. +// Array caller instructions stay top-level. func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { payload := []byte(`{"model":"claude-opus-5","system":[{"type":"text","text":"Be concise."}],"messages":[{"role":"user","content":"hi"}]}`) out := checkSystemInstructionsWithMode(payload, false) blocks := gjson.GetBytes(out, "system").Array() - if len(blocks) != 2 { - t.Fatalf("expected 2 top-level system blocks, got %d", len(blocks)) + if len(blocks) != 3 { + t.Fatalf("expected 3 top-level system blocks, got %d", len(blocks)) } content := gjson.GetBytes(out, "messages.0.content").Array() if len(content) != 2 { @@ -4158,71 +4056,17 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) { } assertClaudeCodeCurrentDateBlock(t, content[0]) assertEphemeralUserTextBlock(t, content[1], "hi", "") - assertClaudeMidConversationSystemMessage(t, out, 1, "Be concise.", "") -} - -func TestCheckSystemInstructionsWithMode_ArraySystemKeepsBlocksAsSeparateMessages(t *testing.T) { - payload := []byte(`{"model":"claude-opus-5","system":[` + - `{"type":"text","text":"first guidance","cache_control":{"type":"ephemeral","ttl":"1h"}},` + - `{"type":"text","text":"second guidance"}],` + - `"messages":[{"role":"user","content":"hi"}]}`) - - out := checkSystemInstructionsWithMode(payload, false) - if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 { - t.Fatalf("message count = %d, want user and two separate system messages: %s", got, out) - } - content := gjson.GetBytes(out, "messages.0.content").Array() - if len(content) != 2 { - t.Fatalf("user content has %d blocks, want currentDate and user text: %s", len(content), out) - } - assertClaudeCodeCurrentDateBlock(t, content[0]) - assertEphemeralUserTextBlock(t, content[1], "hi", "") - assertClaudeMidConversationSystemMessage(t, out, 1, "first guidance", "") - assertClaudeMidConversationSystemMessage(t, out, 2, "second guidance", "") + assertClaudeTopLevelCallerSystem(t, out, 1, "Be concise.", "") } -func TestRelocateClaudeSystemPromptForCountTokensKeepsBlocksSeparate(t *testing.T) { - tests := []struct { - name string - model string - legacy bool - }{ - {name: "mid-system model", model: "claude-opus-5"}, - {name: "legacy model", model: "claude-opus-4-6", legacy: true}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - payload := []byte(`{"model":"` + test.model + `","system":[` + - `{"type":"text","text":"first guidance"},` + - `{"type":"text","text":"second guidance"}],` + - `"messages":[{"role":"user","content":"hi"}]}`) - - out := relocateClaudeSystemPromptForCountTokens(payload, false) - if gjson.GetBytes(out, "system").Exists() { - t.Fatalf("count_tokens system must be absent: %s", out) - } - if test.legacy { - content := gjson.GetBytes(out, "messages.0.content").Array() - if len(content) != 3 { - t.Fatalf("legacy content has %d blocks, want two reminders and user text: %s", len(content), out) - } - if got := content[0].Get("text").String(); got != claudeCallerSystemReminder("first guidance") { - t.Fatalf("first caller reminder = %q", got) - } - if got := content[1].Get("text").String(); got != claudeCallerSystemReminder("second guidance") { - t.Fatalf("second caller reminder = %q", got) - } - if got := content[2].Get("text").String(); got != "hi" { - t.Fatalf("user text = %q, want hi", got) - } - return - } - if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 { - t.Fatalf("message count = %d, want user and two system messages: %s", got, out) +func TestPrepareClaudeSystemPromptForCountTokensKeepsBlocksSeparate(t *testing.T) { + for _, model := range []string{"claude-opus-5", "claude-opus-4-6", "claude-future-6"} { + t.Run(model, func(t *testing.T) { + payload := []byte(`{"model":"` + model + `","system":[{"type":"text","text":"first guidance"},{"type":"text","text":"second guidance"}],"messages":[{"role":"user","content":"hi"}]}`) + out := prepareClaudeSystemPromptForCountTokens(payload, false) + if !bytes.Equal(payload, out) { + t.Fatalf("count_tokens changed caller system or messages: %s", out) } - assertClaudeMidConversationSystemMessage(t, out, 1, "first guidance", "") - assertClaudeMidConversationSystemMessage(t, out, 2, "second guidance", "") }) } } @@ -4320,7 +4164,7 @@ func TestRelocateClaudeSystemPromptForCountTokens_AdvisorToolResultLeavesMessage ] }`) - out := relocateClaudeSystemPromptForCountTokens(payload, false) + out := prepareClaudeSystemPromptForCountTokens(payload, false) // Caller system blocks must be retained in top-level system so that // their tokens are counted, without splicing them into messages[]. @@ -4466,7 +4310,7 @@ func TestCheckSystemInstructionsWithMode_StandaloneAdvisorResultWithoutCall(t *t } } -func TestCheckSystemInstructionsWithMode_NormalTextWithAdvisorWordDoesNotBypassMidSystemSplice(t *testing.T) { +func TestCheckSystemInstructionsWithMode_NormalTextWithAdvisorWordKeepsSystemTopLevel(t *testing.T) { payload := []byte(`{ "model": "claude-opus-5", "system": [ @@ -4480,12 +4324,12 @@ func TestCheckSystemInstructionsWithMode_NormalTextWithAdvisorWordDoesNotBypassM out := checkSystemInstructionsWithMode(payload, false) - // Since there is no advisor tool invocation/result, normal mid-conversation system insertion occurs. - if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 { - t.Fatalf("messages count = %d, want 3 (user + 2 system messages): %s", got, out) + // Caller instructions stay top-level regardless of advisor content. + if got := gjson.GetBytes(out, "messages.#").Int(); got != 1 { + t.Fatalf("messages count = %d, want 1 user message: %s", got, out) } - assertClaudeMidConversationSystemMessage(t, out, 1, "first guidance", "") - assertClaudeMidConversationSystemMessage(t, out, 2, "second guidance", "") + assertClaudeTopLevelCallerSystem(t, out, 1, "first guidance", "") + assertClaudeTopLevelCallerSystem(t, out, 2, "second guidance", "") } func TestCheckSystemInstructionsWithMode_AdvisorToolResultArrayContent(t *testing.T) { @@ -4594,15 +4438,15 @@ func TestCheckSystemInstructionsWithMode_ToolResultWithAdvisorRedactedResult(t * } } -// Test case 5: Special characters survive the mid-conversation system move. +// Special characters survive top-level system preservation. func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { payload := []byte(`{"model":"claude-opus-5","system":"Use tags & \"quotes\" in output.","messages":[{"role":"user","content":"hi"}]}`) out := checkSystemInstructionsWithMode(payload, false) wantSystem := `Use tags & "quotes" in output.` - if got := gjson.GetBytes(out, "system.#").Int(); got != 2 { - t.Fatalf("top-level system block count = %d, want 2", got) + if got := gjson.GetBytes(out, "system.#").Int(); got != 3 { + t.Fatalf("top-level system block count = %d, want 3", got) } content := gjson.GetBytes(out, "messages.0.content").Array() if len(content) != 2 { @@ -4610,7 +4454,7 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) { } assertClaudeCodeCurrentDateBlock(t, content[0]) assertEphemeralUserTextBlock(t, content[1], "hi", "") - assertClaudeMidConversationSystemMessage(t, out, 1, wantSystem, "") + assertClaudeTopLevelCallerSystem(t, out, 1, wantSystem, "") } func TestCheckSystemInstructionsWithSigningMode_LongPromptIsExactAndIdempotent(t *testing.T) { @@ -4633,11 +4477,11 @@ func TestCheckSystemInstructionsWithSigningMode_LongPromptIsExactAndIdempotent(t if !bytes.Equal(first, second) { t.Fatalf("complete cloak layout is not byte-idempotent:\nfirst: %s\nsecond: %s", first, second) } - if got := gjson.GetBytes(first, "system.#").Int(); got != 2 { - t.Fatalf("top-level system block count = %d, want 2", got) + if got := gjson.GetBytes(first, "system.#").Int(); got != 3 { + t.Fatalf("top-level system block count = %d, want 3", got) } - if got := gjson.GetBytes(first, "messages.#").Int(); got != 2 { - t.Fatalf("message count = %d, want user then system", got) + if got := gjson.GetBytes(first, "messages.#").Int(); got != 1 { + t.Fatalf("message count = %d, want only the user turn", got) } content := gjson.GetBytes(first, "messages.0.content").Array() if len(content) != 2 { @@ -4645,7 +4489,7 @@ func TestCheckSystemInstructionsWithSigningMode_LongPromptIsExactAndIdempotent(t } assertClaudeCodeCurrentDateBlock(t, content[0]) assertEphemeralUserTextBlock(t, content[1], "hello", "") - assertClaudeMidConversationSystemMessage(t, first, 1, wantSystem, "") + assertClaudeTopLevelCallerSystem(t, first, 1, wantSystem, "") if strings.Contains(content[0].Get("text").String(), "PI_SYSTEM_BEGIN") || strings.Contains(content[1].Get("text").String(), "PI_SYSTEM_BEGIN") { t.Fatal("caller system prompt leaked into the user content blocks") } @@ -7555,8 +7399,8 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) { if got := gjson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity { t.Fatalf("Messages system.1.text = %q, want official CLI identity", got) } - if got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 2 { - t.Fatalf("Messages top-level system block count = %d, want 2", got) + if got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 3 { + t.Fatalf("Messages top-level system block count = %d, want 3", got) } content := gjson.GetBytes(upstreamBody, "messages.0.content").Array() if len(content) != 2 { @@ -7564,7 +7408,7 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) { } assertClaudeCodeCurrentDateBlock(t, content[0]) assertEphemeralUserTextBlock(t, content[1], "search", "1h") - assertClaudeMidConversationSystemMessage(t, upstreamBody, 1, "messages-system-prompt", "1h") + assertClaudeTopLevelCallerSystem(t, upstreamBody, 1, "messages-system-prompt", "1h") } func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing.T) { @@ -7632,8 +7476,8 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing if got := gjson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity { t.Fatalf("streaming system.1.text = %q, want official CLI identity", got) } - if got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 2 { - t.Fatalf("streaming top-level system block count = %d, want 2", got) + if got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 3 { + t.Fatalf("streaming top-level system block count = %d, want 3", got) } content := gjson.GetBytes(upstreamBody, "messages.0.content").Array() if len(content) != 2 { @@ -7641,7 +7485,7 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing } assertClaudeCodeCurrentDateBlock(t, content[0]) assertEphemeralUserTextBlock(t, content[1], "fetch", "1h") - assertClaudeMidConversationSystemMessage(t, upstreamBody, 1, "stream-system-prompt", "1h") + assertClaudeTopLevelCallerSystem(t, upstreamBody, 1, "stream-system-prompt", "1h") assertClaudeCredentialIdentity(t, upstreamBody, upstreamHeaders, deviceIDs, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") if !strings.Contains(downstream.String(), `"name":"fetch_url"`) { t.Fatalf("downstream stream did not restore fetch_url: %s", downstream.String()) @@ -7651,112 +7495,6 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing } } -func TestPrependClaudeSystemReminders_FollowsToolResultsAndIsIdempotent(t *testing.T) { - payload := []byte(`{"messages":[` + - `{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]},` + - `{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"},{"type":"text","text":"continue"}]}` + - `]}`) - - texts := []string{"first guidance", "second guidance"} - first := prependClaudeSystemRemindersToFirstUserMessage(payload, texts) - second := prependClaudeSystemRemindersToFirstUserMessage(first, texts) - if !bytes.Equal(first, second) { - t.Fatalf("caller reminder insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second) - } - content := gjson.GetBytes(first, "messages.1.content").Array() - if len(content) != 4 { - t.Fatalf("content has %d blocks, want tool_result, two caller reminders, and user text", len(content)) - } - if got := content[0].Get("type").String(); got != "tool_result" { - t.Fatalf("content[0].type = %q, want tool_result", got) - } - for idx, text := range texts { - if got := content[idx+1].Get("text").String(); got != claudeCallerSystemReminder(text) { - t.Fatalf("content[%d].text = %q, want caller reminder %q", idx+1, got, text) - } - } - if got := content[3].Get("text").String(); got != "continue" { - t.Fatalf("content[3].text = %q, want user text", got) - } -} - -func TestInsertClaudeMidConversationSystemMessages_FollowsToolResultUserTurn(t *testing.T) { - payload := []byte(`{"messages":[` + - `{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{}}]},` + - `{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}` + - `]}`) - - out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"}) - if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 { - t.Fatalf("message count = %d, want 3: %s", got, out) - } - blocks := gjson.GetBytes(out, "messages.1.content") - if got := blocks.Get("0.type").String(); got != "tool_result" { - t.Fatalf("first block type = %q, want tool_result: %s", got, out) - } - if got := blocks.Get("0.tool_use_id").String(); got != "toolu_1" { - t.Fatalf("tool_use_id = %q, want toolu_1: %s", got, out) - } - assertClaudeMidConversationSystemMessage(t, out, 2, "guidance", "") -} - -func TestInsertClaudeMidConversationSystemMessages_PrecedesExistingAssistantTurn(t *testing.T) { - payload := []byte(`{"messages":[` + - `{"role":"user","content":"hello"},` + - `{"role":"assistant","content":"answer"},` + - `{"role":"user","content":"continue"}` + - `]}`) - - out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"}) - roles := gjson.GetBytes(out, "messages.#.role").Array() - wantRoles := []string{"user", "system", "assistant", "user"} - if len(roles) != len(wantRoles) { - t.Fatalf("message count = %d, want %d: %s", len(roles), len(wantRoles), out) - } - for idx, wantRole := range wantRoles { - if got := roles[idx].String(); got != wantRole { - t.Fatalf("messages[%d].role = %q, want %q", idx, got, wantRole) - } - } - assertClaudeMidConversationSystemMessage(t, out, 1, "guidance", "") -} - -func TestInsertClaudeMidConversationSystemMessages_FollowsConsecutiveUserRun(t *testing.T) { - payload := []byte(`{"messages":[` + - `{"role":"user","content":"first"},` + - `{"role":"user","content":"second"},` + - `{"role":"assistant","content":"answer"}` + - `]}`) - - out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"}) - roles := gjson.GetBytes(out, "messages.#.role").Array() - wantRoles := []string{"user", "user", "system", "assistant"} - if len(roles) != len(wantRoles) { - t.Fatalf("message count = %d, want %d: %s", len(roles), len(wantRoles), out) - } - for idx, wantRole := range wantRoles { - if got := roles[idx].String(); got != wantRole { - t.Fatalf("messages[%d].role = %q, want %q", idx, got, wantRole) - } - } - assertClaudeMidConversationSystemMessage(t, out, 2, "guidance", "") -} - -func TestInsertClaudeMidConversationSystemMessages_IsIdempotent(t *testing.T) { - payload := []byte(`{"messages":[{"role":"user","content":"hello"}]}`) - texts := []string{"first guidance", "second guidance"} - first := insertClaudeMidConversationSystemMessages(payload, texts) - second := insertClaudeMidConversationSystemMessages(first, texts) - if !bytes.Equal(first, second) { - t.Fatalf("mid-conversation system insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second) - } - if got := gjson.GetBytes(first, "messages.#").Int(); got != 3 { - t.Fatalf("message count = %d, want user and two system messages: %s", got, first) - } - assertClaudeMidConversationSystemMessage(t, first, 1, texts[0], "") - assertClaudeMidConversationSystemMessage(t, first, 2, texts[1], "") -} - // TestClaudeCodeCLIBetas_MatchesObservedClientMatrix pins the Anthropic-Beta // baseline to Claude Code 2.1.220 behavior captured against api.anthropic.com. // The OAuth profile was reverified on 2026-08-03 with two distinct accounts. @@ -8657,9 +8395,7 @@ func TestApplyCloakingStrictModeIgnoresNonTextCallerSystemBlock(t *testing.T) { } } -// A cloaked direct-Anthropic count_tokens request relocates caller system blocks -// into messages, so a non-text block has no destination there either and must be -// rejected before any upstream call. +// Count-token requests reject non-text system blocks before any upstream call. func TestClaudeExecutor_CountTokensRejectsNonTextCallerSystemBlock(t *testing.T) { upstreamCalled := false transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index c43b887a..76ff3468 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -157,7 +157,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy directAnthropic := isAnthropicUpstreamBase(baseURL) // Claude Code's count_tokens carries only model, messages and tools, so the // full Messages cloaking must not run here for any origin. Apply the parts - // that still have to hold: relocate the caller's system prompt into messages + // that still have to hold: preserve the caller's top-level system prompt // so its tokens stay counted, and obfuscate sensitive words exactly like the // Messages path. Kimi opt-in uses the same contract. policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) @@ -168,10 +168,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy return cliproxyexecutor.Response{}, errSystem } } - body = relocateClaudeSystemPromptForCountTokens(body, settings.strictMode) - if rebuildMidSystemMessageEnabled(e.cfg, auth) { - body = rebuildMidSystemMessagesToTopLevel(body) - } + body = prepareClaudeSystemPromptForCountTokens(body, settings.strictMode) if len(settings.sensitiveWords) > 0 { body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords)) } @@ -204,7 +201,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy // the token count to reflect it, so CPA must not silently rewrite the request. // Only an explicit claude-code-cli profile aligns the shape, and then it aligns // to the measured one: Claude Code 2.1.220 count_tokens carries exactly model, - // messages and tools, never a system block. + // messages and tools, plus caller system instructions when present. alignCLICountTokensShape := fp.ProfileClaudeCodeCLI if directAnthropic || alignCLICountTokensShape { body, _ = sjson.DeleteBytes(body, "metadata") diff --git a/internal/runtime/executor/claude_mid_system_model_test.go b/internal/runtime/executor/claude_mid_system_model_test.go index 65db8603..0e859fe9 100644 --- a/internal/runtime/executor/claude_mid_system_model_test.go +++ b/internal/runtime/executor/claude_mid_system_model_test.go @@ -3,6 +3,7 @@ package executor import ( "context" "errors" + "fmt" "io" "net/http" "strings" @@ -190,9 +191,8 @@ func TestClaudeExecutor_PayloadOverrideCannotSmuggleLegacyMidSystemMessage(t *te assertMidSystemRejected(t, err, upstream) } -// A caller may already have the exact role=system turn that cloaking would -// otherwise insert. The message-count proof must keep that turn caller-owned, -// so a later legacy model rewrite is rejected instead of silently consuming it. +// A caller-owned role=system turn is not part of cloaking; a legacy model +// rewrite must still reject it even when its text matches top-level instructions. func TestClaudeExecutor_PayloadOverrideDoesNotClaimMatchingCallerTurn(t *testing.T) { upstream := &midSystemUpstream{} cfg := midSystemConfig() @@ -212,11 +212,9 @@ func TestClaudeExecutor_PayloadOverrideDoesNotClaimMatchingCallerTurn(t *testing assertMidSystemRejected(t, err, upstream) } -// Cloaking relocates a caller's system prompt into a role=system turn for models -// that accept one. A payload rule can then rewrite the model to one that does -// not. Because the caller never wrote that turn, CPA must reconcile its own -// placement through the legacy reminder path instead of returning 400. -func TestClaudeExecutor_PayloadOverrideReconcilesRelocatedSystemPrompt(t *testing.T) { +// A model override must preserve top-level caller instructions without creating +// a synthetic conversation turn, including when the final model is legacy. +func TestClaudeExecutor_PayloadOverridePreservesTopLevelSystemPrompt(t *testing.T) { for _, test := range []struct { name string send func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, payload []byte) error @@ -258,20 +256,20 @@ func TestClaudeExecutor_PayloadOverrideReconcilesRelocatedSystemPrompt(t *testin `"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) if err := test.send(t, ex, upstream.context(t, nil), payload); err != nil { - t.Fatalf("request error = %v, want CPA's inserted turn reconciled", err) + t.Fatalf("request error = %v, want top-level instructions preserved", err) } if !upstream.called { - t.Fatal("expected reconciled request to reach upstream") + t.Fatal("expected request to reach upstream") } if got := gjson.GetBytes(upstream.body, "model").String(); got != "claude-haiku-4-5-20251001" { t.Fatalf("upstream model = %q, want payload override preserved", got) } if gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() { - t.Fatalf("reconciled body still carries role=system; body=%s", upstream.body) + t.Fatalf("body carries role=system; body=%s", upstream.body) } - if !strings.Contains(gjson.GetBytes(upstream.body, "messages.0.content").Raw, "") || - !strings.Contains(gjson.GetBytes(upstream.body, "messages.0.content").Raw, "Caller top") { - t.Fatalf("caller system prompt was not replayed as a legacy reminder; body=%s", upstream.body) + if gjson.GetBytes(upstream.body, "system.2.text").String() != "Caller top" || + strings.Contains(gjson.GetBytes(upstream.body, "messages").Raw, "Caller top") { + t.Fatalf("caller system prompt did not stay top-level: %s", upstream.body) } }) } @@ -362,9 +360,7 @@ func TestClaudeExecutor_LegacyMidSystemMessageOptInStillRebuilds(t *testing.T) { } } -// OAuth credentials store per-credential options in token metadata. Cloaking -// runs after the initial rebuild pass, so the option must also fold the system -// turns introduced by cloaking back into the top-level system field. +// Existing OAuth rebuild metadata remains compatible with top-level cloaking. func TestClaudeExecutor_OAuthRebuildKeepsCloakedInstructionsTopLevel(t *testing.T) { tests := []struct { name string @@ -434,6 +430,88 @@ func TestClaudeExecutor_OAuthRebuildKeepsCloakedInstructionsTopLevel(t *testing. } } +// The Codex Responses path must keep instructions out of conversation turns on +// the initial request and every continuation, without rebuild metadata. +func TestClaudeExecutor_CloakingPreservesResponsesTurnLayout(t *testing.T) { + for _, model := range []string{"claude-opus-5", "claude-opus-4-6", "claude-future-6"} { + for _, path := range []string{"execute", "stream", "count"} { + t.Run(model+"/"+path, func(t *testing.T) { + input := `[{"type":"message","role":"developer","content":[{"type":"input_text","text":"DEVELOPER_RULE"}]}]` + for turn := 0; turn < 3; turn++ { + user := fmt.Sprintf("USER_%d", turn) + input = strings.TrimSuffix(input, "]") + fmt.Sprintf(`,{"type":"message","role":"user","content":[{"type":"input_text","text":%q}]}]`, user) + payload := []byte(fmt.Sprintf(`{"model":%q,"instructions":"SYSTEM_RULE","input":%s}`, model, input)) + upstream := &midSystemUpstream{} + ex := NewClaudeExecutor(midSystemConfig()) + ctx := upstream.context(t, nil) + auth := midSystemAuth() + req := cliproxyexecutor.Request{Model: model, Payload: payload} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse} + var err error + switch path { + case "execute": + _, err = ex.Execute(ctx, auth, req, opts) + case "stream": + var result *cliproxyexecutor.StreamResult + result, err = ex.ExecuteStream(ctx, auth, req, opts) + if err == nil { + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + } + } + case "count": + _, err = ex.countTokensUpstream(ctx, auth, req, opts) + } + if err != nil { + t.Fatal(err) + } + if !upstream.called { + t.Fatal("request did not reach upstream") + } + messages := gjson.GetBytes(upstream.body, "messages").Array() + if len(messages) != 2*turn+1 { + t.Fatalf("turn %d: got %d messages, want %d", turn, len(messages), 2*turn+1) + } + for i, message := range messages { + role, text := "user", fmt.Sprintf("USER_%d", i/2) + if i%2 == 1 { + role, text = "assistant", "ASSISTANT_REPLY" + } + if message.Get("role").String() != role || !strings.Contains(message.Get("content").Raw, text) { + t.Fatalf("turn %d message %d lost role or content: %s", turn, i, message.Raw) + } + } + for _, rule := range []string{"SYSTEM_RULE", "DEVELOPER_RULE"} { + if strings.Count(gjson.GetBytes(upstream.body, "system").Raw, rule) != 1 || + strings.Contains(gjson.GetBytes(upstream.body, "messages").Raw, rule) { + t.Fatalf("turn %d: %s must occur exactly once, only in top-level system", turn, rule) + } + } + input = strings.TrimSuffix(input, "]") + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ASSISTANT_REPLY"}]}]` + } + }) + } + } +} + +func TestClaudeCloakingPreservesCallerSystemCacheMetadata(t *testing.T) { + const block = `{"type":"text","text":"CALLER_RULE","cache_control":{"type":"ephemeral","ttl":"1h","scope":"global"}}` + payload := []byte(`{"model":"claude-opus-5","system":[` + block + `],"messages":[{"role":"user","content":"hi"}]}`) + first := checkSystemInstructionsWithMode(payload, false) + if got := gjson.GetBytes(first, "system.2").Raw; got != block { + t.Fatalf("caller system block changed: %s", got) + } + if second := checkSystemInstructionsWithMode(first, false); string(second) != string(first) { + t.Fatal("repeated cloaking changed the request or duplicated caller instructions") + } + counted := prepareClaudeSystemPromptForCountTokens(payload, false) + if got := gjson.GetBytes(counted, "system.0").Raw; got != block { + t.Fatalf("count_tokens changed caller cache metadata: %s", got) + } +} + // The pairing must never originate inside CPA. A non-Claude caller reaches the // Claude executor through a translator, and every translator hoists system // content into the top-level system field, so no translated body can carry a diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 93599a86..4fbfd004 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -126,9 +126,8 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte // System-level inputs become canonical top-level Claude system blocks in // source order: instructions first, then every input item whose role is // system or developer. Each source block stays a separate Claude block and - // keeps operator authority; the Claude executor decides the final placement - // (mid-conversation role=system messages, or system reminders on legacy - // models), so this layer must not merge, trim or downgrade them to user text. + // keeps operator authority. Executor cloaking adds attribution in that same + // top-level slot, never downgrading caller instructions to conversation text. messageCapacity := root.Get("input.#").Int() messageBlocks := common.NewRawArrayItems(messageCapacity) systemBlocks := make([][]byte, 0, 4)