diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index fd86ffa1..f212fa1d 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -242,6 +242,10 @@ func claudeCCHFallbackBillingHeader(ctx context.Context, cfg *config.Config, pay 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", "") } @@ -286,7 +290,13 @@ func checkSystemInstructionsWithSigningModeAt( 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) } @@ -765,6 +775,158 @@ 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() { + str := strings.ReplaceAll(system.String(), "\u200B", "") + return str == claudeCodeFableReportingOutcomes || strings.Contains(str, claudeCodeFableReportingOutcomes) + } + for _, blk := range system.Array() { + text := strings.ReplaceAll(blk.Get("text").String(), "\u200B", "") + if text == 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, + 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 { + system := gjson.GetBytes(body, "system") + if system.IsArray() { + blocks := make([]string, 0, len(system.Array())) + removed := false + for _, blk := range system.Array() { + if strings.ReplaceAll(blk.Get("text").String(), "\u200B", "") == claudeCodeFableReportingOutcomes { + removed = true + continue + } + blocks = append(blocks, blk.Raw) + } + if removed { + body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]")) + } + } else if strings.ReplaceAll(system.String(), "\u200B", "") == 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") + } else if thinkingType != "adaptive" && fableState.injectedDisplay && !payloadTouchedDisplay { + body, _ = sjson.DeleteBytes(body, "thinking.display") + } + } else if fableState.injectedDisplay && !payloadTouchedDisplay { + body, _ = sjson.DeleteBytes(body, "thinking.display") + } + if !hasFableReportingBlock(body) { + 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, ",")+"]")) + } else if system.Type == gjson.String { + str := system.String() + blocks := []string{ + buildTextBlock(str, nil), + buildTextBlock(claudeCodeFableReportingOutcomes, nil), + } + body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]")) + } else if !system.Exists() { + blocks := []string{ + 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 if CPA automatically injected it + if fableState.injectedReporting { + system := gjson.GetBytes(body, "system") + if system.IsArray() { + blocks := make([]string, 0, len(system.Array())) + removed := false + for _, blk := range system.Array() { + if strings.ReplaceAll(blk.Get("text").String(), "\u200B", "") == claudeCodeFableReportingOutcomes { + removed = true + continue + } + blocks = append(blocks, blk.Raw) + } + if removed { + body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]")) + } + } else if strings.ReplaceAll(system.String(), "\u200B", "") == 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 { @@ -1075,6 +1237,19 @@ func applyCloaking( apiKey string, confirmedClaudeCode bool, cchSigning bool, +) ([]byte, bool, error) { + return applyCloakingInternal(ctx, cfg, auth, payload, apiKey, confirmedClaudeCode, cchSigning, true) +} + +func applyCloakingInternal( + ctx context.Context, + cfg *config.Config, + auth *cliproxyauth.Auth, + payload []byte, + apiKey string, + confirmedClaudeCode bool, + cchSigning bool, + obfuscateSensitiveWords bool, ) ([]byte, bool, error) { policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode) if !policy.Cloak { @@ -1145,13 +1320,18 @@ func applyCloaking( promptID, ) - // Fable 5.1 / Mythos 5.1 native profile: emit fallbacks and adaptive thinking display - if isClaudeFable51Model(gjson.GetBytes(payload, "model").String()) { + // 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.type").String() == "adaptive" && !gjson.GetBytes(payload, "thinking.display").Exists() { - payload, _ = sjson.SetBytes(payload, "thinking.display", "updates") + 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") + } } } @@ -1173,7 +1353,7 @@ func applyCloaking( } // Apply sensitive word obfuscation - if len(settings.sensitiveWords) > 0 { + if obfuscateSensitiveWords && len(settings.sensitiveWords) > 0 { matcher := helps.BuildSensitiveWordMatcher(settings.sensitiveWords) payload = helps.ObfuscateSensitiveWords(payload, matcher) } diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 3186d62e..4c728b33 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -99,6 +99,7 @@ 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{} @@ -127,8 +128,32 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r 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", + ) + contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) + body = reconcileClaudeCodeFableModelAfterPayload( + body, + fableState, + touchedPayloadPaths["fallbacks"], + touchedPayloadPaths["thinking.display"], + cloaked, + isProbeOrHelper, + ) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 1161c913..32e76b91 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -102,6 +102,7 @@ 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{} @@ -130,8 +131,32 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A 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", + ) + contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) + body = reconcileClaudeCodeFableModelAfterPayload( + body, + fableState, + touchedPayloadPaths["fallbacks"], + touchedPayloadPaths["thinking.display"], + cloaked, + isProbeOrHelper, + ) body = ensureModelMaxTokens(body, baseModel) // Disable thinking if tool_choice forces tool use (Anthropic API constraint) diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 335cb9a9..0724489f 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -4589,6 +4589,1679 @@ 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_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_PayloadSystemTTLOverrideStillRemovesInjectedFableReportingBlock(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-system-ttl-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", + "system.0.cache_control.ttl": "1h", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-system-ttl-fable-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-system-ttl-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 from Fable 5.1 to Sonnet 5, so injected Reporting outcomes block + // MUST be removed even though payload rule touched system.0.cache_control.ttl! + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if strings.Contains(blk.Get("text").String(), "Reporting outcomes") { + t.Fatalf("Reporting outcomes block must be removed on rewritten Sonnet model, got: %s", blk.Raw) + } + } +} + +func TestClaudeExecutor_PayloadSonnetToFableWithSystemTTLEditsAddsReportingBlock(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-sonnet-to-fable-test", + 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", + "system.1.cache_control.ttl": "1h", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-payload-sonnet-to-fable-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-payload-sonnet-to-fable-test", + "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 from Sonnet 5 to Fable 5.1, so Reporting outcomes block + // MUST be added even though payload rule touched system.1.cache_control.ttl! + 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("Reporting outcomes block must be attached on rewritten Fable model, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } +} + +func TestClaudeExecutor_PayloadOverrideProbeWithExplicitDiagnosticsPreserved(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-oat-probe-explicit-diag-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-haiku-4-5-20251001"}}, + Params: map[string]any{ + "max_tokens": 1, + "diagnostics": map[string]any{ + "operator_custom": "custom_value_123", + }, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-probe-explicit-diag-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-probe-explicit-diag-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Initially non-probe so CPA injects diagnostics, then payload rule overrides to probe AND sets custom diagnostics + payload := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1000,"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) + } + + // The operator-provided diagnostics object must be preserved! + if got := gjson.GetBytes(seenBody, "diagnostics.operator_custom").String(); got != "custom_value_123" { + t.Fatalf("operator custom diagnostics must be preserved, got: %s", gjson.GetBytes(seenBody, "diagnostics").Raw) + } +} + +func TestClaudeExecutor_PayloadStringSystemFableAddsReportingBlock(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-string-system-fable-test", + 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", + "system": "You are a helpful coding assistant.", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-string-system-fable-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-string-system-fable-test", + "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) + } + + // Payload rule set string system and rewrote to Fable 5.1: + // System must become an array containing the reporting outcomes block! + 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("Reporting outcomes block must be attached for string system Fable request, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } +} + +func TestClaudeExecutor_PayloadStringSystemMentioningReportingOutcomesStillInjectsFableReportingBlock(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-string-system-mention-test", + 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", + "system": "Please follow best practices when reporting outcomes to the user.", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-string-system-mention-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-string-system-mention-test", + "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) + } + + // Payload rule had a system string that merely mentioned "reporting outcomes". + // The exact `# Reporting outcomes` block MUST be injected! + hasExactReporting := false + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if blk.Get("text").String() == claudeCodeFableReportingOutcomes { + hasExactReporting = true + break + } + } + if !hasExactReporting { + t.Fatalf("exact `# Reporting outcomes` block must be injected even when string mentions reporting outcomes, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } +} + +func TestClaudeExecutor_PayloadStringSystemWithExactReportingPromptDoesNotDuplicate(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-string-system-exact-test", + 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", + "system": claudeCodeFableReportingOutcomes, + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-string-system-exact-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-string-system-exact-test", + "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) + } + + reportingCount := 0 + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + if blk.Get("text").String() == claudeCodeFableReportingOutcomes { + reportingCount++ + } + } + if reportingCount != 1 { + t.Fatalf("expected exactly 1 reporting block, got %d in: %s", reportingCount, gjson.GetBytes(seenBody, "system").Raw) + } +} + +func TestClaudeExecutor_PayloadFableThinkingAdaptiveToDisabledDropsInjectedDisplay(t *testing.T) { + var seenHeaders http.Header + var seenBody []byte + 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-fable-disabled-thinking-test", + Cloak: &config.CloakConfig{}, + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "thinking.type": "disabled", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-fable-disabled-thinking-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-fable-disabled-thinking-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Starts with adaptive thinking so CPA cloaking injects thinking.display=updates, + // then payload rule overrides thinking.type to disabled: + 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) + } + + // Injected thinking.display must be dropped + if gjson.GetBytes(seenBody, "thinking.display").Exists() { + t.Fatalf("thinking.display must be dropped when thinking.type is disabled, got: %s", gjson.GetBytes(seenBody, "thinking").Raw) + } + // thinking-display-updates beta header must NOT be present + betas := seenHeaders.Get("anthropic-beta") + if strings.Contains(betas, "thinking-display-updates") { + t.Fatalf("thinking-display-updates beta header must NOT be present on disabled thinking, got: %s", betas) + } +} + +func TestClaudeExecutor_UncloakedProbePreservesCallerBillingTags(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-api03-uncloaked-test", + // No CloakConfig, uncloaked request + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-uncloaked-probe-test", + Attributes: map[string]string{ + "api_key": "sk-ant-api03-uncloaked-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Caller provides their own billing header with prompt ID on probe + callerBilling := "x-anthropic-billing-header: cc_version=2.1.258; cc_entrypoint=cli; cch=abc12; cc_prompt_id=00000000-0000-4000-8000-000000000001;" + payload := []byte(fmt.Sprintf(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"system":[{"type":"text","text":%q}],"messages":[{"role":"user","content":"quota"}]}`, callerBilling)) + + _, 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) + } + + // Because cloaking was disabled, the caller-owned billing header must be preserved exactly! + gotSystem := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.Contains(gotSystem, "cc_prompt_id=00000000-0000-4000-8000-000000000001") { + t.Fatalf("uncloaked request must preserve caller billing tags on probe, got: %s", gotSystem) + } +} + +func TestClaudeExecutor_FableWithSensitiveWordsHasSingleObfuscatedReportingBlock(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-fable-sensitive-test", + Cloak: &config.CloakConfig{ + SensitiveWords: []string{"Reporting"}, + }, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-fable-sensitive-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-fable-sensitive-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) + } + + reportingCount := 0 + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + raw := blk.Get("text").String() + cleaned := strings.ReplaceAll(raw, "\u200B", "") + if cleaned == claudeCodeFableReportingOutcomes { + reportingCount++ + if !strings.Contains(raw, "\u200B") { + t.Fatalf("Reporting block must be obfuscated with zero-width space, got: %q", raw) + } + if strings.Contains(raw, "Reporting") { + t.Fatalf("Reporting block must not contain cleartext sensitive word 'Reporting', got: %q", raw) + } + } + } + if reportingCount != 1 { + t.Fatalf("expected exactly 1 reporting block, got %d; system = %s", reportingCount, gjson.GetBytes(seenBody, "system").Raw) + } +} + +func TestClaudeExecutor_PayloadFableToSonnetWithSensitiveWordsRemovesReportingBlock(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-fable-to-sonnet-sensitive-test", + Cloak: &config.CloakConfig{ + SensitiveWords: []string{"Reporting"}, + }, + }}, + 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-fable-to-sonnet-sensitive-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-fable-to-sonnet-sensitive-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) + } + + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + raw := blk.Get("text").String() + cleaned := strings.ReplaceAll(raw, "\u200B", "") + if cleaned == claudeCodeFableReportingOutcomes { + t.Fatalf("reporting block should be removed when rewritten to Sonnet 5, got: %s", raw) + } + } +} + +func TestClaudeExecutor_SensitiveWordsDoNotCorruptBillingHeaderTags(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-billing-corrupt-test", + Cloak: &config.CloakConfig{ + SensitiveWords: []string{"cli", "version", "entrypoint", "prompt", "anthropic"}, + }, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-billing-corrupt-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-billing-corrupt-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) + } + + // system[0] billing header must NOT contain zero-width spaces in its tags + billingText := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.HasPrefix(billingText, "x-anthropic-billing-header: cc_version=") { + t.Fatalf("billing header must start cleanly without obfuscation, got: %q", billingText) + } + if strings.Contains(billingText, "\u200B") { + t.Fatalf("billing header must not contain zero-width space characters, got: %q", billingText) + } + if !strings.Contains(billingText, "cc_entrypoint=cli;") { + t.Fatalf("billing header must preserve cc_entrypoint=cli;, got: %q", billingText) + } +} + +func TestClaudeExecutor_DisabledCloakingSkipsSensitiveWordObfuscation(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{ + DisableClaudeCloakMode: true, + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-skip-obfuscate-test", + Cloak: &config.CloakConfig{ + SensitiveWords: []string{"confidential", "secret"}, + }, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-skip-obfuscate-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-skip-obfuscate-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-sonnet-5","system":[{"type":"text","text":"this is confidential"}],"messages":[{"role":"user","content":"keep this secret"}]}`) + _, 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) + } + + rawBody := string(seenBody) + if strings.Contains(rawBody, "\u200B") { + t.Fatalf("disabled cloaking must not insert zero-width spaces into Execute body, got: %s", rawBody) + } + if !strings.Contains(rawBody, "confidential") || !strings.Contains(rawBody, "secret") { + t.Fatalf("expected cleartext preserved in Execute, got: %s", rawBody) + } +} + +func TestClaudeExecutor_DisabledCloakingStreamSkipsSensitiveWordObfuscation(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", "text/event-stream") + _, _ = w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-5\",\"content\":[]}}\n\n")) + _, _ = w.Write([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")) + })) + defer server.Close() + + cfg := &config.Config{ + ClaudeKey: []config.ClaudeKey{{ + APIKey: "sk-ant-oat-skip-obfuscate-stream-test", + Cloak: &config.CloakConfig{ + Mode: "never", + SensitiveWords: []string{"confidential", "secret"}, + }, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-skip-obfuscate-stream-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-skip-obfuscate-stream-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + payload := []byte(`{"model":"claude-sonnet-5","stream":true,"system":[{"type":"text","text":"this is confidential"}],"messages":[{"role":"user","content":"keep this secret"}]}`) + streamResult, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: payload, + }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if err != nil { + t.Fatalf("ExecuteStream error = %v", err) + } + for range streamResult.Chunks { + } + + rawBody := string(seenBody) + if strings.Contains(rawBody, "\u200B") { + t.Fatalf("disabled cloaking must not insert zero-width spaces into ExecuteStream body, got: %s", rawBody) + } + if !strings.Contains(rawBody, "confidential") || !strings.Contains(rawBody, "secret") { + t.Fatalf("expected cleartext preserved in ExecuteStream, got: %s", rawBody) + } +} + +func TestClaudeExecutor_PayloadReplacesSystemOnOriginalFableReaddsReportingBlock(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-fable-replace-sys-test", + }}, + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "claude-fable-5-1"}}, + Params: map[string]any{ + "system": "Custom replaced system prompt", + }, + }}, + }, + } + auth := &cliproxyauth.Auth{ + ID: "auth-fable-replace-sys-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-fable-replace-sys-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) + } + + hasReplacedSystem := false + hasReporting := false + for _, blk := range gjson.GetBytes(seenBody, "system").Array() { + text := blk.Get("text").String() + if text == "Custom replaced system prompt" { + hasReplacedSystem = true + } + if text == claudeCodeFableReportingOutcomes { + hasReporting = true + } + } + if !hasReplacedSystem { + t.Fatalf("expected custom replaced system prompt in system, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } + if !hasReporting { + t.Fatalf("expected Fable reporting outcomes block re-added in system, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } +} + +func TestClaudeExecutor_UserTitlePromptWithoutSchemaTreatedAsNormalTurn(t *testing.T) { + var seenHeaders http.Header + var seenBody []byte + 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-user-title-prompt-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-user-title-prompt-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-user-title-prompt-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + // Normal user query with text "Return a short title for this blog post", but NO structured output schema + payload := []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"Return a short title for this blog post"}]}`) + _, 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) + } + + // Must NOT be treated as a title helper: + // 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("user title query must carry 1h cache, got: %s", gjson.GetBytes(seenBody, "system").Raw) + } + if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") { + t.Fatalf("user title query must carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta")) + } + // 2. Billing header must carry cc_prompt_id + billingText := gjson.GetBytes(seenBody, "system.0.text").String() + if !strings.Contains(billingText, "cc_prompt_id=") { + t.Fatalf("user title query must carry cc_prompt_id, got: %s", billingText) + } +} + +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) @@ -6172,46 +7845,9 @@ func TestClaudeExecutorPayloadOverrideReenablesThinking(t *testing.T) { } } -func TestClaudeExecutorPayloadOverrideMaxTokens(t *testing.T) { - const model = "claude-fable-5-1" - for _, test := range []struct { - name string - stream bool - maxOutputTokens int - }{ - {name: "execute"}, - {name: "execute stream", stream: true}, - {name: "execute overrides client limit", maxOutputTokens: 32000}, - {name: "execute stream overrides client limit", stream: true, maxOutputTokens: 32000}, - } { - t.Run(test.name, func(t *testing.T) { - cfg := &config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{ - Models: []config.PayloadModelRule{{Name: model, Protocol: "claude"}}, - Params: map[string]any{"max_tokens": 128000}, - }}}} - payload := []byte(`{"model":"claude-fable-5-1","input":"hi"}`) - if test.maxOutputTokens > 0 { - payload, _ = sjson.SetBytes(payload, "max_output_tokens", test.maxOutputTokens) - } - upstreamBody := executeClaudeContextManagementRequest(t, cfg, payload, test.stream, sdktranslator.FormatOpenAIResponse) - if got := gjson.GetBytes(upstreamBody, "max_tokens").Int(); got != 128000 { - t.Fatalf("final upstream max_tokens = %d, want 128000; body=%s", got, upstreamBody) - } - }) - } -} - -func executeClaudeContextManagementRequest(t *testing.T, cfg *config.Config, payload []byte, stream bool, sourceFormats ...sdktranslator.Format) []byte { +func executeClaudeContextManagementRequest(t *testing.T, cfg *config.Config, payload []byte, stream bool) []byte { t.Helper() - sourceFormat := sdktranslator.FormatClaude - if len(sourceFormats) > 0 { - sourceFormat = sourceFormats[0] - } - model := gjson.GetBytes(payload, "model").String() - if model == "" { - model = "claude-opus-5" - } var upstreamBody []byte transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { var errRead error @@ -6235,8 +7871,8 @@ func executeClaudeContextManagementRequest(t *testing.T, cfg *config.Config, pay ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) executor := NewClaudeExecutor(cfg) auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-payload-rule", "cloak_mode": "always"}} - request := cliproxyexecutor.Request{Model: model, Payload: payload} - options := cliproxyexecutor.Options{SourceFormat: sourceFormat, ResponseFormat: sdktranslator.FormatClaude} + request := cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload} + options := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude} if stream { result, errStream := executor.ExecuteStream(ctx, auth, request, options) @@ -6787,3 +8423,81 @@ func TestClaudeExecutor_DisabledThinkingStripsDisplayBeta(t *testing.T) { t.Errorf("disabled thinking must not send effort beta, got: %s", betas) } } + +func TestClaudeExecutor_CloakModePrefersStoredPrevReqOverCallerFake(t *testing.T) { + var seenBodies [][]byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + seenBodies = append(seenBodies, body) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("request-id", "req_real_upstream_001") + _, _ = 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-cloak-prev-req-test", + Cloak: &config.CloakConfig{}, + }}, + } + auth := &cliproxyauth.Auth{ + ID: "auth-cloak-prev-req-test", + Metadata: claudeOAuthTestMetadata(), + Attributes: map[string]string{ + "api_key": "sk-ant-oat-cloak-prev-req-test", + "base_url": server.URL, + }, + } + + executor := NewClaudeExecutor(cfg) + headers := http.Header{"Session-Id": []string{"sess-test-001"}} + + // Turn 1: normal turn, establishes real upstream request-id req_real_upstream_001 + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: []byte(`{"model":"claude-sonnet-5","messages":[{"role":"user","content":"turn 1"}]}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: headers, + }) + if err != nil { + t.Fatalf("Turn 1 Execute error = %v", err) + } + + // Turn 2: a non-native caller in cloak mode sends a fake cc_prev_req in system + fakeCallerPayload := []byte(`{ + "model": "claude-sonnet-5", + "system": [ + {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.258.000; cc_entrypoint=cli; cch=00000; cc_prev_req=req_fake_caller_999; cc_prompt_id=aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee;"}, + {"type": "text", "text": "custom instructions"} + ], + "messages": [ + {"role":"user","content":"turn 1"}, + {"role":"assistant","content":"ok"}, + {"role":"user","content":"turn 2"} + ] + }`) + + _, err2 := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "claude-sonnet-5", + Payload: fakeCallerPayload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: headers, + }) + if err2 != nil { + t.Fatalf("Turn 2 Execute error = %v", err2) + } + + if len(seenBodies) < 2 { + t.Fatalf("expected at least 2 requests, got %d", len(seenBodies)) + } + turn2System := gjson.GetBytes(seenBodies[1], "system.0.text").String() + if !strings.Contains(turn2System, "cc_prev_req=req_real_upstream_001") { + t.Fatalf("CPA in cloak mode must use real storedPrevReq req_real_upstream_001, got: %s", turn2System) + } + if strings.Contains(turn2System, "req_fake_caller_999") { + t.Fatalf("CPA must not use fake caller cc_prev_req, got: %s", turn2System) + } +} diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go index 12663bb9..8cb14d88 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 {