diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index 154479386e..585f23d1a1 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -148,20 +148,44 @@ func computeFingerprint(messageText, version string) string { // generateBillingHeader creates the x-anthropic-billing-header text block that // Claude Code prepends to its system prompt. cch is present only on signed paths. -func generateBillingHeader(cchSigning bool, version, messageText, entrypoint, workload string) string { +func generateBillingHeader(cchSigning bool, version, messageText, entrypoint, workload string, isSubagent bool, prevReq, promptID string) string { if entrypoint == "" { entrypoint = "cli" } buildHash := computeFingerprint(messageText, version) - workloadPart := "" + var b strings.Builder + b.WriteString("x-anthropic-billing-header: cc_version=") + b.WriteString(version) + b.WriteByte('.') + b.WriteString(buildHash) + b.WriteString("; cc_entrypoint=") + b.WriteString(entrypoint) + b.WriteByte(';') + + if cchSigning { + b.WriteString(" cch=00000;") + } if workload != "" { - workloadPart = fmt.Sprintf(" cc_workload=%s;", workload) + b.WriteString(" cc_workload=") + b.WriteString(workload) + b.WriteByte(';') + } + if isSubagent { + b.WriteString(" cc_is_subagent=true;") } - if cchSigning { - return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=%s; cch=00000;%s", version, buildHash, entrypoint, workloadPart) + if prevReq != "" { + b.WriteString(" cc_prev_req=") + b.WriteString(prevReq) + b.WriteByte(';') + } + if promptID != "" { + b.WriteString(" cc_prompt_id=") + b.WriteString(promptID) + b.WriteByte(';') + } } - return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=%s;%s", version, buildHash, entrypoint, workloadPart) + return b.String() } func claudeBillingFingerprintMessageText(payload []byte) string { @@ -191,17 +215,37 @@ func claudeBillingFingerprintMessageText(payload []byte) string { } func claudeCCHFallbackBillingHeader(ctx context.Context, cfg *config.Config, payload []byte, entrypoint string) string { + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(payload) + prevReq, promptID := helps.ExtractClaudeBillingTags(payload) + if !isProbeOrHelper { + continuityCtx := helps.ClaudeContinuityContextFromContext(ctx) + if prevReq == "" && continuityCtx != nil { + prevReq = continuityCtx.PreviousRequestID + } + if promptID == "" && continuityCtx != nil { + promptID = continuityCtx.PromptID + } + } + incomingHeaders := resolveIncomingClaudeHeaders(ctx, helps.IncomingHeadersFromContext(ctx)) + isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, payload) return generateBillingHeader( true, helps.DefaultClaudeVersion(cfg), claudeBillingFingerprintMessageText(payload), entrypoint, getWorkloadFromContext(ctx), + isSubagent, + prevReq, + promptID, ) } const claudeCodeCLIIdentity = "You are Claude Code, Anthropic's official CLI for Claude." +const claudeCodeFableReportingOutcomes = `# Reporting outcomes + +Report what actually happened, not what you intended. When you say something is done, sent, saved, fixed, or verified, that claim must rest on a result you observed in this session — tool output, the file as it now reads, the page as it now loads — not on what the step should have produced. If you did not check, say you did not check. If any step failed, was skipped, or came back different from what you expected, say so in the first sentence of your report, before anything else, even when the rest of the work succeeded. Never quietly work around a failure in a way that makes it look resolved; a problem the user can see is recoverable, one your summary hides is not. When you stop before the task is complete, your first line says so plainly and names what is left. Do not describe partial work as done, and do not let a summary read as more certain than the evidence behind it.` + func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { return checkSystemInstructionsWithSigningMode(payload, strictMode, false, "2.1.258", "cli", "") } @@ -212,17 +256,38 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { // Claude models give it operator-level authority without changing the cached // top-level prefix. func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cchSigning bool, version, entrypoint, workload string) []byte { - return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, time.Now()) + return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, time.Now(), false, "", "") } -func checkSystemInstructionsWithSigningModeAt(payload []byte, strictMode bool, cchSigning bool, version, entrypoint, workload string, now time.Time) []byte { +// isClaudeFable51Model reports whether the model is specifically Fable 5.1 / Mythos 5.1, +// matching native Claude Code 2.1.258 family/major/minor checks (AFo = {major:5, minor:1}). +func isClaudeFable51Model(model string) bool { + m := strings.ToLower(strings.TrimSpace(model)) + return strings.Contains(m, "fable-5-1") || strings.Contains(m, "fable-5.1") || strings.Contains(m, "mythos-5-1") || strings.Contains(m, "mythos-5.1") +} + +func checkSystemInstructionsWithSigningModeAt( + payload []byte, + strictMode bool, + cchSigning bool, + version, entrypoint, workload string, + now time.Time, + isSubagent bool, + prevReq, promptID string, +) []byte { system := gjson.GetBytes(payload, "system") messageText := claudeBillingFingerprintMessageText(payload) - billingText := generateBillingHeader(cchSigning, version, messageText, entrypoint, workload) + billingText := generateBillingHeader(cchSigning, version, messageText, entrypoint, workload, isSubagent, prevReq, promptID) billingBlock := buildTextBlock(billingText, nil) agentBlock := buildTextBlock(claudeCodeCLIIdentity, &claudeCodeCacheControl) - payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+billingBlock+","+agentBlock+"]")) + + systemBlocks := []string{billingBlock, agentBlock} + model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) + if isClaudeFable51Model(model) && !helps.IsClaudeProbeOrHelperRequest(payload) { + systemBlocks = append(systemBlocks, buildTextBlock(claudeCodeFableReportingOutcomes, nil)) + } + payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]")) if strictMode { return injectClaudeCodeCurrentDate(payload, now) } @@ -701,6 +766,141 @@ func reconcileClaudeCodeSystemPlacementAfterPayload(payload []byte, state claude return prependClaudeSystemRemindersToFirstUserMessage(updated, state.texts) } +type claudeCodeFableState struct { + injectedFallbacks bool + injectedDisplay bool + injectedReporting bool +} + +func hasFableReportingBlock(body []byte) bool { + system := gjson.GetBytes(body, "system") + if !system.IsArray() { + return system.String() == claudeCodeFableReportingOutcomes + } + for _, blk := range system.Array() { + if blk.Get("text").String() == claudeCodeFableReportingOutcomes { + return true + } + } + return false +} + +func captureClaudeCodeFableState(before, after []byte, cloaked bool) claudeCodeFableState { + if !cloaked || len(before) == 0 || len(after) == 0 { + return claudeCodeFableState{} + } + return claudeCodeFableState{ + injectedFallbacks: !gjson.GetBytes(before, "fallbacks").Exists() && gjson.GetBytes(after, "fallbacks").Exists(), + injectedDisplay: !gjson.GetBytes(before, "thinking.display").Exists() && gjson.GetBytes(after, "thinking.display").Exists(), + injectedReporting: !hasFableReportingBlock(before) && hasFableReportingBlock(after), + } +} + +// reconcileClaudeCodeFableModelAfterPayload reconciles model-specific additions +// (Opus fallback, thinking.display=updates, and # Reporting outcomes system block) +// if payload rules rewrite the request model between Fable 5.1 and non-Fable models. +func reconcileClaudeCodeFableModelAfterPayload( + body []byte, + fableState claudeCodeFableState, + payloadTouchedFallbacks bool, + payloadTouchedDisplay bool, + payloadTouchedSystem bool, + cloaked bool, + isProbeOrHelper bool, +) []byte { + if !cloaked || len(body) == 0 { + return body + } + + // Probes and helpers must never carry Fable additions (Opus fallback, display=updates, reporting block) + if isProbeOrHelper { + if fableState.injectedFallbacks && !payloadTouchedFallbacks { + body, _ = sjson.DeleteBytes(body, "fallbacks") + } + if fableState.injectedDisplay && !payloadTouchedDisplay { + body, _ = sjson.DeleteBytes(body, "thinking.display") + } + if fableState.injectedReporting && !payloadTouchedSystem { + system := gjson.GetBytes(body, "system") + if system.IsArray() { + blocks := make([]string, 0, len(system.Array())) + removed := false + for _, blk := range system.Array() { + if blk.Get("text").String() == claudeCodeFableReportingOutcomes { + removed = true + continue + } + blocks = append(blocks, blk.Raw) + } + if removed { + body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]")) + } + } else if system.String() == claudeCodeFableReportingOutcomes { + body, _ = sjson.DeleteBytes(body, "system") + } + } + return body + } + currentModel := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String())) + + if isClaudeFable51Model(currentModel) { + // Non-Fable rewritten to Fable 5.1 (or original Fable 5.1): attach Fable additions + // unless matching payload rules explicitly configured or filtered them. + if !gjson.GetBytes(body, "fallbacks").Exists() && !payloadTouchedFallbacks { + body, _ = sjson.SetRawBytes(body, "fallbacks", []byte(`[{"model":"claude-opus-5"}]`)) + } + if gjson.GetBytes(body, "thinking").Exists() { + thinkingType := gjson.GetBytes(body, "thinking.type").String() + if thinkingType == "adaptive" && !gjson.GetBytes(body, "thinking.display").Exists() && !payloadTouchedDisplay { + body, _ = sjson.SetBytes(body, "thinking.display", "updates") + } + } + if !hasFableReportingBlock(body) && !payloadTouchedSystem { + system := gjson.GetBytes(body, "system") + if system.IsArray() { + blocks := make([]string, 0, len(system.Array())+1) + for _, blk := range system.Array() { + blocks = append(blocks, blk.Raw) + } + blocks = append(blocks, buildTextBlock(claudeCodeFableReportingOutcomes, nil)) + body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]")) + } + } + return body + } + + // Target model is Non-Fable 5.1: + // Only delete fallbacks if CPA automatically injected it and matching payload rules did NOT explicitly configure/modify it + if fableState.injectedFallbacks && !payloadTouchedFallbacks { + body, _ = sjson.DeleteBytes(body, "fallbacks") + } + if fableState.injectedDisplay && !payloadTouchedDisplay { + body, _ = sjson.DeleteBytes(body, "thinking.display") + } + + // Remove Reporting outcomes ONLY if CPA automatically injected it and matching payload rules did NOT modify system + if fableState.injectedReporting && !payloadTouchedSystem { + system := gjson.GetBytes(body, "system") + if system.IsArray() { + blocks := make([]string, 0, len(system.Array())) + removed := false + for _, blk := range system.Array() { + if blk.Get("text").String() == claudeCodeFableReportingOutcomes { + removed = true + continue + } + blocks = append(blocks, blk.Raw) + } + if removed { + body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]")) + } + } else if system.String() == claudeCodeFableReportingOutcomes { + body, _ = sjson.DeleteBytes(body, "system") + } + } + return body +} + // claudeCodeLocalDate reproduces Claude Code 2.1.220's wcs() helper: // new Date(), local calendar fields, and zero-padded YYYY-MM-DD components. func claudeCodeLocalDate(now time.Time) string { @@ -1026,7 +1226,75 @@ func applyCloaking( billingVersion := helps.DefaultClaudeVersion(cfg) workload := getWorkloadFromContext(ctx) - payload = checkSystemInstructionsWithSigningModeAt(payload, settings.strictMode, cchSigning, billingVersion, "cli", workload, claudeCodeCurrentTime(cfg, auth)) + + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(payload) + isSubagent := false + prevReq := "" + promptID := "" + if !isProbeOrHelper { + incomingHeaders := resolveIncomingClaudeHeaders(ctx, helps.IncomingHeadersFromContext(ctx)) + isSubagent = helps.IsClaudeSubagentRequest(incomingHeaders, payload) + existingPrevReq, existingPromptID := helps.ExtractClaudeBillingTags(payload) + + sessionID := helps.ClaudeSessionIDFromContext(ctx) + if sessionID == "" && auth != nil { + sessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, payload, payload, confirmedClaudeCode) + } + + if sessionID != "" && auth != nil { + credIdentity := claudeDiagnosticsCredentialIdentity(auth) + isNewTurn := helps.IsClaudeNewPromptTurn(payload) + continuityKey, seq, prevMsgID, storedPrevReq, storedPromptID := helps.BeginClaudeContinuity(credIdentity, sessionID, isNewTurn, existingPromptID) + + if existingPromptID != "" { + promptID = existingPromptID + } else { + promptID = storedPromptID + } + if existingPrevReq != "" { + prevReq = existingPrevReq + } else { + prevReq = storedPrevReq + } + + if continuityCtx := helps.ClaudeContinuityContextFromContext(ctx); continuityCtx != nil { + continuityCtx.Key = continuityKey + continuityCtx.Sequence = seq + continuityCtx.PreviousMessageID = prevMsgID + continuityCtx.PreviousRequestID = prevReq + continuityCtx.PromptID = promptID + continuityCtx.Initialized = true + } + } + } + + payload = checkSystemInstructionsWithSigningModeAt( + payload, + settings.strictMode, + cchSigning, + billingVersion, + "cli", + workload, + claudeCodeCurrentTime(cfg, auth), + isSubagent, + prevReq, + promptID, + ) + + // In native Claude Code 2.1.258, claude-fable-5-1 requests carry: + // "fallbacks": [{"model": "claude-opus-5"}] + model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) + if isClaudeFable51Model(model) && !isProbeOrHelper { + if !gjson.GetBytes(payload, "fallbacks").Exists() { + payload, _ = sjson.SetRawBytes(payload, "fallbacks", []byte(`[{"model":"claude-opus-5"}]`)) + } + if gjson.GetBytes(payload, "thinking").Exists() { + thinkingType := gjson.GetBytes(payload, "thinking.type").String() + if thinkingType == "adaptive" && !gjson.GetBytes(payload, "thinking.display").Exists() { + payload, _ = sjson.SetBytes(payload, "thinking.display", "updates") + } + } + } // Claude-Code-CLI fingerprint identity (real OAuth or fingerprint-profile=claude-code-cli) // is applied later through the shared ApplyClaudeCredentialMetadata path. diff --git a/internal/runtime/executor/claude_executor_diagnostics.go b/internal/runtime/executor/claude_executor_diagnostics.go index cc81ccb171..81910bcd8b 100644 --- a/internal/runtime/executor/claude_executor_diagnostics.go +++ b/internal/runtime/executor/claude_executor_diagnostics.go @@ -14,13 +14,22 @@ import ( type claudeDiagnosticsRequestState struct { key string sequence uint64 + promptID string } func injectClaudeDiagnostics(body []byte, auth *cliproxyauth.Auth, sessionID string) ([]byte, claudeDiagnosticsRequestState) { - key, sequence, previousMessageID := helps.BeginClaudeDiagnostics(claudeDiagnosticsCredentialIdentity(auth), sessionID) + key, sequence, previousMessageID, _, promptID := helps.BeginClaudeContinuity(claudeDiagnosticsCredentialIdentity(auth), sessionID, false, "") + return injectClaudeDiagnosticsWithState(body, key, sequence, previousMessageID, promptID) +} + +func injectClaudeDiagnosticsWithState(body []byte, key string, sequence uint64, previousMessageID string, promptIDs ...string) ([]byte, claudeDiagnosticsRequestState) { if key == "" { return body, claudeDiagnosticsRequestState{} } + promptID := "" + if len(promptIDs) > 0 { + promptID = promptIDs[0] + } value := `{"previous_message_id":null}` if previousMessageID != "" { value = `{"previous_message_id":` + marshalJSONStringWithoutHTMLEscape(previousMessageID) + `}` @@ -29,7 +38,7 @@ func injectClaudeDiagnostics(body []byte, auth *cliproxyauth.Auth, sessionID str if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.Exists() { updated, errSet := sjson.SetRawBytes(body, "diagnostics", []byte(value)) if errSet == nil { - return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence} + return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence, promptID: promptID} } } if contextManagement := gjson.GetBytes(body, "context_management"); contextManagement.Exists() { @@ -41,14 +50,22 @@ func injectClaudeDiagnostics(body []byte, auth *cliproxyauth.Auth, sessionID str updated = append(updated, `,"diagnostics":`...) updated = append(updated, value...) updated = append(updated, body[insertAt:]...) - return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence} + return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence, promptID: promptID} } } updated, errSet := sjson.SetRawBytes(body, "diagnostics", []byte(value)) if errSet != nil { return body, claudeDiagnosticsRequestState{} } - return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence} + return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence, promptID: promptID} +} + +func commitClaudeContinuity(state claudeDiagnosticsRequestState, messageID, requestID string) { + helps.CommitClaudeContinuity(state.key, state.sequence, messageID, requestID, state.promptID) +} + +func commitClaudeDiagnostics(state claudeDiagnosticsRequestState, messageID string) { + commitClaudeContinuity(state, messageID, "") } func claudeDiagnosticsCredentialIdentity(auth *cliproxyauth.Auth) string { @@ -71,10 +88,6 @@ func claudeDiagnosticsCredentialIdentity(auth *cliproxyauth.Auth) string { return "" } -func commitClaudeDiagnostics(state claudeDiagnosticsRequestState, messageID string) { - helps.CommitClaudeDiagnostics(state.key, state.sequence, messageID) -} - func claudeMessageIDFromResponse(data []byte) string { return strings.TrimSpace(gjson.GetBytes(data, "id").String()) } diff --git a/internal/runtime/executor/claude_executor_diagnostics_test.go b/internal/runtime/executor/claude_executor_diagnostics_test.go index 891a9c1b89..b58fb78278 100644 --- a/internal/runtime/executor/claude_executor_diagnostics_test.go +++ b/internal/runtime/executor/claude_executor_diagnostics_test.go @@ -3,6 +3,7 @@ package executor import ( "bytes" "context" + "fmt" "io" "net/http" "strings" @@ -109,3 +110,157 @@ func TestClaudeMessageIDFromSSECommitsOnlyCompletedMessage(t *testing.T) { t.Fatalf("incomplete SSE message ID = %q, want empty", got) } } + +func TestClaudeExecutorContinuityAdvancesRequestIDAndPromptIDInBillingHeader(t *testing.T) { + var capturedBillingHeaders []string + call := 0 + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + body, errRead := io.ReadAll(req.Body) + if errRead != nil { + t.Fatal(errRead) + } + billing := gjson.GetBytes(body, "system.0.text").String() + capturedBillingHeaders = append(capturedBillingHeaders, billing) + call++ + response := `{"id":"msg_turn_` + string(rune('0'+call)) + `","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}` + header := http.Header{ + "Content-Type": []string{"application/json"}, + "request-id": []string{fmt.Sprintf("req_upstream_turn_%d", call)}, + } + return &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(response)), Request: req}, nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + deviceIDs := []string{"0000000000000000000000000000000000000000000000000000000000000000"} + testID := uuid.NewString() + auth := &cliproxyauth.Auth{ + ID: "continuity-test-" + testID, + Attributes: map[string]string{"api_key": "sk-ant-oat-continuity-test"}, + Metadata: map[string]any{ + "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + claudeauth.ClaudeDeviceIDsMetadataKey: deviceIDs, + }, + } + executor := NewClaudeExecutor(&config.Config{}) + options := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "continuity-conv-" + testID}, + } + + // Turn 1: User prompt + req1 := cliproxyexecutor.Request{Model: "claude-sonnet-5", Payload: []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"turn 1 prompt"}],"max_tokens":100}`)} + if _, err := executor.Execute(ctx, auth, req1, options); err != nil { + t.Fatalf("turn 1 failed: %v", err) + } + + // Turn 2: User prompt in same conversation + req2 := cliproxyexecutor.Request{Model: "claude-sonnet-5", Payload: []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"turn 1 prompt"},{"role":"assistant","content":"ok"},{"role":"user","content":"turn 2 prompt"}],"max_tokens":100}`)} + if _, err := executor.Execute(ctx, auth, req2, options); err != nil { + t.Fatalf("turn 2 failed: %v", err) + } + + // Turn 2.1: Tool result continuation within turn 2 + req2Tool := cliproxyexecutor.Request{Model: "claude-sonnet-5", Payload: []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"turn 1 prompt"},{"role":"assistant","content":"ok"},{"role":"user","content":"turn 2 prompt"},{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"bash","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"tool output"}]}],"max_tokens":100}`)} + if _, err := executor.Execute(ctx, auth, req2Tool, options); err != nil { + t.Fatalf("turn 2.1 tool failed: %v", err) + } + + // Turn 3: Probe request (max_tokens: 1) + reqProbe := cliproxyexecutor.Request{Model: "claude-sonnet-5", Payload: []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"probe"}],"max_tokens":1}`)} + if _, err := executor.Execute(ctx, auth, reqProbe, options); err != nil { + t.Fatalf("probe failed: %v", err) + } + + // Turn 4: Subagent request (carrying X-Claude-Code-Agent-Id) + subagentOptions := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: http.Header{"X-Claude-Code-Agent-Id": []string{"subagent-worker-1"}}, + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "continuity-subagent-" + testID}, + } + reqSubagent := cliproxyexecutor.Request{Model: "claude-sonnet-5", Payload: []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"subagent prompt"}],"max_tokens":100}`)} + if _, err := executor.Execute(ctx, auth, reqSubagent, subagentOptions); err != nil { + t.Fatalf("subagent failed: %v", err) + } + + // Turn 5: Resumed main session user prompt after probe (must chain from turn 2.1, NOT from probe turn 3) + req5 := cliproxyexecutor.Request{Model: "claude-sonnet-5", Payload: []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"turn 5 prompt"}],"max_tokens":100}`)} + if _, err := executor.Execute(ctx, auth, req5, options); err != nil { + t.Fatalf("turn 5 failed: %v", err) + } + + if len(capturedBillingHeaders) != 6 { + t.Fatalf("captured %d billing headers, want 6", len(capturedBillingHeaders)) + } + + // Verify Turn 1: + h1 := capturedBillingHeaders[0] + if !strings.Contains(h1, "cc_version=2.1.258.") || !strings.Contains(h1, "cc_entrypoint=cli;") || !strings.Contains(h1, "cch=") { + t.Fatalf("h1 invalid: %s", h1) + } + if strings.Contains(h1, "cc_prev_req=") { + t.Fatalf("h1 must not contain cc_prev_req: %s", h1) + } + if !strings.Contains(h1, "cc_prompt_id=") { + t.Fatalf("h1 must contain cc_prompt_id: %s", h1) + } + prompt1 := extractTag(h1, "cc_prompt_id=") + + // Verify Turn 2: + h2 := capturedBillingHeaders[1] + if !strings.Contains(h2, "cc_prev_req=req_upstream_turn_1;") { + t.Fatalf("h2 must contain cc_prev_req=req_upstream_turn_1;, got: %s", h2) + } + if !strings.Contains(h2, "cc_prompt_id=") { + t.Fatalf("h2 must contain cc_prompt_id: %s", h2) + } + prompt2 := extractTag(h2, "cc_prompt_id=") + if prompt2 == prompt1 { + t.Fatalf("h2 promptID (%s) must differ from h1 promptID (%s)", prompt2, prompt1) + } + + // Verify Turn 2.1 (tool continuation): + h2Tool := capturedBillingHeaders[2] + if !strings.Contains(h2Tool, "cc_prev_req=req_upstream_turn_2;") { + t.Fatalf("h2Tool must contain cc_prev_req=req_upstream_turn_2;, got: %s", h2Tool) + } + prompt2Tool := extractTag(h2Tool, "cc_prompt_id=") + if prompt2Tool != prompt2 { + t.Fatalf("h2Tool promptID (%s) must match turn 2 promptID (%s)", prompt2Tool, prompt2) + } + + // Verify Turn 3 (probe with max_tokens: 1): + hProbe := capturedBillingHeaders[3] + if strings.Contains(hProbe, "cc_prev_req=") || strings.Contains(hProbe, "cc_prompt_id=") { + t.Fatalf("probe must not contain cc_prev_req or cc_prompt_id: %s", hProbe) + } + + // Verify Turn 4 (subagent): + hSubagent := capturedBillingHeaders[4] + if !strings.Contains(hSubagent, "cc_is_subagent=true;") { + t.Fatalf("hSubagent must contain cc_is_subagent=true;, got: %s", hSubagent) + } + if !strings.Contains(hSubagent, "cc_prompt_id=") { + t.Fatalf("hSubagent must contain cc_prompt_id: %s", hSubagent) + } + + // Verify Turn 5 (main turn after probe): + // Must chain to turn 2.1's response (req_upstream_turn_3), bypassing probe turn 3 (req_upstream_turn_4)! + h5 := capturedBillingHeaders[5] + if !strings.Contains(h5, "cc_prev_req=req_upstream_turn_3;") { + t.Fatalf("h5 must contain cc_prev_req=req_upstream_turn_3; (bypassing probe turn 4), got: %s", h5) + } + if !strings.Contains(h5, "cc_prompt_id=") { + t.Fatalf("h5 must contain cc_prompt_id: %s", h5) + } +} + +func extractTag(header, prefix string) string { + idx := strings.Index(header, prefix) + if idx < 0 { + return "" + } + val := header[idx+len(prefix):] + if end := strings.IndexByte(val, ';'); end >= 0 { + val = val[:end] + } + return val +} diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 372b432dbe..22ca324e72 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -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) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { @@ -62,6 +63,14 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } + + continuityCtx := &helps.ClaudeContinuityContext{} + ctx = helps.WithClaudeContinuityContext(ctx, continuityCtx) + ctx = helps.WithIncomingHeaders(ctx, incomingHeaders) + if claudeSessionID != "" { + ctx = helps.WithClaudeSessionID(ctx, claudeSessionID) + } + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, upstreamStream, helps.APIKeyModelIsCompat(req)) body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, upstreamStream, helps.APIKeyModelIsCompat(req)) body = helps.SetStringIfDifferent(body, "model", upstreamModel) @@ -77,6 +86,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. bodyBeforeCloaking := body + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) var cloaked bool body, cloaked, err = applyCloaking( ctx, @@ -91,24 +101,105 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r return resp, err } 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. diagnosticsState := claudeDiagnosticsRequestState{} + if !isProbeOrHelper { + isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) + } + if continuityCtx.Initialized { + diagnosticsState = claudeDiagnosticsRequestState{ + key: continuityCtx.Key, + sequence: continuityCtx.Sequence, + promptID: continuityCtx.PromptID, + } + } contextManagementState := claudeCodeContextManagementState{ eligible: cloaked && isAnthropicUpstreamBase(baseURL), callerOwned: gjson.GetBytes(body, "context_management").Exists(), } + diagnosticsInjectedByCPA := false if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) - if fp.InjectDiagnostics { - body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) + if fp.InjectDiagnostics && !isProbeOrHelper { + diagnosticsInjectedByCPA = true + if continuityCtx.Initialized { + body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID) + } else { + body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) + } } } requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body, contextManagementState.payloadRuleTouched = helps.ApplyPayloadConfigWithRequestTracked(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers, "context_management") + var touchedPayloadPaths map[string]bool + body, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( + e.cfg, + baseModel, + to.String(), + from.String(), + "", + body, + originalTranslated, + requestedModel, + requestPath, + opts.Headers, + "context_management", + "fallbacks", + "thinking.display", + "system", + ) + contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) + wasProbeOrHelper := isProbeOrHelper + isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) + if isProbeOrHelper { + diagnosticsState = claudeDiagnosticsRequestState{} + if diagnosticsInjectedByCPA { + body, _ = sjson.DeleteBytes(body, "diagnostics") + } + body = helps.StripClaudeBillingTags(body) + if continuityCtx != nil { + *continuityCtx = helps.ClaudeContinuityContext{} + } + } else if wasProbeOrHelper { + // Declassified as probe (e.g. payload override changed max_tokens: 1 to normal request): + // Initialize continuity and diagnostics if cloaked and eligible. + if cloaked { + sessionID := helps.ClaudeSessionIDFromContext(ctx) + if sessionID == "" && auth != nil { + sessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, body, body, confirmedClaudeCode) + } + if sessionID != "" && auth != nil { + credIdentity := claudeDiagnosticsCredentialIdentity(auth) + isNewTurn := helps.IsClaudeNewPromptTurn(body) + continuityKey, seq, prevMsgID, storedPrevReq, storedPromptID := helps.BeginClaudeContinuity(credIdentity, sessionID, isNewTurn, "") + if continuityCtx != nil { + continuityCtx.Key = continuityKey + continuityCtx.Sequence = seq + continuityCtx.PreviousMessageID = prevMsgID + continuityCtx.PreviousRequestID = storedPrevReq + continuityCtx.PromptID = storedPromptID + continuityCtx.Initialized = true + } + body = helps.InjectClaudeBillingTags(body, storedPrevReq, storedPromptID) + if fp.InjectDiagnostics && isAnthropicUpstreamBase(baseURL) { + body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityKey, seq, prevMsgID, storedPromptID) + } + } + } + } + body = reconcileClaudeCodeFableModelAfterPayload( + body, + fableState, + touchedPayloadPaths["fallbacks"], + touchedPayloadPaths["thinking.display"], + touchedPayloadPaths["system"], + cloaked, + isProbeOrHelper, + ) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -143,7 +234,10 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Only a ttl the caller wrote out explicitly survives, because // upgradeClaudeCacheControlTTL skips any block that already has one. // claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool. - if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI { + // In native Claude Code 2.1.258, 1h cache and extended-cache-ttl are restricted to main + // interaction queries (repl_main_thread*); subagents, side queries, and probes omit both. + isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && !isSubagent && !isProbeOrHelper { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) } @@ -291,7 +385,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r helps.RecordAPIResponseError(ctx, e.cfg, errValidate) return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errValidate) } - commitClaudeDiagnostics(diagnosticsState, claudeMessageIDFromSSE(data)) + if msgID := claudeMessageIDFromSSE(data); msgID != "" { + commitClaudeContinuity(diagnosticsState, msgID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) + } lines := bytes.Split(data, []byte("\n")) for i, line := range lines { if detail, ok := helps.ParseClaudeStreamUsage(line); ok { @@ -307,7 +403,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } data = bytes.Join(lines, []byte("\n")) } else { - commitClaudeDiagnostics(diagnosticsState, claudeMessageIDFromResponse(data)) + commitClaudeContinuity(diagnosticsState, claudeMessageIDFromResponse(data), helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) reporter.Publish(ctx, helps.ParseClaudeUsage(data)) var errRestore error data, errRestore = restoreClaudeOAuthToolNamesFromResponse(data, oauthToolNamesReverseMap) diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index aef014adee..77c635b1b9 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -34,24 +34,25 @@ import ( ) const ( - 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" - claudeAdvisorToolBeta = "advisor-tool-2026-03-01" - 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" - claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" - claudeRedactThinkingBeta = "redact-thinking-2026-02-12" + 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" + claudeAdvisorToolBeta = "advisor-tool-2026-03-01" + 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" + claudeThinkingDisplayUpdatesBeta = "thinking-display-updates-2026-08-18" + claudeExtendedCacheTTLBeta = "extended-cache-ttl-2025-04-11" + claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" + claudeRedactThinkingBeta = "redact-thinking-2026-02-12" ) -// claudeCodeCLIConstantBetas are the betas Claude Code 2.1.220 sends on every +// claudeCodeCLIConstantBetas are the betas Claude Code sends on every // /v1/messages request from the "cli" entrypoint, in wire order, excluding the // leading claude-code-20250219. // @@ -76,12 +77,11 @@ var claudeCodeTrailingBetas = []string{ } // 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 +// 2.1.258 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 against api.anthropic.com with isolated 2.1.220 profiles on both -// API-key and OAuth paths. A 2026-08-03 A/B capture with two distinct OAuth -// accounts confirmed the current tool beta and OAuth trailer below. +// Verified against api.anthropic.com with native 2.1.258 captures on interactive, +// non-interactive, subagent, and multi-model paths (Sonnet, Opus, Fable, Haiku). // The full observed order is: // // 1 claude-code-20250219 @@ -95,17 +95,19 @@ var claudeCodeTrailingBetas = []string{ // 9 mid-conversation-system-2026-04-07 models accepting a role=system turn // 10 advisor-tool-2026-03-01 requests declaring advisor tools or requesting advisor beta // 11 advanced-tool-use-2025-11-20 requests with tools -// 12 effort-2025-11-24 -// 13 server-side-fallback-2026-06-01 -// 14 fallback-credit-2026-06-01 -// 15 fast-mode-2026-02-01 speed:fast requests only -// 16 extended-cache-ttl-2025-04-11 OAuth credentials only -// 17 cache-diagnosis-2026-04-07 requests with diagnostics only +// 12 effort-2025-11-24 effort-supporting models with active thinking +// 13 server-side-fallback-2026-06-01 requests with fallbacks or requested +// 14 fallback-credit-2026-06-01 OAuth credentials +// 15 structured-outputs-2025-12-15 structured output requests +// 16 thinking-display-updates-2026-08-18 requests with thinking.display=updates +// 17 fast-mode-2026-02-01 speed:fast requests only +// 18 extended-cache-ttl-2025-04-11 OAuth credentials (omitted on subagent & probe) +// 19 cache-diagnosis-2026-04-07 requests with diagnostics only // // 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)+7) + betas := make([]string, 0, len(claudeCodeCLIConstantBetas)+len(claudeCodeTrailingBetas)+8) betas = append(betas, claudeCodeBeta) if oauthToken { betas = append(betas, claudeOAuthBeta) @@ -129,19 +131,30 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { betas = append(betas, claudeAdvancedToolUseBeta) } - betas = append(betas, claudeEffortBeta) - if oauthToken && !requested[claudeFallbackCreditBeta] { + if claudeRequestSupportsEffort(body, requested) { + betas = append(betas, claudeEffortBeta) + } + if requested[claudeServerSideFallbackBeta] || gjson.GetBytes(body, "fallbacks").Exists() { + betas = append(betas, claudeServerSideFallbackBeta) + } + if requested[claudeFallbackCreditBeta] || oauthToken { betas = append(betas, claudeFallbackCreditBeta) } for _, beta := range claudeCodeTrailingBetas { + if beta == claudeServerSideFallbackBeta || beta == claudeFallbackCreditBeta { + continue + } if requested[beta] { betas = append(betas, beta) } } + if requested[claudeThinkingDisplayUpdatesBeta] || claudeThinkingDisplayUpdates(body) { + betas = append(betas, claudeThinkingDisplayUpdatesBeta) + } if claudeRequestUsesFastMode(body, requested) { betas = append(betas, claudeFastModeBeta) } - if oauthToken { + if oauthToken && !helps.IsClaudeSubagentRequest(nil, body) && !helps.IsClaudeProbeOrHelperRequest(body) { betas = append(betas, claudeExtendedCacheTTLBeta) } if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.IsObject() { @@ -150,6 +163,36 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) return strings.Join(betas, ",") } +func isClaudeHaikuModel(model string) bool { + return strings.Contains(strings.ToLower(model), "haiku") +} + +func claudeRequestSupportsEffort(body []byte, requested map[string]bool) bool { + if requested[claudeEffortBeta] { + return true + } + if len(body) == 0 { + return true + } + if helps.IsClaudeProbeOrHelperRequest(body) { + return false + } + model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String())) + if isClaudeHaikuModel(model) { + return false + } + thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) + if thinkingType == "disabled" { + return false + } + return true +} + +func claudeThinkingDisplayUpdates(body []byte) bool { + display := gjson.GetBytes(body, "thinking.display") + return display.Type == gjson.String && strings.EqualFold(strings.TrimSpace(display.String()), "updates") +} + // claudeBodyHasAdvisorTool reports whether the request body declares an // advisor server tool. func claudeBodyHasAdvisorTool(body []byte) bool { @@ -250,7 +293,7 @@ func withClaudeCountTokensOAuthBeta(betas string) string { // be described accurately. // // Betas already present are left exactly where the caller put them. -func withClaudeOAuthCredentialBetas(betas string) string { +func withClaudeOAuthCredentialBetas(betas string, includeExtendedCacheTTL bool) string { parts := make([]string, 0, 16) seen := make(map[string]bool) for _, beta := range strings.Split(betas, ",") { @@ -269,7 +312,7 @@ func withClaudeOAuthCredentialBetas(betas string) string { copy(parts[insertAt+1:], parts[insertAt:]) parts[insertAt] = claudeOAuthBeta } - if !seen[claudeExtendedCacheTTLBeta] { + if includeExtendedCacheTTL && !seen[claudeExtendedCacheTTLBeta] { parts = append(parts, claudeExtendedCacheTTLBeta) } return strings.Join(parts, ",") @@ -806,11 +849,15 @@ func applyClaudeHeadersWithNativeProfile( } // Measured Haiku helper requests already carry the exact credential // beta profile and intentionally omit extended-cache-ttl. + // Native Claude Code subagents and probes also omit extended-cache-ttl. if useOAuthBetas && !helperProfile { if countTokens { baseBetas = withClaudeCountTokensOAuthBeta(baseBetas) } else { - baseBetas = withClaudeOAuthCredentialBetas(baseBetas) + isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) + isProbe := helps.IsClaudeProbeOrHelperRequest(body) + includeExtendedCacheTTL := !isSubagent && !isProbe + baseBetas = withClaudeOAuthCredentialBetas(baseBetas, includeExtendedCacheTTL) } } } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 865b3bdad4..f5657298f7 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -16,6 +16,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) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { @@ -65,6 +66,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if fp.ProfileClaudeCodeCLI { claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata) } + + continuityCtx := &helps.ClaudeContinuityContext{} + ctx = helps.WithClaudeContinuityContext(ctx, continuityCtx) + ctx = helps.WithIncomingHeaders(ctx, incomingHeaders) + if claudeSessionID != "" { + ctx = helps.WithClaudeSessionID(ctx, claudeSessionID) + } + originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true, helps.APIKeyModelIsCompat(req)) body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, true, helps.APIKeyModelIsCompat(req)) body = helps.SetStringIfDifferent(body, "model", upstreamModel) @@ -80,6 +89,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation) // based on client type and configuration. bodyBeforeCloaking := body + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) var cloaked bool body, cloaked, err = applyCloaking( ctx, @@ -94,24 +104,105 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return nil, err } 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. diagnosticsState := claudeDiagnosticsRequestState{} + if !isProbeOrHelper { + isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) + } + if continuityCtx.Initialized { + diagnosticsState = claudeDiagnosticsRequestState{ + key: continuityCtx.Key, + sequence: continuityCtx.Sequence, + promptID: continuityCtx.PromptID, + } + } contextManagementState := claudeCodeContextManagementState{ eligible: cloaked && isAnthropicUpstreamBase(baseURL), callerOwned: gjson.GetBytes(body, "context_management").Exists(), } + diagnosticsInjectedByCPA := false if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) - if fp.InjectDiagnostics { - body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) + if fp.InjectDiagnostics && !isProbeOrHelper { + diagnosticsInjectedByCPA = true + if continuityCtx.Initialized { + body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID) + } else { + body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID) + } } } requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body, contextManagementState.payloadRuleTouched = helps.ApplyPayloadConfigWithRequestTracked(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers, "context_management") + var touchedPayloadPaths map[string]bool + body, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( + e.cfg, + baseModel, + to.String(), + from.String(), + "", + body, + originalTranslated, + requestedModel, + requestPath, + opts.Headers, + "context_management", + "fallbacks", + "thinking.display", + "system", + ) + contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) + wasProbeOrHelper := isProbeOrHelper + isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) + if isProbeOrHelper { + diagnosticsState = claudeDiagnosticsRequestState{} + if diagnosticsInjectedByCPA { + body, _ = sjson.DeleteBytes(body, "diagnostics") + } + body = helps.StripClaudeBillingTags(body) + if continuityCtx != nil { + *continuityCtx = helps.ClaudeContinuityContext{} + } + } else if wasProbeOrHelper { + // Declassified as probe (e.g. payload override changed max_tokens: 1 to normal request): + // Initialize continuity and diagnostics if cloaked and eligible. + if cloaked { + sessionID := helps.ClaudeSessionIDFromContext(ctx) + if sessionID == "" && auth != nil { + sessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, body, body, confirmedClaudeCode) + } + if sessionID != "" && auth != nil { + credIdentity := claudeDiagnosticsCredentialIdentity(auth) + isNewTurn := helps.IsClaudeNewPromptTurn(body) + continuityKey, seq, prevMsgID, storedPrevReq, storedPromptID := helps.BeginClaudeContinuity(credIdentity, sessionID, isNewTurn, "") + if continuityCtx != nil { + continuityCtx.Key = continuityKey + continuityCtx.Sequence = seq + continuityCtx.PreviousMessageID = prevMsgID + continuityCtx.PreviousRequestID = storedPrevReq + continuityCtx.PromptID = storedPromptID + continuityCtx.Initialized = true + } + body = helps.InjectClaudeBillingTags(body, storedPrevReq, storedPromptID) + if fp.InjectDiagnostics && isAnthropicUpstreamBase(baseURL) { + body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityKey, seq, prevMsgID, storedPromptID) + } + } + } + } + body = reconcileClaudeCodeFableModelAfterPayload( + body, + fableState, + touchedPayloadPaths["fallbacks"], + touchedPayloadPaths["thinking.display"], + touchedPayloadPaths["system"], + cloaked, + isProbeOrHelper, + ) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) @@ -144,7 +235,10 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Only a ttl the caller wrote out explicitly survives, because // upgradeClaudeCacheControlTTL skips any block that already has one. // claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool. - if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI { + // In native Claude Code 2.1.258, 1h cache and extended-cache-ttl are restricted to main + // interaction queries (repl_main_thread*); subagents, side queries, and probes omit both. + isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body) + if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && !isSubagent && !isProbeOrHelper { body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h) } @@ -356,7 +450,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return } if upstreamCompleted { - commitClaudeDiagnostics(diagnosticsState, upstreamMessageID) + commitClaudeContinuity(diagnosticsState, upstreamMessageID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) } return } @@ -418,7 +512,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return } if upstreamCompleted { - commitClaudeDiagnostics(diagnosticsState, upstreamMessageID) + commitClaudeContinuity(diagnosticsState, upstreamMessageID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) } }() result := &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out} diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 769b869b2c..f19834f51d 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -4589,6 +4589,1099 @@ func TestApplyCloaking_PreservesConfiguredStrictModeAndSensitiveWordsWhenModeOmi } } +func TestApplyCloaking_FableInjectsFallbacksAndDisplayUpdates(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-fable-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-fable-test"}} + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + + out, cloaked, err := applyCloaking( + context.Background(), + cfg, + auth, + payload, + "sk-ant-oat-fable-test", + false, + true, + ) + if err != nil { + t.Fatalf("applyCloaking() error = %v", err) + } + if !cloaked { + t.Fatal("applyCloaking() cloaked = false, want true") + } + + fallbacks := gjson.GetBytes(out, "fallbacks").Array() + if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != "claude-opus-5" { + t.Fatalf("expected fallbacks=[{\"model\":\"claude-opus-5\"}], got: %s", gjson.GetBytes(out, "fallbacks").Raw) + } + + systemBlocks := gjson.GetBytes(out, "system").Array() + if len(systemBlocks) != 3 { + t.Fatalf("expected 3 system blocks for Fable cloaking (billing, identity, reporting outcomes), got %d", len(systemBlocks)) + } + if !strings.Contains(systemBlocks[2].Get("text").String(), "Reporting outcomes") { + t.Fatalf("expected system block 2 to contain Reporting outcomes, got: %s", systemBlocks[2].Get("text").String()) + } + if systemBlocks[2].Get("cache_control").Exists() { + t.Fatalf("system block 2 for Reporting outcomes should have no cache_control, got: %s", systemBlocks[2].Get("cache_control").Raw) + } + + display := gjson.GetBytes(out, "thinking.display").String() + if display != "updates" { + t.Fatalf("expected thinking.display=updates, got: %q", display) + } + + betas := claudeCodeCLIBetas(out, nil, true) + if !strings.Contains(betas, "server-side-fallback-2026-06-01") { + t.Fatalf("expected server-side-fallback beta in betas, got: %s", betas) + } + if !strings.Contains(betas, "thinking-display-updates-2026-08-18") { + t.Fatalf("expected thinking-display-updates beta in betas, got: %s", betas) + } + if strings.Contains(betas, "redact-thinking-2026-02-12") { + t.Fatalf("expected redact-thinking beta to be dropped when display=updates, got: %s", betas) + } +} + +func TestApplyCloaking_SonnetOmitsReportingOutcomes(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-sonnet-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-sonnet-test"}} + payload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"test"}]}`) + + out, cloaked, err := applyCloaking( + context.Background(), + cfg, + auth, + payload, + "sk-ant-oat-sonnet-test", + false, + true, + ) + if err != nil { + t.Fatalf("applyCloaking() error = %v", err) + } + if !cloaked { + t.Fatal("applyCloaking() cloaked = false, want true") + } + + systemBlocks := gjson.GetBytes(out, "system").Array() + if len(systemBlocks) != 2 { + t.Fatalf("expected 2 system blocks for Sonnet (billing, identity only), got %d", len(systemBlocks)) + } + for i, b := range systemBlocks { + if strings.Contains(b.Get("text").String(), "Reporting outcomes") { + t.Fatalf("system block %d should not contain Reporting outcomes on non-Fable model, got: %s", i, b.Get("text").String()) + } + } +} + +func TestApplyCloaking_DisabledByConfigLeavesFableUntouched(t *testing.T) { + cfg := &config.Config{ + DisableClaudeCloakMode: true, + } + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-fable-test"}} + originalSystem := "You are a custom assistant for my company." + payload := []byte(`{"model":"claude-fable-5-1","system":"` + originalSystem + `","messages":[{"role":"user","content":"test"}]}`) + + out, cloaked, err := applyCloaking( + context.Background(), + cfg, + auth, + payload, + "sk-ant-oat-fable-test", + false, + true, + ) + if err != nil { + t.Fatalf("applyCloaking() error = %v", err) + } + if cloaked { + t.Fatal("applyCloaking() cloaked = true, want false when DisableClaudeCloakMode is true") + } + if got := gjson.GetBytes(out, "system").String(); got != originalSystem { + t.Fatalf("expected system to be preserved unchanged, got: %s", got) + } + if gjson.GetBytes(out, "fallbacks").Exists() { + t.Fatalf("expected no fallbacks injected when cloaking is disabled, got: %s", gjson.GetBytes(out, "fallbacks").Raw) + } +} + +func TestApplyCloaking_NativeClaudeCodeLeavesFableUntouched(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-fable-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-fable-test"}} + originalSystem := "You are a native claude code agent." + payload := []byte(`{"model":"claude-fable-5-1","system":"` + originalSystem + `","messages":[{"role":"user","content":"test"}]}`) + + out, cloaked, err := applyCloaking( + context.Background(), + cfg, + auth, + payload, + "sk-ant-oat-fable-test", + true, // confirmedClaudeCode = true + true, + ) + if err != nil { + t.Fatalf("applyCloaking() error = %v", err) + } + if cloaked { + t.Fatal("applyCloaking() cloaked = true, want false for confirmed native Claude Code") + } + if got := gjson.GetBytes(out, "system").String(); got != originalSystem { + t.Fatalf("expected system to be preserved unchanged for native client, got: %s", got) + } +} + +func TestApplyCloaking_Fable5OmitsFallbacksAndReportingOutcomes(t *testing.T) { + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-fable5-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-fable5-test"}} + payload := []byte(`{"model":"claude-fable-5","messages":[{"role":"user","content":"test"}]}`) + + out, cloaked, err := applyCloaking( + context.Background(), + cfg, + auth, + payload, + "sk-ant-oat-fable5-test", + false, + true, + ) + if err != nil { + t.Fatalf("applyCloaking() error = %v", err) + } + if !cloaked { + t.Fatal("applyCloaking() cloaked = false, want true") + } + + if gjson.GetBytes(out, "fallbacks").Exists() { + t.Fatalf("claude-fable-5 should not have fallbacks injected, got: %s", gjson.GetBytes(out, "fallbacks").Raw) + } + + systemBlocks := gjson.GetBytes(out, "system").Array() + if len(systemBlocks) != 2 { + t.Fatalf("expected 2 system blocks for claude-fable-5, got %d", len(systemBlocks)) + } + for _, b := range systemBlocks { + if strings.Contains(b.Get("text").String(), "Reporting outcomes") { + t.Fatalf("claude-fable-5 should not contain Reporting outcomes, got: %s", b.Get("text").String()) + } + } +} + +func TestClaudeExecutor_TitleHelperWithSystemPromptIsolated(t *testing.T) { + helps.ResetClaudeDiagnosticsForTest() + defer helps.ResetClaudeDiagnosticsForTest() + + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("request-id", "req_helper123") + _, _ = w.Write([]byte(`{"id":"msg_helper123","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"Title"}]}`)) + })) + defer server.Close() + + payload := []byte(`{"model":"claude-sonnet-5","system":"Return a short title summarizing this conversation","messages":[{"role":"user","content":"test"}]}`) + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-title-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-title-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-title-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + ctx := helps.WithClaudeSessionID(context.Background(), "session-title-1") + _, err := executor.Execute(ctx, auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + // Title helper must NOT carry diagnostics + if gjson.GetBytes(seenBody, "diagnostics").Exists() { + t.Fatalf("expected title helper to omit diagnostics, got: %s", gjson.GetBytes(seenBody, "diagnostics").Raw) + } + + // Title helper must NOT advance session continuity state + credID := claudeDiagnosticsCredentialIdentity(auth) + _, _, prevMsg := helps.BeginClaudeDiagnostics(credID, "session-title-1") + if prevMsg != "" { + t.Fatalf("expected title helper to leave prevMsg empty, got: %q", prevMsg) + } +} + +func TestClaudeExecutor_SubagentAndProbeOmit1hCacheTTLAndBeta(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_sub1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-subagent-cache-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-subagent-cache-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-subagent-cache-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + + // 1. Subagent request: carries X-Claude-Code-Agent-Id header + subagentPayload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"do task"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: subagentPayload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: http.Header{ + "X-Claude-Code-Agent-Id": {"subagent-123"}, + }, + }) + if err != nil { + t.Fatalf("Execute(subagent) error = %v", err) + } + if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") { + t.Fatalf("subagent must not carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta")) + } + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if blk.Get("cache_control.ttl").String() == "1h" { + t.Fatalf("subagent system block must not carry ttl: 1h, got: %s", blk.Raw) + } + } + + // 2. Probe request: max_tokens: 1 + probePayload := []byte(`{"model":"claude-sonnet-5","max_tokens":1,"messages":[{"role":"user","content":"probe"}]}`) + _, err = executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: probePayload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + }) + if err != nil { + t.Fatalf("Execute(probe) error = %v", err) + } + if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") { + t.Fatalf("probe must not carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta")) + } + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if blk.Get("cache_control.ttl").String() == "1h" { + t.Fatalf("probe system block must not carry ttl: 1h, got: %s", blk.Raw) + } + } + + // 3. Normal interactive main-thread request: MUST carry 1h cache and beta + mainPayload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"hello"}]}`) + _, err = executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: mainPayload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + }) + if err != nil { + t.Fatalf("Execute(main) error = %v", err) + } + if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") { + t.Fatalf("main thread request must carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta")) + } + has1h := false + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if blk.Get("cache_control.ttl").String() == "1h" { + has1h = true + break + } + } + if !has1h { + t.Fatalf("main thread request system block must carry ttl: 1h, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } + + // 4. Confirmed native Claude Code subagent: must NOT have extended-cache-ttl restored + confirmedSubagentPayload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"task"}]}`) + _, err = executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: confirmedSubagentPayload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: http.Header{ + "User-Agent": {"claude-cli/2.1.258 (external, cli)"}, + "X-Claude-Code-Agent-Id": {"subagent-confirmed-456"}, + "Anthropic-Beta": {"claude-code-20250219,oauth-2025-04-20,effort-2025-11-24"}, + }, + }) + if err != nil { + t.Fatalf("Execute(confirmed subagent) error = %v", err) + } + if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") { + t.Fatalf("confirmed subagent must not carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta")) + } +} + +func TestClaudeExecutor_PayloadOverrideFableModelReconciled(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "model": "claude-sonnet-5", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Model was rewritten to claude-sonnet-5 + if got := gjson.GetBytes(seenBody, "model").String(); got != "claude-sonnet-5" { + t.Fatalf("model = %q, want claude-sonnet-5", got) + } + + // Because model is now claude-sonnet-5, Fable additions must NOT be present: + if gjson.GetBytes(seenBody, "fallbacks").Exists() { + t.Fatalf("fallbacks must be omitted when rewritten to non-Fable, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw) + } + if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") { + t.Fatalf("server-side-fallback beta must be omitted when rewritten to non-Fable, got: %s", seenHeaders.Get("Anthropic-Beta")) + } + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if strings.Contains(blk.Get("text").String(), "Reporting outcomes") { + t.Fatalf("system must not contain Reporting outcomes when rewritten to non-Fable, got: %s", blk.Raw) + } + } +} + +func TestClaudeExecutor_PayloadOverrideNonFableToFableReconciled(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-fable-5-1","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-test-2", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-sonnet-5"}}, + Params: map[string]any{ + "model": "claude-fable-5-1", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-test-2", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-test-2", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-sonnet-5","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Model was rewritten to claude-fable-5-1 + if got := gjson.GetBytes(seenBody, "model").String(); got != "claude-fable-5-1" { + t.Fatalf("model = %q, want claude-fable-5-1", got) + } + + // Fable additions must now be attached: + fallbacks := gjson.GetBytes(seenBody, "fallbacks").Array() + if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != "claude-opus-5" { + t.Fatalf("fallbacks must be injected for Fable 5.1, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw) + } + if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") { + t.Fatalf("server-side-fallback beta must be present, got: %s", seenHeaders.Get("Anthropic-Beta")) + } + hasReporting := false + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if strings.Contains(blk.Get("text").String(), "Reporting outcomes") { + hasReporting = true + break + } + } + if !hasReporting { + t.Fatalf("system must contain Reporting outcomes for Fable 5.1, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } +} + +func TestClaudeExecutor_PayloadOverridePreservesExplicitFallbacks(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-test-3", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "model": "claude-sonnet-5", + "fallbacks": []any{map[string]any{"model": "claude-opus-5"}}, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-test-3", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-test-3", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Explicit payload fallback override must be preserved! + fallbacks := gjson.GetBytes(seenBody, "fallbacks").Array() + if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != "claude-opus-5" { + t.Fatalf("explicit payload fallback override must be preserved, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw) + } + if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") { + t.Fatalf("server-side-fallback beta must be present for explicit fallback, got: %s", seenHeaders.Get("Anthropic-Beta")) + } +} + +func TestClaudeExecutor_PayloadOverrideUnrelatedModelRuleDoesNotPreserveFableFallbacks(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-test-4", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{ + // Rule 1: Unrelated rule for gpt-4 has fallbacks + { + Models: []config.PayloadModelRule{{Name: "gpt-4"}}, + Params: map[string]any{ + "fallbacks": []any{map[string]any{"model": "claude-opus-5"}}, + }, + }, + // Rule 2: Rewrites Fable 5.1 to Sonnet 5 WITHOUT fallbacks + { + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "model": "claude-sonnet-5", + }, + }, + }, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-test-4", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-test-4", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Unrelated rule must NOT preserve fallback on Sonnet 5 + if gjson.GetBytes(seenBody, "fallbacks").Exists() { + t.Fatalf("unrelated rule for gpt-4 must not cause fallbacks to be preserved on sonnet-5, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw) + } + if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") { + t.Fatalf("server-side-fallback beta must be omitted, got: %s", seenHeaders.Get("Anthropic-Beta")) + } +} + +func TestClaudeExecutor_PayloadOverrideMaxTokensTo1ReclassifiesAsProbe(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_probe1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"."}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-probe-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-sonnet-5"}}, + Params: map[string]any{ + "max_tokens": 1, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-probe-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-probe-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-sonnet-5","max_tokens":1000,"messages":[{"role":"user","content":"."}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Payload rule made it max_tokens: 1 + if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 1 { + t.Fatalf("max_tokens = %d, want 1", got) + } + + // Probe must NOT carry 1h cache control and must NOT carry extended-cache-ttl beta + if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") { + t.Fatalf("reclassified probe must not carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta")) + } + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if blk.Get("cache_control.ttl").String() == "1h" { + t.Fatalf("reclassified probe system block must not carry ttl: 1h, got: %s", blk.Raw) + } + } + + // Probe must NOT carry diagnostics + if gjson.GetBytes(seenBody, "diagnostics").Exists() { + t.Fatalf("reclassified probe must omit diagnostics, got: %s", gjson.GetBytes(seenBody, "diagnostics").Raw) + } + + // Probe must NOT carry cc_prev_req or cc_prompt_id in billing header + billingText := gjson.GetBytes(seenBody, "system.0.text").String() + if strings.Contains(billingText, "cc_prev_req=") { + t.Fatalf("reclassified probe must omit cc_prev_req, got: %s", billingText) + } + if strings.Contains(billingText, "cc_prompt_id=") { + t.Fatalf("reclassified probe must omit cc_prompt_id, got: %s", billingText) + } +} + +func TestClaudeExecutor_PayloadOverrideFableToProbeStripsFableAdditions(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-fable-5-1","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-probe-strip-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "max_tokens": 1, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-probe-strip-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-probe-strip-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Initially non-probe (max_tokens: 1000, content: ".") + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"max_tokens":1000,"messages":[{"role":"user","content":"."}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Because payload rule reclassified request as probe (max_tokens: 1): + // 1. fallbacks must be stripped + if gjson.GetBytes(seenBody, "fallbacks").Exists() { + t.Fatalf("probe must not carry fallbacks, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw) + } + // 2. thinking.display must be stripped + if gjson.GetBytes(seenBody, "thinking.display").Exists() { + t.Fatalf("probe must not carry thinking.display, got: %s", gjson.GetBytes(seenBody, "thinking.display").Raw) + } + // 3. Reporting outcomes block must be stripped + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if strings.Contains(blk.Get("text").String(), "Reporting outcomes") { + t.Fatalf("probe must not carry Reporting outcomes block, got: %s", blk.Raw) + } + } + // 4. Beta headers must omit server-side-fallback, thinking-display-updates, and extended-cache-ttl + betas := seenHeaders.Get("Anthropic-Beta") + if strings.Contains(betas, "server-side-fallback") { + t.Fatalf("probe must omit server-side-fallback beta, got: %s", betas) + } + if strings.Contains(betas, "thinking-display-updates") { + t.Fatalf("probe must omit thinking-display-updates beta, got: %s", betas) + } + if strings.Contains(betas, "extended-cache-ttl") { + t.Fatalf("probe must omit extended-cache-ttl beta, got: %s", betas) + } +} + +func TestClaudeExecutor_PayloadOverrideProbeToNormalReinitializes(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-fable-5-1","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-declassify-probe-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "max_tokens": 1000, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-declassify-probe-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-declassify-probe-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Initially a probe (max_tokens: 1, content: ".") + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"max_tokens":1,"messages":[{"role":"user","content":"."}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Declassified probe is now a normal request: + // 1. Must carry 1h cache TTL and extended-cache-ttl beta + has1h := false + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if blk.Get("cache_control.ttl").String() == "1h" { + has1h = true + break + } + } + if !has1h { + t.Fatalf("declassified normal request must carry 1h cache, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } + betas := seenHeaders.Get("Anthropic-Beta") + if !strings.Contains(betas, "extended-cache-ttl-2025-04-11") { + t.Fatalf("declassified normal request must carry extended-cache-ttl beta, got: %s", betas) + } + // 2. Must carry Fable additions (fallbacks, display, reporting block) + if !gjson.GetBytes(seenBody, "fallbacks").Exists() { + t.Fatalf("declassified Fable request must carry fallbacks") + } + + // 3. Must carry cc_prompt_id in billing header + billingText := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.Contains(billingText, "cc_prompt_id=") { + t.Fatalf("declassified normal request must carry cc_prompt_id, got: %s", billingText) + } +} + +func TestClaudeExecutor_CallerOwnedDiagnosticsPreservedOnProbe(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-haiku-4-5-20251001","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-api-caller-diagnostics-test", + Cloak: &config.CloakConfig{Mode: "never"}, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-caller-diagnostics-test", + Attributes: map[string]string{ + "api_key": "sk-ant-api-caller-diagnostics-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Caller sends max_tokens: 1 probe with their own explicit diagnostics object + payload := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"diagnostics":{"caller_custom_key":"val123"},"messages":[{"role":"user","content":"quota"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-haiku-4-5-20251001", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Caller-owned diagnostics must be preserved! + if got := gjson.GetBytes(seenBody, "diagnostics.caller_custom_key").String(); got != "val123" { + t.Fatalf("caller diagnostics must be preserved, got: %s", gjson.GetBytes(seenBody, "diagnostics").Raw) + } +} + +func TestClaudeExecutor_PayloadOverrideParentThinkingPreventsDisplayUpdates(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-fable-5-1","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-parent-thinking-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "thinking": map[string]any{ + "type": "adaptive", + }, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-parent-thinking-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-parent-thinking-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Payload rule replaced parent "thinking" without "display", so "display" must NOT be re-added! + if gjson.GetBytes(seenBody, "thinking.display").Exists() { + t.Fatalf("thinking.display must not be injected when parent thinking was overridden, got: %s", gjson.GetBytes(seenBody, "thinking").Raw) + } +} + +func TestClaudeExecutor_PayloadOverrideRawPreservesExplicitFallbacks(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-test-5", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + OverrideRaw: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "model": `"claude-sonnet-5"`, + "fallbacks": `[{"model":"claude-opus-5"}]`, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-test-5", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-test-5", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // Raw payload override setting fallbacks must be preserved! + fallbacks := gjson.GetBytes(seenBody, "fallbacks").Array() + if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != "claude-opus-5" { + t.Fatalf("explicit raw payload fallback override must be preserved, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw) + } + if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") { + t.Fatalf("server-side-fallback beta must be present for explicit raw fallback, got: %s", seenHeaders.Get("Anthropic-Beta")) + } +} + +func TestClaudeExecutor_PayloadFilterFallbacksPreservesRemovalOnFable(t *testing.T) { + var seenBody []byte + var seenHeaders http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + seenHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-fable-5-1","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-filter-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Filter: []config.PayloadFilterRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: []string{"fallbacks"}, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-filter-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-filter-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + // fallbacks was filtered out by operator rule, must NOT be recreated! + if gjson.GetBytes(seenBody, "fallbacks").Exists() { + t.Fatalf("fallbacks must remain deleted after payload filter, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw) + } + if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") { + t.Fatalf("server-side-fallback beta must be absent when fallbacks is filtered, got: %s", seenHeaders.Get("Anthropic-Beta")) + } +} + +func TestClaudeExecutor_PayloadCustomReportingOutcomesPreservedOnRewrite(t *testing.T) { + var seenBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`)) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-payload-fable-custom-reporting-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "model": "claude-sonnet-5", + "system": []map[string]any{ + {"type": "text", "text": "Custom user prompt containing Reporting outcomes heading in discussion."}, + }, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-fable-custom-reporting-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-fable-custom-reporting-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-fable-5-1","messages":[{"role":"user","content":"test"}]}`) + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-fable-5-1", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("Execute error = %v", err) + } + + system := gjson.GetBytes(seenBody, "system").Array() + hasCustom := false + for _, blk := range system { + if strings.Contains(blk.Get("text").String(), "Custom user prompt") { + hasCustom = true + } + } + if !hasCustom { + t.Fatalf("custom user system prompt must NOT be deleted, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } +} + func TestNormalizeClaudeSamplingForUpstream_RemovesTemperature(t *testing.T) { payload := []byte(`{"temperature":0,"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`) out := normalizeClaudeSamplingForUpstream(payload, false) @@ -5542,9 +6635,9 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { want: constants + ",effort-2025-11-24", }, { - name: "claude-haiku-4-5-20251001 stays on the reminder path", + name: "claude-haiku-4-5-20251001 stays on the reminder path and omits effort", body: `{"model":"claude-haiku-4-5-20251001"}`, - want: constants + ",effort-2025-11-24", + want: constants, }, { name: "legacy model with tools adds advanced tool use only", @@ -5607,6 +6700,39 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { body: `{"model":"claude-opus-5","tools":[{"type":"advisor_20260301","name":"advisor"}]}`, want: constants + ",mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24", }, + { + name: "thinking display updates emits thinking-display-updates beta and drops redact-thinking", + body: `{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"updates"}}`, + want: "claude-code-20250219,interleaved-thinking-2025-05-14," + + "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,thinking-display-updates-2026-08-18", + }, + { + name: "body with fallbacks automatically adds server-side-fallback beta", + body: `{"model":"claude-fable-5-1","fallbacks":[{"model":"claude-opus-5"}]}`, + want: constants + ",mid-conversation-system-2026-04-07,effort-2025-11-24,server-side-fallback-2026-06-01", + }, + { + name: "subagent request omits extended-cache-ttl beta", + body: `{"model":"claude-sonnet-5","system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.258.0ab; cc_is_subagent=true;"}]}`, + 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,mid-conversation-system-2026-04-07," + + "effort-2025-11-24,fallback-credit-2026-06-01", + }, + { + name: "probe request max_tokens=1 omits effort and extended-cache-ttl betas", + body: `{"model":"claude-sonnet-5","max_tokens":1}`, + 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,mid-conversation-system-2026-04-07," + + "fallback-credit-2026-06-01", + }, } for _, tt := range tests { diff --git a/internal/runtime/executor/helps/claude_diagnostics.go b/internal/runtime/executor/helps/claude_diagnostics.go index d1d0a99e0f..796ff5c942 100644 --- a/internal/runtime/executor/helps/claude_diagnostics.go +++ b/internal/runtime/executor/helps/claude_diagnostics.go @@ -1,12 +1,19 @@ package helps import ( + "context" "crypto/sha256" "encoding/hex" + "net/http" + "regexp" "sort" "strings" "sync" "time" + + "github.com/google/uuid" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) const ( @@ -16,8 +23,90 @@ const ( claudeDiagnosticsEvictBatchSize = 256 ) +var ( + claudeRequestIDPattern = regexp.MustCompile(`^req_[A-Za-z0-9_-]{1,36}$`) + claudePromptIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) +) + +type claudeContextKey string + +const ( + claudeSessionIDContextKey claudeContextKey = "cpa_claude_session_id" + claudeContinuityContextKey claudeContextKey = "cpa_claude_continuity_ctx" + claudeIncomingHeadersContextKey claudeContextKey = "cpa_claude_incoming_headers" +) + +// WithIncomingHeaders attaches incoming HTTP headers to ctx. +func WithIncomingHeaders(ctx context.Context, headers http.Header) context.Context { + if headers == nil { + return ctx + } + return context.WithValue(ctx, claudeIncomingHeadersContextKey, headers) +} + +// IncomingHeadersFromContext retrieves incoming HTTP headers from ctx, if present. +func IncomingHeadersFromContext(ctx context.Context) http.Header { + if ctx == nil { + return nil + } + if h, ok := ctx.Value(claudeIncomingHeadersContextKey).(http.Header); ok { + return h + } + return nil +} + +// ClaudeContinuityContext holds request-scoped continuity state across cloaking and execution. +type ClaudeContinuityContext struct { + Key string + Sequence uint64 + PreviousMessageID string + PreviousRequestID string + PromptID string + Initialized bool +} + +// WithClaudeSessionID attaches a known Claude session ID to ctx. +func WithClaudeSessionID(ctx context.Context, sessionID string) context.Context { + if sessionID == "" { + return ctx + } + return context.WithValue(ctx, claudeSessionIDContextKey, sessionID) +} + +// ClaudeSessionIDFromContext retrieves the Claude session ID from ctx, if present. +func ClaudeSessionIDFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if val, ok := ctx.Value(claudeSessionIDContextKey).(string); ok { + return val + } + return "" +} + +// WithClaudeContinuityContext attaches a mutable ClaudeContinuityContext to ctx. +func WithClaudeContinuityContext(ctx context.Context, cc *ClaudeContinuityContext) context.Context { + if cc == nil { + return ctx + } + return context.WithValue(ctx, claudeContinuityContextKey, cc) +} + +// ClaudeContinuityContextFromContext retrieves the ClaudeContinuityContext from ctx, if present. +func ClaudeContinuityContextFromContext(ctx context.Context) *ClaudeContinuityContext { + if ctx == nil { + return nil + } + if cc, ok := ctx.Value(claudeContinuityContextKey).(*ClaudeContinuityContext); ok { + return cc + } + return nil +} + type claudeDiagnosticsEntry struct { previousMessageID string + previousRequestID string + promptID string minimumSequence uint64 committedSequence uint64 lastAccess uint64 @@ -32,16 +121,26 @@ var claudeDiagnosticsState = struct { nextAccess uint64 }{entries: make(map[string]claudeDiagnosticsEntry)} -// BeginClaudeDiagnostics starts one request generation for a stable credential -// identity and Claude conversation. It returns the last successfully completed -// upstream message ID, if any. Only a SHA-256 digest of the credential identity -// and session is retained as the cache key, so access-token rotation does not -// interrupt continuity. -func BeginClaudeDiagnostics(credentialIdentity, sessionID string) (key string, sequence uint64, previousMessageID string) { +// IsValidClaudePromptID verifies whether an explicit prompt ID adheres to strict RFC 4122 UUIDv4 semantics. +func IsValidClaudePromptID(id string) bool { + id = strings.TrimSpace(id) + if !claudePromptIDPattern.MatchString(id) { + return false + } + parsed, err := uuid.Parse(id) + return err == nil && parsed.Version() == 4 && parsed.Variant() == uuid.RFC4122 +} + +// BeginClaudeContinuity starts one request generation for a stable credential +// identity and Claude conversation. It tracks the previous upstream message ID, +// previous upstream request ID (cc_prev_req), and active prompt ID (cc_prompt_id). +// If explicitPromptID is provided and valid, it is adopted. Otherwise if isNewPromptTurn +// is true (or if no prompt ID exists), a fresh UUIDv4 is generated. +func BeginClaudeContinuity(credentialIdentity, sessionID string, isNewPromptTurn bool, explicitPromptID string) (key string, sequence uint64, previousMessageID, previousRequestID, promptID string) { credentialIdentity = strings.TrimSpace(credentialIdentity) sessionID = strings.TrimSpace(sessionID) if credentialIdentity == "" || sessionID == "" { - return "", 0, "" + return "", 0, "", "", "" } digest := sha256.Sum256([]byte(credentialIdentity + "\x00" + sessionID)) key = hex.EncodeToString(digest[:]) @@ -62,19 +161,39 @@ func BeginClaudeDiagnostics(credentialIdentity, sessionID string) (key string, s if newGeneration { entry = claudeDiagnosticsEntry{minimumSequence: sequence} } + + activePromptID := entry.promptID + explicitPromptID = strings.TrimSpace(explicitPromptID) + if explicitPromptID != "" && IsValidClaudePromptID(explicitPromptID) { + activePromptID = strings.ToLower(explicitPromptID) + } else if isNewPromptTurn || activePromptID == "" { + activePromptID = uuid.NewString() + } + claudeDiagnosticsState.nextAccess++ entry.lastAccess = claudeDiagnosticsState.nextAccess entry.expiresAt = now.Add(claudeDiagnosticsTTL) claudeDiagnosticsState.entries[key] = entry - return key, sequence, entry.previousMessageID + return key, sequence, entry.previousMessageID, entry.previousRequestID, activePromptID } -// CommitClaudeDiagnostics advances continuity only after a response completes. -// A response from an older concurrently-started request cannot overwrite a -// newer committed generation, including after TTL expiry or capacity eviction. -func CommitClaudeDiagnostics(key string, sequence uint64, messageID string) { +// BeginClaudeDiagnostics starts one request generation for a stable credential +// identity and Claude conversation. It returns the last successfully completed +// upstream message ID, if any. Only a SHA-256 digest of the credential identity +// and session is retained as the cache key, so access-token rotation does not +// interrupt continuity. +func BeginClaudeDiagnostics(credentialIdentity, sessionID string) (key string, sequence uint64, previousMessageID string) { + key, sequence, prevMsg, _, _ := BeginClaudeContinuity(credentialIdentity, sessionID, false, "") + return key, sequence, prevMsg +} + +// CommitClaudeContinuity advances continuity only after a response completes. +// It commits the upstream message ID (msg_01...), request ID (req_01...), and prompt ID (cc_prompt_id). +// A non-empty messageID is required so incomplete/truncated streams do not advance sequence. +func CommitClaudeContinuity(key string, sequence uint64, messageID, requestID string, promptIDs ...string) { key = strings.TrimSpace(key) messageID = strings.TrimSpace(messageID) + requestID = strings.TrimSpace(requestID) if key == "" || sequence == 0 || messageID == "" { return } @@ -88,12 +207,29 @@ func CommitClaudeDiagnostics(key string, sequence uint64, messageID string) { } claudeDiagnosticsState.nextAccess++ entry.previousMessageID = messageID + if requestID != "" && claudeRequestIDPattern.MatchString(requestID) { + entry.previousRequestID = requestID + } else { + entry.previousRequestID = "" + } + if len(promptIDs) > 0 { + if pID := strings.TrimSpace(promptIDs[0]); pID != "" && IsValidClaudePromptID(pID) { + entry.promptID = strings.ToLower(pID) + } + } entry.committedSequence = sequence entry.lastAccess = claudeDiagnosticsState.nextAccess entry.expiresAt = now.Add(claudeDiagnosticsTTL) claudeDiagnosticsState.entries[key] = entry } +// CommitClaudeDiagnostics advances continuity only after a response completes. +// A response from an older concurrently-started request cannot overwrite a +// newer committed generation, including after TTL expiry or capacity eviction. +func CommitClaudeDiagnostics(key string, sequence uint64, messageID string) { + CommitClaudeContinuity(key, sequence, messageID, "") +} + func cleanupClaudeDiagnosticsLocked(now time.Time) { if !claudeDiagnosticsState.lastCleanup.IsZero() && now.Sub(claudeDiagnosticsState.lastCleanup) < claudeDiagnosticsCleanupPeriod { return @@ -127,6 +263,11 @@ func evictClaudeDiagnosticsLocked() { } } +// ResetClaudeDiagnosticsForTest resets in-memory continuity state for test isolation. +func ResetClaudeDiagnosticsForTest() { + resetClaudeDiagnosticsForTest() +} + func resetClaudeDiagnosticsForTest() { claudeDiagnosticsState.Lock() defer claudeDiagnosticsState.Unlock() @@ -135,3 +276,245 @@ func resetClaudeDiagnosticsForTest() { claudeDiagnosticsState.nextSequence = 0 claudeDiagnosticsState.nextAccess = 0 } + +// IsClaudeNewPromptTurn inspects the request messages to determine whether +// this request starts a new user prompt turn (requiring a new cc_prompt_id) +// versus a tool continuation step (which reuses the current cc_prompt_id). +func IsClaudeNewPromptTurn(body []byte) bool { + if IsClaudeProbeOrHelperRequest(body) { + return false + } + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return true + } + arr := messages.Array() + if len(arr) == 0 { + return true + } + lastMsg := arr[len(arr)-1] + if lastMsg.Get("role").String() != "user" { + return false + } + content := lastMsg.Get("content") + if content.IsArray() { + // If any element is a tool_result, this is a tool continuation turn. + for _, part := range content.Array() { + if part.Get("type").String() == "tool_result" { + return false + } + } + } + return true +} + +var ( + claudePrevReqBillingPattern = regexp.MustCompile(`\s*cc_prev_req=[^;]+;`) + claudePromptIDBillingPattern = regexp.MustCompile(`\s*cc_prompt_id=[^;]+;`) +) + +func isClaudeProbeRequest(body []byte) bool { + maxTokens := gjson.GetBytes(body, "max_tokens") + if !maxTokens.Exists() || maxTokens.Int() != 1 { + return false + } + tools := gjson.GetBytes(body, "tools") + if tools.Exists() && len(tools.Array()) > 0 { + return false + } + messages := gjson.GetBytes(body, "messages") + if !messages.Exists() || !messages.IsArray() || len(messages.Array()) == 0 { + return true // headless preflight probe without messages + } + arr := messages.Array() + if len(arr) != 1 { + return false + } + firstMsg := arr[0] + if firstMsg.Get("role").String() != "user" { + return false + } + content := firstMsg.Get("content") + if content.Type == gjson.String { + str := strings.TrimSpace(content.String()) + return str == "quota" || str == "test" || str == "." || str == "probe" + } + if content.IsArray() { + for _, part := range content.Array() { + t := strings.TrimSpace(part.Get("text").String()) + if strings.Contains(t, "") { + continue + } + if t == "quota" || t == "test" || t == "." || t == "probe" { + return true + } + if t == "Hi" && part.Get("cache_control").Exists() { + return true + } + } + } + return false +} + +// IsClaudeProbeOrHelperRequest reports whether the request is a minimal +// probe/preflight (max_tokens: 1) or an automated title generation helper, +// which in native Claude Code do not emit cc_prompt_id or cc_prev_req. +func IsClaudeProbeOrHelperRequest(body []byte) bool { + if isClaudeProbeRequest(body) { + return true + } + // Title helper: must contain the title generation instruction, combined with + // single-property title schema or title system prompt. + hasTitleInstruction := false + system := gjson.GetBytes(body, "system") + if system.IsArray() { + for _, part := range system.Array() { + if strings.Contains(part.Get("text").String(), "Return a short title") { + hasTitleInstruction = true + break + } + } + } else if strings.Contains(system.String(), "Return a short title") { + hasTitleInstruction = true + } + if !hasTitleInstruction { + messages := gjson.GetBytes(body, "messages") + if messages.IsArray() { + for _, msg := range messages.Array() { + content := msg.Get("content") + if content.IsArray() { + for _, part := range content.Array() { + if strings.Contains(part.Get("text").String(), "Return a short title") { + hasTitleInstruction = true + break + } + } + } else if strings.Contains(content.String(), "Return a short title") { + hasTitleInstruction = true + break + } + } + } + } + if !hasTitleInstruction { + return false + } + props := gjson.GetBytes(body, "output_config.format.schema.properties") + if props.Exists() { + return props.Get("title").Exists() && len(props.Map()) == 1 + } + return true +} + +// IsClaudeSubagentRequest reports whether the incoming request originates from +// or represents a Claude Code subagent. +func IsClaudeSubagentRequest(headers http.Header, body []byte) bool { + if val := HeaderValueCaseInsensitive(headers, "X-Claude-Code-Agent-Id"); val != "" { + return true + } + if val := HeaderValueCaseInsensitive(headers, "X-Claude-Code-Parent-Agent-Id"); val != "" { + return true + } + if gjson.GetBytes(body, "metadata.user_id.parent_session_id").Exists() { + return true + } + if userID := gjson.GetBytes(body, "metadata.user_id").String(); userID != "" { + if strings.Contains(userID, `"parent_session_id"`) { + return true + } + } + // Only inspect system[0].text or billing header for cc_is_subagent, never raw user message content + system := gjson.GetBytes(body, "system") + if system.IsArray() && len(system.Array()) > 0 { + if strings.Contains(system.Array()[0].Get("text").String(), "cc_is_subagent=true") { + return true + } + } else if system.Type == gjson.String && strings.Contains(system.String(), "cc_is_subagent=true") { + return true + } + return false +} + +// StripClaudeBillingTags removes cc_prev_req and cc_prompt_id from the billing header in body. +func StripClaudeBillingTags(body []byte) []byte { + system := gjson.GetBytes(body, "system") + if !system.IsArray() || len(system.Array()) == 0 { + return body + } + billingText := system.Array()[0].Get("text").String() + if !strings.HasPrefix(billingText, "x-anthropic-billing-header:") { + return body + } + cleaned := claudePrevReqBillingPattern.ReplaceAllString(billingText, "") + cleaned = claudePromptIDBillingPattern.ReplaceAllString(cleaned, "") + if cleaned != billingText { + updated, err := sjson.SetBytes(body, "system.0.text", cleaned) + if err == nil { + return updated + } + } + return body +} + +// InjectClaudeBillingTags appends cc_prev_req and cc_prompt_id to the billing header in body if present. +func InjectClaudeBillingTags(body []byte, prevReq, promptID string) []byte { + system := gjson.GetBytes(body, "system") + if !system.IsArray() || len(system.Array()) == 0 { + return body + } + billingText := system.Array()[0].Get("text").String() + if !strings.HasPrefix(billingText, "x-anthropic-billing-header:") { + return body + } + cleaned := claudePrevReqBillingPattern.ReplaceAllString(billingText, "") + cleaned = claudePromptIDBillingPattern.ReplaceAllString(cleaned, "") + cleaned = strings.TrimSpace(cleaned) + if !strings.HasSuffix(cleaned, ";") { + cleaned += ";" + } + if prevReq != "" { + cleaned += " cc_prev_req=" + prevReq + ";" + } + if promptID != "" { + cleaned += " cc_prompt_id=" + promptID + ";" + } + updated, err := sjson.SetBytes(body, "system.0.text", cleaned) + if err == nil { + return updated + } + return body +} + +// ExtractClaudeBillingTags extracts existing cc_prev_req and cc_prompt_id values +// from a billing header in system text, if present. +func ExtractClaudeBillingTags(body []byte) (prevReq, promptID string) { + system := gjson.GetBytes(body, "system") + var billingText string + if system.IsArray() && len(system.Array()) > 0 { + billingText = system.Array()[0].Get("text").String() + } else if system.Type == gjson.String { + billingText = system.String() + } + if billingText == "" || !strings.HasPrefix(billingText, "x-anthropic-billing-header:") { + return "", "" + } + if idx := strings.Index(billingText, "cc_prev_req="); idx >= 0 { + val := billingText[idx+len("cc_prev_req="):] + if end := strings.IndexByte(val, ';'); end >= 0 { + val = val[:end] + } + if claudeRequestIDPattern.MatchString(val) { + prevReq = val + } + } + if idx := strings.Index(billingText, "cc_prompt_id="); idx >= 0 { + val := billingText[idx+len("cc_prompt_id="):] + if end := strings.IndexByte(val, ';'); end >= 0 { + val = val[:end] + } + if IsValidClaudePromptID(val) { + promptID = strings.ToLower(val) + } + } + return prevReq, promptID +} diff --git a/internal/runtime/executor/helps/claude_diagnostics_test.go b/internal/runtime/executor/helps/claude_diagnostics_test.go index 09a0e07539..f6248a63eb 100644 --- a/internal/runtime/executor/helps/claude_diagnostics_test.go +++ b/internal/runtime/executor/helps/claude_diagnostics_test.go @@ -2,8 +2,11 @@ package helps import ( "fmt" + "net/http" "testing" "time" + + "github.com/google/uuid" ) func TestClaudeDiagnosticsTracksCompletedMessagePerCredentialSession(t *testing.T) { @@ -100,3 +103,199 @@ func TestClaudeDiagnosticsRejectsLateOlderCommit(t *testing.T) { t.Fatalf("previous message = %q, want newer completed generation", previous) } } + +func TestClaudeContinuityTracksRequestIDAndPromptID(t *testing.T) { + resetClaudeDiagnosticsForTest() + defer resetClaudeDiagnosticsForTest() + + // Turn 1: New prompt turn + key, seq1, prevMsg, prevReq, prompt1 := BeginClaudeContinuity("cred-1", "sess-1", true, "") + if prevMsg != "" || prevReq != "" || prompt1 == "" { + t.Fatalf("turn 1 = prevMsg:%q prevReq:%q prompt:%q, want empty prevs and fresh prompt", prevMsg, prevReq, prompt1) + } + CommitClaudeContinuity(key, seq1, "msg_01aaa", "req_01bbb", prompt1) + + // Turn 1.1: Tool continuation turn (not new prompt) + _, seq2, prevMsg, prevReq, prompt2 := BeginClaudeContinuity("cred-1", "sess-1", false, "") + if prevMsg != "msg_01aaa" || prevReq != "req_01bbb" { + t.Fatalf("turn 1.1 = prevMsg:%q prevReq:%q, want msg_01aaa / req_01bbb", prevMsg, prevReq) + } + if prompt2 != prompt1 { + t.Fatalf("turn 1.1 prompt = %q, want same prompt %q as turn 1", prompt2, prompt1) + } + CommitClaudeContinuity(key, seq2, "msg_01ccc", "req_01ddd", prompt2) + + // Turn 2: New prompt turn + _, _, prevMsg, prevReq, prompt3 := BeginClaudeContinuity("cred-1", "sess-1", true, "") + if prevMsg != "msg_01ccc" || prevReq != "req_01ddd" { + t.Fatalf("turn 2 = prevMsg:%q prevReq:%q, want msg_01ccc / req_01ddd", prevMsg, prevReq) + } + if prompt3 == prompt1 { + t.Fatalf("turn 2 prompt = %q, want new prompt different from %q", prompt3, prompt1) + } +} + +func TestClaudeContinuityHelperPredicates(t *testing.T) { + probeBody := []byte(`{"model":"claude-fable-5-1","max_tokens":1,"messages":[{"role":"user","content":[{"type":"text","text":"Hi","cache_control":{"type":"ephemeral"}}]}]}`) + if !IsClaudeProbeOrHelperRequest(probeBody) { + t.Fatal("IsClaudeProbeOrHelperRequest(fable probe) = false, want true") + } + + quotaProbeBody := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"quota"}]}`) + if !IsClaudeProbeOrHelperRequest(quotaProbeBody) { + t.Fatal("IsClaudeProbeOrHelperRequest(quota probe) = false, want true") + } + + ordinaryHiMaxTokens1 := []byte(`{"model":"claude-fable-5-1","max_tokens":1,"messages":[{"role":"user","content":"Hi"}]}`) + if IsClaudeProbeOrHelperRequest(ordinaryHiMaxTokens1) { + t.Fatal("IsClaudeProbeOrHelperRequest(ordinary Hi max_tokens=1) = true, want false") + } + + multiTurnMaxTokens1 := []byte(`{"model":"claude-sonnet-5","max_tokens":1,"messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"what is 1+1?"}]}`) + if IsClaudeProbeOrHelperRequest(multiTurnMaxTokens1) { + t.Fatal("IsClaudeProbeOrHelperRequest(multi-turn max_tokens=1) = true, want false") + } + + withToolsMaxTokens1 := []byte(`{"model":"claude-sonnet-5","max_tokens":1,"messages":[{"role":"user","content":"Hi"}],"tools":[{"name":"t1","description":"tool"}]}`) + if IsClaudeProbeOrHelperRequest(withToolsMaxTokens1) { + t.Fatal("IsClaudeProbeOrHelperRequest(tools max_tokens=1) = true, want false") + } + + titleBody := []byte(`{"model":"claude-haiku-4-5-20251001","output_config":{"format":{"schema":{"properties":{"title":{"type":"string"}}}}},"messages":[{"role":"user","content":"Return a short title summarizing this conversation"}]}`) + if !IsClaudeProbeOrHelperRequest(titleBody) { + t.Fatal("IsClaudeProbeOrHelperRequest(title helper) = false, want true") + } + + ordinaryTitleSchema := []byte(`{"model":"claude-haiku-4-5-20251001","output_config":{"format":{"schema":{"properties":{"title":{"type":"string"}}}}},"messages":[{"role":"user","content":"what is the title of the book?"}]}`) + if IsClaudeProbeOrHelperRequest(ordinaryTitleSchema) { + t.Fatal("IsClaudeProbeOrHelperRequest(ordinary title schema) = true, want false") + } + + relocatedTitleBody := []byte(`{"model":"claude-sonnet-5","system":[{"type":"text","text":"cli-identity"}],"messages":[{"role":"system","content":"Return a short title summarizing this conversation"}]}`) + if !IsClaudeProbeOrHelperRequest(relocatedTitleBody) { + t.Fatal("IsClaudeProbeOrHelperRequest(relocated title system in messages) = false, want true") + } + + toolResultBody := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"tool_use","id":"t1"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}]}`) + if IsClaudeNewPromptTurn(toolResultBody) { + t.Fatal("IsClaudeNewPromptTurn(tool_result) = true, want false") + } + + newPromptBody := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"explain sorting"}]}`) + if !IsClaudeNewPromptTurn(newPromptBody) { + t.Fatal("IsClaudeNewPromptTurn(user text) = false, want true") + } + + subagentHeader := http.Header{"X-Claude-Code-Agent-Id": []string{"sub-1"}} + if !IsClaudeSubagentRequest(subagentHeader, []byte(`{}`)) { + t.Fatal("IsClaudeSubagentRequest(agent header) = false, want true") + } + + subagentMetaBody := []byte(`{"metadata":{"user_id":"{\"device_id\":\"dev\",\"session_id\":\"sess\",\"parent_session_id\":\"parent-1\"}"}}`) + if !IsClaudeSubagentRequest(nil, subagentMetaBody) { + t.Fatal("IsClaudeSubagentRequest(parent_session_id) = false, want true") + } + + billingSystemBody := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.258.1e2; cc_entrypoint=cli; cch=00000; cc_prev_req=req_01abc; cc_prompt_id=3c6489dc-badc-42b2-bd28-49f8ebabfedd;"}]}`) + prevReq, promptID := ExtractClaudeBillingTags(billingSystemBody) + if prevReq != "req_01abc" || promptID != "3c6489dc-badc-42b2-bd28-49f8ebabfedd" { + t.Fatalf("ExtractClaudeBillingTags = %q, %q; want req_01abc, 3c6489dc-badc-42b2-bd28-49f8ebabfedd", prevReq, promptID) + } +} + +func TestIsValidClaudePromptID(t *testing.T) { + v4 := uuid.NewString() + if !IsValidClaudePromptID(v4) { + t.Fatalf("IsValidClaudePromptID(%q) = false, want true", v4) + } + + // UUIDv1 should be rejected + v1 := "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + if IsValidClaudePromptID(v1) { + t.Fatalf("IsValidClaudePromptID(v1 %q) = true, want false", v1) + } + + // UUIDv4 with non-RFC4122 variant (variant bits 0xxx instead of 10xx, e.g. '0' instead of '8','9','a','b') + nonRFC4122 := "3c6489dc-badc-42b2-0d28-49f8ebabfedd" + if IsValidClaudePromptID(nonRFC4122) { + t.Fatalf("IsValidClaudePromptID(non-RFC4122 variant %q) = true, want false", nonRFC4122) + } + + // All zeros UUID should be rejected (version 0) + allZeros := "00000000-0000-0000-0000-000000000000" + if IsValidClaudePromptID(allZeros) { + t.Fatalf("IsValidClaudePromptID(all zeros %q) = true, want false", allZeros) + } + + // Invalid format strings + for _, invalid := range []string{"", "not-a-uuid", "12345", "3c6489dc-badc-42b2-bd28-49f8ebabfedd-extra"} { + if IsValidClaudePromptID(invalid) { + t.Fatalf("IsValidClaudePromptID(%q) = true, want false", invalid) + } + } +} + +func TestClaudeContinuityConcurrentOverlappingPromptID(t *testing.T) { + resetClaudeDiagnosticsForTest() + defer resetClaudeDiagnosticsForTest() + + // Turn 1 starts (in-flight, not committed) + key, seq1, _, _, prompt1 := BeginClaudeContinuity("cred-1", "sess-1", true, "") + + // Turn 2 starts concurrently before Turn 1 commits + _, seq2, _, _, prompt2 := BeginClaudeContinuity("cred-1", "sess-1", true, "") + if prompt1 == prompt2 { + t.Fatalf("turn 1 and turn 2 got same prompt %q, want distinct", prompt1) + } + + // Turn 1 completes upstream and commits + CommitClaudeContinuity(key, seq1, "msg_01aaa", "req_01bbb", prompt1) + + // Turn 1.1 tool continuation starts (not new turn) + _, _, prevMsg, prevReq, prompt11 := BeginClaudeContinuity("cred-1", "sess-1", false, "") + if prevMsg != "msg_01aaa" || prevReq != "req_01bbb" { + t.Fatalf("turn 1.1 prevMsg=%q prevReq=%q, want msg_01aaa / req_01bbb", prevMsg, prevReq) + } + if prompt11 != prompt1 { + t.Fatalf("turn 1.1 prompt=%q, want committed turn 1 prompt %q, not overwritten by uncommitted turn 2 (%q)", prompt11, prompt1, prompt2) + } + + // Turn 2 completes upstream and commits + CommitClaudeContinuity(key, seq2, "msg_01ccc", "req_01ddd", prompt2) + + // Turn 2.1 tool continuation starts + _, _, prevMsg2, prevReq2, prompt21 := BeginClaudeContinuity("cred-1", "sess-1", false, "") + if prevMsg2 != "msg_01ccc" || prevReq2 != "req_01ddd" { + t.Fatalf("turn 2.1 prevMsg=%q prevReq=%q, want msg_01ccc / req_01ddd", prevMsg2, prevReq2) + } + if prompt21 != prompt2 { + t.Fatalf("turn 2.1 prompt=%q, want committed turn 2 prompt %q", prompt21, prompt2) + } +} + +func TestClaudeContinuityClearsStaleRequestID(t *testing.T) { + resetClaudeDiagnosticsForTest() + defer resetClaudeDiagnosticsForTest() + + // Turn 1: has valid request-id + key, seq1, _, _, p1 := BeginClaudeContinuity("cred-1", "sess-1", true, "") + CommitClaudeContinuity(key, seq1, "msg_01aaa", "req_01bbb", p1) + + // Turn 1.1: verify prevReq is req_01bbb + _, seq2, _, prevReq1, p2 := BeginClaudeContinuity("cred-1", "sess-1", false, "") + if prevReq1 != "req_01bbb" { + t.Fatalf("turn 1.1 prevReq = %q, want req_01bbb", prevReq1) + } + + // Turn 1.1 finishes, but upstream returned no request-id + CommitClaudeContinuity(key, seq2, "msg_01ccc", "", p2) + + // Turn 2: verify prevReq is empty, NOT stale req_01bbb + _, _, prevMsg, prevReq2, _ := BeginClaudeContinuity("cred-1", "sess-1", true, "") + if prevMsg != "msg_01ccc" { + t.Fatalf("turn 2 prevMsg = %q, want msg_01ccc", prevMsg) + } + if prevReq2 != "" { + t.Fatalf("turn 2 prevReq = %q, want empty (must not retain stale request-id from prior turn)", prevReq2) + } +} diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index 12663bb9a1..8cb14d88eb 100644 --- a/internal/runtime/executor/helps/payload_helpers.go +++ b/internal/runtime/executor/helps/payload_helpers.go @@ -32,13 +32,23 @@ func ApplyPayloadConfigWithRequest(cfg *config.Config, model, protocol, fromProt // ApplyPayloadConfigWithRequestTracked applies payload config and reports whether // an applied rule targeted trackedPath or one of its descendants. -func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, fromProtocol, root string, payload, original []byte, requestedModel string, requestPath string, headers http.Header, trackedPath string) ([]byte, bool) { +// ApplyPayloadConfigWithTrackedPaths applies payload config and reports which +// tracked paths (or their descendants) were targeted by an applied rule. +func ApplyPayloadConfigWithTrackedPaths(cfg *config.Config, model, protocol, fromProtocol, root string, payload, original []byte, requestedModel string, requestPath string, headers http.Header, trackedPaths ...string) ([]byte, map[string]bool) { + touched := make(map[string]bool) if cfg == nil || len(payload) == 0 { - return payload, false + return payload, touched } out := payload - trackedPath = strings.TrimSpace(trackedPath) - trackedPathTouched := false + + markTouched := func(resolvedPath string) { + for _, tp := range trackedPaths { + tp = strings.TrimSpace(tp) + if tp != "" && payloadRuleTargetsPath(resolvedPath, tp) { + touched[tp] = true + } + } + } // Apply disable-image-generation filtering before payload rules so config payload // overrides can explicitly re-enable image_generation when desired. @@ -83,7 +93,7 @@ func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, f } out = updated appliedDefaults[resolvedPath] = struct{}{} - trackedPathTouched = trackedPathTouched || payloadRuleTargetsPath(resolvedPath, trackedPath) + markTouched(resolvedPath) } } } @@ -115,7 +125,7 @@ func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, f } out = updated appliedDefaults[resolvedPath] = struct{}{} - trackedPathTouched = trackedPathTouched || payloadRuleTargetsPath(resolvedPath, trackedPath) + markTouched(resolvedPath) } } } @@ -134,7 +144,7 @@ func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, f var applied bool out, applied = setPayloadValueIfDifferentTracked(out, resolvedPath, value) if applied { - trackedPathTouched = trackedPathTouched || payloadRuleTargetsPath(resolvedPath, trackedPath) + markTouched(resolvedPath) } } } @@ -158,7 +168,7 @@ func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, f var applied bool out, applied = setPayloadRawValueIfDifferentTracked(out, resolvedPath, rawValue) if applied { - trackedPathTouched = trackedPathTouched || payloadRuleTargetsPath(resolvedPath, trackedPath) + markTouched(resolvedPath) } } } @@ -182,13 +192,20 @@ func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, f continue } out = updated - trackedPathTouched = trackedPathTouched || payloadRuleTargetsPath(resolvedPath, trackedPath) + markTouched(resolvedPath) } } } } } - return out, trackedPathTouched + return out, touched +} + +// ApplyPayloadConfigWithRequestTracked applies payload config and reports whether +// an applied rule targeted trackedPath or one of its descendants. +func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, fromProtocol, root string, payload, original []byte, requestedModel string, requestPath string, headers http.Header, trackedPath string) ([]byte, bool) { + out, touched := ApplyPayloadConfigWithTrackedPaths(cfg, model, protocol, fromProtocol, root, payload, original, requestedModel, requestPath, headers, trackedPath) + return out, touched[trackedPath] } func isImagesEndpointRequestPath(path string) bool { @@ -510,10 +527,10 @@ func buildPayloadPath(root, path string) string { } func payloadRuleTargetsPath(path, trackedPath string) bool { - if trackedPath == "" { + if trackedPath == "" || path == "" { return false } - return path == trackedPath || strings.HasPrefix(path, trackedPath+".") + return path == trackedPath || strings.HasPrefix(path, trackedPath+".") || strings.HasPrefix(trackedPath, path+".") } func resolvePayloadRulePaths(payload []byte, path string) []string {