diff --git a/AGENTS.md b/AGENTS.md index 8a40ce91d..55ab25d8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ go build -o test-output ./cmd/server && rm test-output # Verify compile (REQUIRE - As a rule, do not make standalone changes to `internal/translator/`. You may modify it only as part of broader changes elsewhere. - If a task requires changing only `internal/translator/`, run `gh repo view --json viewerPermission -q .viewerPermission` to confirm you have `WRITE`, `MAINTAIN`, or `ADMIN`. If you do, you may proceed; otherwise, file a GitHub issue including the goal, rationale, and the intended implementation code, then stop further work. - `internal/runtime/executor/` should contain executors and their unit tests only. Place any helper/supporting files under `internal/runtime/executor/helps/`. +- Payload configuration MUST be the final semantic barrier before sending requests in every executor, including streaming, WebSocket, continuation, retry/fallback, image, and token-count paths where applicable. Complete all built-in payload translation, normalization, injection, and cleanup first, then evaluate and apply user payload rules exactly once to the final business payload. No subsequent logic may overwrite configured values or restore filtered fields. Only necessary transport framing, serialization, signature calculation, and read-only validation may follow without changing business payload semantics. Add regression tests when introducing or changing request-building paths to enforce this ordering. - Follow `gofmt`; keep imports goimports-style; wrap errors with context where helpful - Do not use `log.Fatal`/`log.Fatalf` (terminates the process); prefer returning errors and logging via logrus - Shadowed variables: use method suffix (`errStart := server.Start()`) diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 1e8e249ef..8efc1920c 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -460,10 +460,6 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A return cliproxyexecutor.Response{}, err } - body.payload, _ = sjson.DeleteBytes(body.payload, "generationConfig") - body.payload, _ = sjson.DeleteBytes(body.payload, "tools") - body.payload, _ = sjson.DeleteBytes(body.payload, "safetySettings") - endpoint := e.buildEndpoint(baseModel, "countTokens", "") wsReq := &wsrelay.HTTPRequest{ Method: http.MethodPost, @@ -542,7 +538,6 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe payload = fixGeminiImageAspectRatio(baseModel, payload) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - payload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", payload, originalTranslated, requestedModel, requestPath, opts.Headers) payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType") payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema") @@ -562,6 +557,12 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe payload = helps.EnsureGeminiTrailingUserContent(payload, "contents") } payload = normalizeAIStudioThinkingLevel(payload) + if action == "countTokens" { + payload, _ = sjson.DeleteBytes(payload, "generationConfig") + payload, _ = sjson.DeleteBytes(payload, "tools") + payload, _ = sjson.DeleteBytes(payload, "safetySettings") + } + payload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", payload, originalTranslated, requestedModel, requestPath, opts.Headers) return payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil } diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go index 7e8e226c5..8606d7e1e 100644 --- a/internal/runtime/executor/aistudio_executor_test.go +++ b/internal/runtime/executor/aistudio_executor_test.go @@ -63,7 +63,7 @@ func TestAIStudioTranslateRequestNormalizesThinkingLevel(t *testing.T) { } } -func TestAIStudioTranslateRequestNormalizesThinkingLevelAfterPayloadOverride(t *testing.T) { +func TestAIStudioTranslateRequestPayloadOverrideWinsThinkingNormalization(t *testing.T) { executor := NewAIStudioExecutor(&config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{ Models: []config.PayloadModelRule{{Name: "gemini-3.7-flash", Protocol: "gemini"}}, Params: map[string]any{"generationConfig.thinkingConfig.thinkingLevel": "medium"}, @@ -78,8 +78,8 @@ func TestAIStudioTranslateRequestNormalizesThinkingLevelAfterPayloadOverride(t * if err != nil { t.Fatalf("translateRequest() error = %v", err) } - if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "MEDIUM" { - t.Fatalf("thinkingLevel = %q, want MEDIUM; payload=%s", got, payload) + if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "medium" { + t.Fatalf("thinkingLevel = %q, want medium; payload=%s", got, payload) } } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 3c7d552a1..e42a12a0d 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -92,9 +92,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts)) translated = e.obfuscateSensitiveWords(translated) translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) reporter.SetTranslatedReasoningEffort(translated, to.String()) @@ -302,9 +300,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts)) translated = e.obfuscateSensitiveWords(translated) translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) reporter.SetTranslatedReasoningEffort(translated, to.String()) diff --git a/internal/runtime/executor/antigravity_executor_request.go b/internal/runtime/executor/antigravity_executor_request.go index 574b16d5b..9c51a68ac 100644 --- a/internal/runtime/executor/antigravity_executor_request.go +++ b/internal/runtime/executor/antigravity_executor_request.go @@ -83,6 +83,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau } payloadStrBytes := applyAntigravityNativeSignatureReplayIfNeeded(modelName, []byte(payloadStr)) + payloadStrBytes = helps.FinalizePayload(ctx, payloadStrBytes) bodyReader = bytes.NewReader(payloadStrBytes) if e.cfg != nil && e.cfg.RequestLog { payloadLog = append([]byte(nil), payloadStrBytes...) @@ -95,6 +96,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau } payload = applyAntigravityNativeSignatureReplayIfNeeded(modelName, payload) + payload = helps.FinalizePayload(ctx, payload) bodyReader = bytes.NewReader(payload) if e.cfg != nil && e.cfg.RequestLog { payloadLog = append([]byte(nil), payload...) diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index 0d2d6243d..bd02f0f4d 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -91,9 +91,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts)) translated = e.obfuscateSensitiveWords(translated) translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) translated, _ = sjson.DeleteBytes(translated, "request.stream") diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index 9085fea5a..a49303027 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -51,6 +51,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut modelInfo, _ := cliproxyauth.ResolvedModelInfo(req) translationReq := sdktranslator.RequestEnvelope{Format: from, Model: baseModel, Body: req.Payload, ModelInfo: modelInfo} payload := helps.TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, translationReq).Body + originalTranslatedForPayload := append([]byte(nil), payload...) payload, err := helps.ApplyRequestThinking(payload, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -89,6 +90,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut requestURL.WriteString(url.QueryEscape(opts.Alt)) } + payload = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "request", originalTranslatedForPayload, req, opts)(payload) httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload)) if errReq != nil { return cliproxyexecutor.Response{}, errReq diff --git a/internal/runtime/executor/claude_cloaked_date_pin_test.go b/internal/runtime/executor/claude_cloaked_date_pin_test.go index 309dae53d..d320501cb 100644 --- a/internal/runtime/executor/claude_cloaked_date_pin_test.go +++ b/internal/runtime/executor/claude_cloaked_date_pin_test.go @@ -231,10 +231,8 @@ func TestClaudeCloakedDateReminderSessionByteStability(t *testing.T) { } } -// TestClaudeCloakedDateReminderDeclassifiedProbePinsSessionDate verifies that when -// a probe request is declassified into a normal request across midnight in an ongoing -// session, the declassification branch re-applies the pinned session date. -func TestClaudeCloakedDateReminderDeclassifiedProbePinsSessionDate(t *testing.T) { +// Explicit max_tokens overrides must not rerun the built-in probe date policy. +func TestClaudeCloakedDateReminderProbeOverrideDoesNotReinitializeSessionDate(t *testing.T) { helps.ResetClaudeDiagnosticsForTest() t.Cleanup(helps.ResetClaudeDiagnosticsForTest) @@ -271,8 +269,7 @@ func TestClaudeCloakedDateReminderDeclassifiedProbePinsSessionDate(t *testing.T) }, nil }) - // Use PayloadConfig to override max_tokens: 1 (which would classify as probe) to 100, - // triggering declassification via wasProbeOrHelper. + // Override max_tokens without reclassifying the original probe. cfg := &config.Config{ Payload: config.PayloadConfig{ Override: []config.PayloadRule{ @@ -313,7 +310,7 @@ func TestClaudeCloakedDateReminderDeclassifiedProbePinsSessionDate(t *testing.T) } // Request 2: Across midnight, request initially has max_tokens: 1 and content: "probe", - // but PayloadOverride declassifies it into a normal request. + // and PayloadOverride changes only the final max_tokens field. mockTime = time.Date(2026, 8, 2, 0, 1, 0, 0, time.UTC) probePayload := `{ "model": "claude-opus-5", @@ -335,11 +332,11 @@ func TestClaudeCloakedDateReminderDeclassifiedProbePinsSessionDate(t *testing.T) t.Fatalf("expected 3 captured bodies, got %d", len(capturedBodies)) } - // Both declassified requests must carry the session's pinned date ("2026-08-01") - if got := extractClaudeCloakDate(t, capturedBodies[1]); got != "2026-08-01" { - t.Fatalf("declassified Execute request date = %q, want session pinned 2026-08-01", got) + // Payload does not reclassify probes or replay the prior session date. + if got := extractClaudeCloakDate(t, capturedBodies[1]); got != "2026-08-02" { + t.Fatalf("configured Execute request date = %q, want original probe date 2026-08-02", got) } - if got := extractClaudeCloakDate(t, capturedBodies[2]); got != "2026-08-01" { - t.Fatalf("declassified ExecuteStream request date = %q, want session pinned 2026-08-01", got) + if got := extractClaudeCloakDate(t, capturedBodies[2]); got != "2026-08-02" { + t.Fatalf("configured ExecuteStream request date = %q, want original probe date 2026-08-02", got) } } diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index c2e34aa9f..ef04fc5d7 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -1270,11 +1270,10 @@ type claudeCodeContextManagementState struct { eligible bool callerOwned bool automaticallyInjected bool - payloadRuleTouched bool } -// reconcileClaudeCodeContextManagement resolves automatic ownership after all -// payload rules and forced tool-choice processing have completed. +// reconcileClaudeCodeContextManagement resolves built-in ownership after forced +// tool-choice processing. User payload rules run later and never need reconciliation. func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContextManagementState) []byte { contextManagement := gjson.GetBytes(payload, "context_management") @@ -1283,7 +1282,7 @@ func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContex // thinking field after injection, so this also covers a request that was // still eligible when injectClaudeCodeContextManagement ran. if !claudeThinkingAcceptsClearThinking(payload) { - if state.callerOwned || !state.automaticallyInjected || state.payloadRuleTouched { + if state.callerOwned || !state.automaticallyInjected { return payload } if contextManagement.Raw != claudeCodeContextManagement { @@ -1296,7 +1295,7 @@ func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContex return updated } - if !state.eligible || state.callerOwned || state.payloadRuleTouched || contextManagement.Exists() { + if !state.eligible || state.callerOwned || contextManagement.Exists() { return payload } updated, err := sjson.SetRawBytes(payload, "context_management", []byte(claudeCodeContextManagement)) diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index cfda1b352..934f2db1b 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -14,7 +14,6 @@ import ( sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/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) { @@ -91,8 +90,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. _, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) - bodyBeforeCloaking := body - isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body) var cloaked bool body, cloaked, err = applyCloakingInternal( ctx, @@ -107,8 +105,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if err != nil { 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{} @@ -126,11 +122,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r eligible: cloaked && isAnthropicUpstreamBase(baseURL), callerOwned: gjson.GetBytes(body, "context_management").Exists(), } - diagnosticsInjectedByCPA := false if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) if fp.InjectDiagnostics && !isProbeOrHelper { - diagnosticsInjectedByCPA = true if continuityCtx.Initialized { body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID) } else { @@ -139,68 +133,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - 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", - "diagnostics", - ) - contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] - body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) - wasProbeOrHelper := isProbeOrHelper - isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) - if isProbeOrHelper { - diagnosticsState = claudeDiagnosticsRequestState{} - if diagnosticsInjectedByCPA && !touchedPayloadPaths["diagnostics"] { - body, _ = sjson.DeleteBytes(body, "diagnostics") - } - if cloaked { - 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 { - existingPrevReq, existingPromptID := helps.ExtractClaudeBillingTags(body) - prevReq, promptID, cCtx, ok := resolveClaudeContinuityTags(ctx, e.cfg, auth, incomingHeaders, body, confirmedClaudeCode, existingPrevReq, existingPromptID) - if ok { - if continuityCtx != nil { - *continuityCtx = cCtx - } - if cCtx.PinnedDate != "" { - body = injectClaudeCodeCurrentDate(body, cCtx.PinnedDate) - } - body = helps.InjectClaudeBillingTags(body, prevReq, promptID) - if fp.InjectDiagnostics && isAnthropicUpstreamBase(baseURL) { - body, diagnosticsState = injectClaudeDiagnosticsWithState(body, cCtx.Key, cCtx.Sequence, cCtx.PreviousMessageID, promptID) - } - } - } - } - 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) @@ -248,8 +180,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05. // A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages). body = normalizeCacheControlTTL(body) - // Payload rules and other request processing may rewrite stream. Keep the - // upstream body, transport headers, and response parser on one authority. + // Set the built-in transport default before the explicit payload barrier. // Native non-stream Haiku helper requests omit stream rather than sending // false, so preserve that measured wire shape when the transport agrees. streamField := gjson.GetBytes(body, "stream") @@ -259,7 +190,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r // Extract betas from body and convert to header var extraBetas []string - extraBetas, body = extractAndRemoveBetas(body) bodyForTranslation := body bodyForUpstream := body var oauthToolNamesReverseMap map[string]string @@ -283,15 +213,30 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) } - bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) + bodyForUpstream, err = ensureClaudeBillingHeaderCCHPlaceholder(bodyForUpstream, cchBilling) if err != nil { return resp, fmt.Errorf("finalize Claude CCH: %w", err) } } bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream) - // Runs on the finished body: payload rules can rewrite model and messages - // long after translation, so an earlier check would not describe the request - // that is about to be sent. + // User rules match the fully prepared business body and are applied only once. + var touchedPayloadPaths map[string]bool + bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( + e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, originalTranslated, + helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers, + "diagnostics", + ) + if touchedPayloadPaths["diagnostics"] { + diagnosticsState = claudeDiagnosticsRequestState{} + } + extraBetas, bodyForUpstream = extractAndRemoveBetas(bodyForUpstream) + if cchSigning { + bodyForUpstream, err = signAnthropicMessagesBody(bodyForUpstream) + if err != nil { + return resp, fmt.Errorf("sign Claude CCH: %w", err) + } + } + // Read-only validation must observe the final configured model and messages. if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil { return resp, errMidSystem } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 1356d45a9..0ef0c91fe 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -16,7 +16,6 @@ import ( sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/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) { @@ -94,8 +93,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. _, wireSettings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) - bodyBeforeCloaking := body - isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(bodyBeforeCloaking) + isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body) var cloaked bool body, cloaked, err = applyCloakingInternal( ctx, @@ -110,8 +108,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if err != nil { 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{} @@ -129,11 +125,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A eligible: cloaked && isAnthropicUpstreamBase(baseURL), callerOwned: gjson.GetBytes(body, "context_management").Exists(), } - diagnosticsInjectedByCPA := false if contextManagementState.eligible { body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body) if fp.InjectDiagnostics && !isProbeOrHelper { - diagnosticsInjectedByCPA = true if continuityCtx.Initialized { body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID) } else { @@ -142,68 +136,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - 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", - "diagnostics", - ) - contextManagementState.payloadRuleTouched = touchedPayloadPaths["context_management"] - body = reconcileClaudeCodeSystemPlacementAfterPayload(body, systemPlacementState) - wasProbeOrHelper := isProbeOrHelper - isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body) - if isProbeOrHelper { - diagnosticsState = claudeDiagnosticsRequestState{} - if diagnosticsInjectedByCPA && !touchedPayloadPaths["diagnostics"] { - body, _ = sjson.DeleteBytes(body, "diagnostics") - } - if cloaked { - 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 { - existingPrevReq, existingPromptID := helps.ExtractClaudeBillingTags(body) - prevReq, promptID, cCtx, ok := resolveClaudeContinuityTags(ctx, e.cfg, auth, incomingHeaders, body, confirmedClaudeCode, existingPrevReq, existingPromptID) - if ok { - if continuityCtx != nil { - *continuityCtx = cCtx - } - if cCtx.PinnedDate != "" { - body = injectClaudeCodeCurrentDate(body, cCtx.PinnedDate) - } - body = helps.InjectClaudeBillingTags(body, prevReq, promptID) - if fp.InjectDiagnostics && isAnthropicUpstreamBase(baseURL) { - body, diagnosticsState = injectClaudeDiagnosticsWithState(body, cCtx.Key, cCtx.Sequence, cCtx.PreviousMessageID, promptID) - } - } - } - } - 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) @@ -253,7 +185,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A // Extract betas from body and convert to header var extraBetas []string - extraBetas, body = extractAndRemoveBetas(body) bodyForTranslation := body bodyForUpstream := body var oauthToolNamesReverseMap map[string]string @@ -277,15 +208,30 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) { cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) } - bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, cchBilling) + bodyForUpstream, err = ensureClaudeBillingHeaderCCHPlaceholder(bodyForUpstream, cchBilling) if err != nil { return nil, fmt.Errorf("finalize Claude CCH: %w", err) } } bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream) - // Runs on the finished body: payload rules can rewrite model and messages - // long after translation, so an earlier check would not describe the request - // that is about to be sent. + // User rules match the fully prepared business body and are applied only once. + var touchedPayloadPaths map[string]bool + bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths( + e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, originalTranslated, + helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers, + "diagnostics", + ) + if touchedPayloadPaths["diagnostics"] { + diagnosticsState = claudeDiagnosticsRequestState{} + } + extraBetas, bodyForUpstream = extractAndRemoveBetas(bodyForUpstream) + if cchSigning { + bodyForUpstream, err = signAnthropicMessagesBody(bodyForUpstream) + if err != nil { + return nil, fmt.Errorf("sign Claude CCH: %w", err) + } + } + // Read-only validation must observe the final configured model and messages. if errMidSystem := validateClaudeMidSystemMessageModel(bodyForUpstream, confirmedClaudeCode, isAnthropicUpstreamBase(baseURL)); errMidSystem != nil { return nil, errMidSystem } diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 5bfbe8df6..f8cfdbea1 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -2971,7 +2971,7 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamConvertsValidClaudeStream(t *testi } } -func TestClaudeExecutor_ExecuteTransportMatchesResponseFormat(t *testing.T) { +func TestClaudeExecutor_ExecutePayloadStreamOverrideDoesNotChangeResponseParser(t *testing.T) { const model = "claude-3-5-sonnet-20241022" streamResponse := strings.Join([]string{ `event: message_start`, @@ -3048,8 +3048,8 @@ func TestClaudeExecutor_ExecuteTransportMatchesResponseFormat(t *testing.T) { t.Fatalf("Execute error: %v", err) } stream := gjson.GetBytes(seenBody, "stream") - if !stream.Exists() || stream.Bool() != tt.wantStream { - t.Fatalf("upstream stream = %s, want %t; body=%s", stream.Raw, tt.wantStream, string(seenBody)) + if !stream.Exists() || stream.Bool() != !tt.wantStream { + t.Fatalf("upstream stream = %s, want %t; body=%s", stream.Raw, !tt.wantStream, string(seenBody)) } wantAccept := "application/json" wantEncoding := "gzip, deflate, br, zstd" @@ -5649,12 +5649,10 @@ func TestClaudeExecutor_SubagentAndProbeOmit1hCacheTTLAndBeta(t *testing.T) { } } -func TestClaudeExecutor_PayloadOverrideFableModelReconciled(t *testing.T) { +func TestClaudeExecutor_PayloadOverrideFableModelLeavesBuiltinsUnchanged(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"}]}`)) })) @@ -5698,26 +5696,19 @@ func TestClaudeExecutor_PayloadOverrideFableModelReconciled(t *testing.T) { 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 gjson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - 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) - } + if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } + } -func TestClaudeExecutor_PayloadOverrideNonFableToFableReconciled(t *testing.T) { +func TestClaudeExecutor_PayloadOverrideNonFableModelDoesNotInjectFableBuiltins(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"}]}`)) })) @@ -5761,24 +5752,13 @@ func TestClaudeExecutor_PayloadOverrideNonFableToFableReconciled(t *testing.T) { 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 gjson.GetBytes(seenBody, "fallbacks").Exists() { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - 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) + if strings.Contains(gjson.GetBytes(seenBody, "system").Raw, "eporting outcomes") { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } + } func TestClaudeExecutor_PayloadOverridePreservesExplicitFallbacks(t *testing.T) { @@ -5836,12 +5816,10 @@ func TestClaudeExecutor_PayloadOverridePreservesExplicitFallbacks(t *testing.T) } } -func TestClaudeExecutor_PayloadOverrideUnrelatedModelRuleDoesNotPreserveFableFallbacks(t *testing.T) { +func TestClaudeExecutor_PayloadUnrelatedRuleLeavesBuiltinFallbacksUnchanged(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"}]}`)) })) @@ -5890,21 +5868,19 @@ func TestClaudeExecutor_PayloadOverrideUnrelatedModelRuleDoesNotPreserveFableFal 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 gjson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - 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")) + if gjson.GetBytes(seenBody, "model").String() != "claude-sonnet-5" { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } + } -func TestClaudeExecutor_PayloadOverrideMaxTokensTo1ReclassifiesAsProbe(t *testing.T) { +func TestClaudeExecutor_PayloadMaxTokensOverrideDoesNotReclassifyBuiltins(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":"."}]}`)) })) @@ -5948,37 +5924,16 @@ func TestClaudeExecutor_PayloadOverrideMaxTokensTo1ReclassifiesAsProbe(t *testin 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) - } + if !strings.Contains(gjson.GetBytes(seenBody, "system").Raw, `"ttl":"1h"`) { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - // 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) { +func TestClaudeExecutor_PayloadFableProbeOverrideKeepsBuiltinAdditions(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"}]}`)) })) @@ -6018,40 +5973,22 @@ func TestClaudeExecutor_PayloadOverrideFableToProbeStripsFableAdditions(t *testi 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) + if gjson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - // 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) + if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - // 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) + if gjson.GetBytes(seenBody, "max_tokens").Int() != 1 || gjson.GetBytes(seenBody, "thinking.display").String() != "updates" { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } + } -func TestClaudeExecutor_PayloadOverrideProbeToNormalReinitializes(t *testing.T) { +func TestClaudeExecutor_PayloadProbeToNormalDoesNotReinitializeBuiltins(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"}]}`)) })) @@ -6091,32 +6028,10 @@ func TestClaudeExecutor_PayloadOverrideProbeToNormalReinitializes(t *testing.T) 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") + if gjson.GetBytes(seenBody, "max_tokens").Int() != 1000 || gjson.GetBytes(seenBody, "fallbacks").Exists() || strings.Contains(gjson.GetBytes(seenBody, "system").Raw, `"ttl":"1h"`) { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - // 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) { @@ -6209,7 +6124,7 @@ func TestClaudeExecutor_PayloadOverrideParentThinkingPreventsDisplayUpdates(t *t } } -func TestClaudeExecutor_PayloadSystemTTLOverrideStillRemovesInjectedFableReportingBlock(t *testing.T) { +func TestClaudeExecutor_PayloadSystemTTLOverrideKeepsBuiltinFableReporting(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenBody, _ = io.ReadAll(r.Body) @@ -6252,16 +6167,16 @@ func TestClaudeExecutor_PayloadSystemTTLOverrideStillRemovesInjectedFableReporti 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) - } + if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } + if gjson.GetBytes(seenBody, "system.0.cache_control.ttl").String() != cfg.Payload.Override[0].Params["system.0.cache_control.ttl"] { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) + } + } -func TestClaudeExecutor_PayloadSonnetToFableWithSystemTTLEditsAddsReportingBlock(t *testing.T) { +func TestClaudeExecutor_PayloadSonnetToFableDoesNotAddReporting(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenBody, _ = io.ReadAll(r.Body) @@ -6304,18 +6219,13 @@ func TestClaudeExecutor_PayloadSonnetToFableWithSystemTTLEditsAddsReportingBlock 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 strings.Contains(gjson.GetBytes(seenBody, "system").Raw, "eporting outcomes") { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } - if !hasReporting { - t.Fatalf("Reporting outcomes block must be attached on rewritten Fable model, got: %s", gjson.GetBytes(seenBody, "system").Raw) + if gjson.GetBytes(seenBody, "system.1.cache_control.ttl").String() != "1h" { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } + } func TestClaudeExecutor_PayloadOverrideProbeWithExplicitDiagnosticsPreserved(t *testing.T) { @@ -6370,7 +6280,7 @@ func TestClaudeExecutor_PayloadOverrideProbeWithExplicitDiagnosticsPreserved(t * } } -func TestClaudeExecutor_PayloadStringSystemFableAddsReportingBlock(t *testing.T) { +func TestClaudeExecutor_PayloadStringSystemFableWinsReportingBlock(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenBody, _ = io.ReadAll(r.Body) @@ -6413,21 +6323,12 @@ func TestClaudeExecutor_PayloadStringSystemFableAddsReportingBlock(t *testing.T) 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) + if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] { + t.Fatalf("payload system override was rewritten: %s", seenBody) } } -func TestClaudeExecutor_PayloadStringSystemMentioningReportingOutcomesStillInjectsFableReportingBlock(t *testing.T) { +func TestClaudeExecutor_PayloadStringSystemMentioningReportingOutcomesStaysExact(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenBody, _ = io.ReadAll(r.Body) @@ -6470,17 +6371,8 @@ func TestClaudeExecutor_PayloadStringSystemMentioningReportingOutcomesStillInjec 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) + if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] { + t.Fatalf("payload system override was rewritten: %s", seenBody) } } @@ -6527,18 +6419,12 @@ func TestClaudeExecutor_PayloadStringSystemWithExactReportingPromptDoesNotDuplic 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) + if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] { + t.Fatalf("payload system override was rewritten: %s", seenBody) } } -func TestClaudeExecutor_PayloadFableThinkingAdaptiveToDisabledDropsInjectedDisplay(t *testing.T) { +func TestClaudeExecutor_PayloadFableThinkingAdaptiveToDisabledKeepsBuiltinDisplay(t *testing.T) { var seenHeaders http.Header var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -6584,9 +6470,9 @@ func TestClaudeExecutor_PayloadFableThinkingAdaptiveToDisabledDropsInjectedDispl 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) + // The scalar override does not rerun thinking normalization. + if gjson.GetBytes(seenBody, "thinking.display").String() != "updates" { + t.Fatalf("thinking.display must remain untouched by the thinking.type override, got: %s", gjson.GetBytes(seenBody, "thinking").Raw) } // thinking-display-updates beta header must NOT be present betas := seenHeaders.Get("anthropic-beta") @@ -6693,7 +6579,7 @@ func TestClaudeExecutor_FableWithSensitiveWordsHasSingleObfuscatedReportingBlock } } -func TestClaudeExecutor_PayloadSonnetToFableWithSensitiveWordsObfuscatesInjectedReportingBlock(t *testing.T) { +func TestClaudeExecutor_PayloadSonnetToFableWithSensitiveWordsDoesNotInjectReporting(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenBody, _ = io.ReadAll(r.Body) @@ -6751,12 +6637,12 @@ func TestClaudeExecutor_PayloadSonnetToFableWithSensitiveWordsObfuscatesInjected } } } - if reportingCount != 1 { - t.Fatalf("expected exactly 1 reporting block, got %d; system = %s", reportingCount, gjson.GetBytes(seenBody, "system").Raw) + if reportingCount != 0 { + t.Fatalf("expected no newly injected reporting block, got %d; system = %s", reportingCount, gjson.GetBytes(seenBody, "system").Raw) } } -func TestClaudeExecutor_PayloadFableToSonnetWithSensitiveWordsRemovesReportingBlock(t *testing.T) { +func TestClaudeExecutor_PayloadFableToSonnetPreservesObfuscatedBuiltins(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenBody, _ = io.ReadAll(r.Body) @@ -6800,13 +6686,13 @@ func TestClaudeExecutor_PayloadFableToSonnetWithSensitiveWordsRemovesReportingBl 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) - } + if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) } + if !strings.Contains(gjson.GetBytes(seenBody, "system").Raw, "\u200b") { + t.Fatalf("payload override must not rerun built-ins: %s", seenBody) + } + } func TestClaudeExecutor_SensitiveWordsDoNotCorruptBillingHeaderTags(t *testing.T) { @@ -6953,7 +6839,7 @@ func TestClaudeExecutor_DisabledCloakingStreamSkipsSensitiveWordObfuscation(t *t } } -func TestClaudeExecutor_PayloadReplacesSystemOnOriginalFableReaddsReportingBlock(t *testing.T) { +func TestClaudeExecutor_PayloadReplacesSystemOnOriginalFableWithoutReinjection(t *testing.T) { var seenBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenBody, _ = io.ReadAll(r.Body) @@ -6994,22 +6880,8 @@ func TestClaudeExecutor_PayloadReplacesSystemOnOriginalFableReaddsReportingBlock 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) + if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] { + t.Fatalf("payload system override was rewritten: %s", seenBody) } } @@ -8665,12 +8537,7 @@ func TestReconcileClaudeCodeContextManagement(t *testing.T) { payload: withAutomatic("disabled"), state: claudeCodeContextManagementState{eligible: true, automaticallyInjected: true}, }, - { - name: "preserves rule owned automatic object when disabled", - payload: withAutomatic("disabled"), - state: claudeCodeContextManagementState{eligible: true, automaticallyInjected: true, payloadRuleTouched: true}, - wantRaw: claudeCodeContextManagement, - }, + { name: "preserves changed automatic object when disabled", payload: `{"thinking":{"type":"disabled"},"context_management":{"edits":[{"type":"custom"}]}}`, @@ -8694,11 +8561,7 @@ func TestReconcileClaudeCodeContextManagement(t *testing.T) { payload: `{"thinking":{"type":"enabled"}}`, state: claudeCodeContextManagementState{eligible: true, callerOwned: true}, }, - { - name: "payload rule ownership prevents addition", - payload: `{"thinking":{"type":"enabled"}}`, - state: claudeCodeContextManagementState{eligible: true, payloadRuleTouched: true}, - }, + { name: "ineligible request prevents addition", payload: `{"thinking":{"type":"enabled"}}`, @@ -8926,8 +8789,8 @@ func TestClaudeExecutorPayloadOverrideReenablesThinking(t *testing.T) { if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != test.thinkingType { t.Fatalf("final upstream thinking.type = %q, want %q; body=%s", got, test.thinkingType, upstreamBody) } - if got := gjson.GetBytes(upstreamBody, "context_management").Raw; got != claudeCodeContextManagement { - t.Fatalf("final upstream context_management = %s, want %s after payload override to %s; body=%s", got, claudeCodeContextManagement, test.thinkingType, upstreamBody) + if got := gjson.GetBytes(upstreamBody, "context_management").Raw; got != "" { + t.Fatalf("payload must not reinject context_management after enabling thinking: %s", upstreamBody) } }) } diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index 8e95d91c5..9d2315660 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -39,6 +39,10 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut // Use streaming translation to preserve function calling, except for claude. stream := from != to body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) + originalTranslatedForPayload := append([]byte(nil), body...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, stream, helps.APIKeyModelIsCompat(req)) + } var errThinking error body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) if errThinking != nil { @@ -48,6 +52,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut body = rebuildMidSystemMessagesToTopLevel(body) } body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req)) + body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body) if errValidate := validateClaudeTokenCountRequest(body); errValidate != nil { return cliproxyexecutor.Response{}, errValidate } @@ -144,6 +149,10 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy // Use streaming translation to preserve function calling, except for claude. stream := from != to body := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req)) + originalTranslatedForPayload := append([]byte(nil), body...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, stream, helps.APIKeyModelIsCompat(req)) + } body = helps.SetStringIfDifferent(body, "model", upstreamModel) var errThinking error body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) @@ -217,6 +226,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy if errMidSystem := validateClaudeMidSystemMessageModel(body, confirmedClaudeCode, directAnthropic); errMidSystem != nil { return cliproxyexecutor.Response{}, errMidSystem } + body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return cliproxyexecutor.Response{}, err diff --git a/internal/runtime/executor/claude_mid_system_model_test.go b/internal/runtime/executor/claude_mid_system_model_test.go index 5fcb79e7e..767e0aac8 100644 --- a/internal/runtime/executor/claude_mid_system_model_test.go +++ b/internal/runtime/executor/claude_mid_system_model_test.go @@ -171,8 +171,7 @@ func sendMidSystemCountTokens(t *testing.T, ex *ClaudeExecutor, ctx context.Cont return err } -// Payload rules run long after translation and can rewrite model and messages, -// so the guard has to read the finished body rather than an intermediate one. +// Read-only validation rejects an incompatible final model/messages pairing. func TestClaudeExecutor_PayloadOverrideCannotSmuggleLegacyMidSystemMessage(t *testing.T) { upstream := &midSystemUpstream{} cfg := midSystemConfig() @@ -183,7 +182,7 @@ func TestClaudeExecutor_PayloadOverrideCannotSmuggleLegacyMidSystemMessage(t *te ex := NewClaudeExecutor(cfg) // The caller addresses a model that accepts the turn; only the payload rule - // turns it into the rejected pairing. + // selects the otherwise incompatible pairing. _, err := ex.Execute(upstream.context(t, nil), midSystemAuth(), cliproxyexecutor.Request{ Model: "claude-sonnet-5", Payload: midSystemLegacyPayload("claude-sonnet-5"), }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) @@ -192,7 +191,7 @@ func TestClaudeExecutor_PayloadOverrideCannotSmuggleLegacyMidSystemMessage(t *te // A caller may already have the exact role=system turn that cloaking would // otherwise insert. The message-count proof must keep that turn caller-owned, -// so a later legacy model rewrite is rejected instead of silently consuming it. +// and a later explicit model override must not silently consume it. func TestClaudeExecutor_PayloadOverrideDoesNotClaimMatchingCallerTurn(t *testing.T) { upstream := &midSystemUpstream{} cfg := midSystemConfig() @@ -214,9 +213,9 @@ func TestClaudeExecutor_PayloadOverrideDoesNotClaimMatchingCallerTurn(t *testing // Cloaking relocates a caller's system prompt into a role=system turn for models // that accept one. A payload rule can then rewrite the model to one that does -// not. Because the caller never wrote that turn, CPA must reconcile its own -// placement through the legacy reminder path instead of returning 400. -func TestClaudeExecutor_PayloadOverrideReconcilesRelocatedSystemPrompt(t *testing.T) { +// not. CPA must not rerun placement; read-only validation rejects the final +// incompatible pairing instead of silently rewriting the configured body. +func TestClaudeExecutor_PayloadOverrideDoesNotRerunSystemPlacement(t *testing.T) { for _, test := range []struct { name string send func(t *testing.T, ex *ClaudeExecutor, ctx context.Context, payload []byte) error @@ -257,28 +256,12 @@ func TestClaudeExecutor_PayloadOverrideReconcilesRelocatedSystemPrompt(t *testin `"system":[{"type":"text","text":"Caller top"}],` + `"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`) - if err := test.send(t, ex, upstream.context(t, nil), payload); err != nil { - t.Fatalf("request error = %v, want CPA's inserted turn reconciled", err) - } - if !upstream.called { - t.Fatal("expected reconciled request to reach upstream") - } - if got := gjson.GetBytes(upstream.body, "model").String(); got != "claude-haiku-4-5-20251001" { - t.Fatalf("upstream model = %q, want payload override preserved", got) - } - if gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() { - t.Fatalf("reconciled body still carries role=system; body=%s", upstream.body) - } - if !strings.Contains(gjson.GetBytes(upstream.body, "messages.0.content").Raw, "") || - !strings.Contains(gjson.GetBytes(upstream.body, "messages.0.content").Raw, "Caller top") { - t.Fatalf("caller system prompt was not replayed as a legacy reminder; body=%s", upstream.body) - } + errSend := test.send(t, ex, upstream.context(t, nil), payload) + assertMidSystemRejected(t, errSend, upstream) }) } } -// A confirmed native caller owns its wire. It gates the turn on the model -// itself, so CPA forwards the body untouched and lets the upstream answer. func TestClaudeExecutor_ConfirmedNativeLegacyMidSystemMessageForwarded(t *testing.T) { upstream := &midSystemUpstream{} ex := NewClaudeExecutor(midSystemConfig()) @@ -484,3 +467,59 @@ func TestValidateClaudeMidSystemMessageModel(t *testing.T) { }) } } + +// Payload rules can repair an otherwise invalid caller pairing. Validation must +// inspect the configured body without translating or normalizing it again. +func TestClaudeExecutorPayloadRepairsLegacyMidSystemMessage(t *testing.T) { + const legacyModel = "claude-haiku-4-5-20251001" + const supportedModel = "claude-sonnet-5" + models := []config.PayloadModelRule{{Name: legacyModel, Protocol: "claude"}} + for _, path := range []struct { + name string + send func(*testing.T, *ClaudeExecutor, context.Context, string) error + }{ + {name: "execute", send: sendMidSystemExecute}, + {name: "stream", send: sendMidSystemStream}, + } { + for _, repair := range []struct { + name string + payload config.PayloadConfig + wantModel string + wantSystem bool + }{ + { + name: "filter system turns", + payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: models, Params: []string{`messages.#(role=="system")#`}}}}, + wantModel: legacyModel, + }, + { + name: "override supported model", + payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: models, Params: map[string]any{"model": supportedModel}}}}, + wantModel: supportedModel, + wantSystem: true, + }, + } { + t.Run(path.name+"/"+repair.name, func(t *testing.T) { + upstream := &midSystemUpstream{} + cfg := midSystemConfig() + cfg.Payload = repair.payload + executor := NewClaudeExecutor(cfg) + if errSend := path.send(t, executor, upstream.context(t, nil), legacyModel); errSend != nil { + t.Fatalf("configured repair rejected: %v", errSend) + } + if !upstream.called { + t.Fatal("repaired body did not reach upstream") + } + if got := gjson.GetBytes(upstream.body, "model").String(); got != repair.wantModel { + t.Fatalf("final model = %q, want %q", got, repair.wantModel) + } + if got := gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists(); got != repair.wantSystem { + t.Fatalf("final system-turn presence = %v, want %v; body=%s", got, repair.wantSystem, upstream.body) + } + if !gjson.GetBytes(upstream.body, `messages.#(role=="user")`).Exists() { + t.Fatalf("repair removed caller user turn: %s", upstream.body) + } + }) + } + } +} diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index 9bc1b6a4f..9c768102f 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -52,9 +52,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, e.Identifier(), baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts)) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.SetBoolIfDifferent(body, "stream", true) body, _ = sjson.DeleteBytes(body, "previous_response_id") @@ -235,9 +233,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, e.Identifier(), baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts)) body = helps.SetStringIfDifferent(body, "model", baseModel) body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index 6e948f9bd..af8790c2f 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -135,6 +135,7 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form rawJSON = helps.SetStringIfDifferent(rawJSON, "prompt_cache_key", cache.ID) } rawJSON = helps.SanitizeCodexInputItemIDs(rawJSON) + rawJSON = helps.FinalizePayload(ctx, rawJSON) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(rawJSON)) if err != nil { return nil, nil, err diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go index bae496f65..a0e6a32ee 100644 --- a/internal/runtime/executor/codex_executor_stream.go +++ b/internal/runtime/executor/codex_executor_stream.go @@ -57,9 +57,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, e.Identifier(), baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts)) body, _ = sjson.DeleteBytes(body, "previous_response_id") body, _ = sjson.DeleteBytes(body, "generate") body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") diff --git a/internal/runtime/executor/codex_executor_tokens.go b/internal/runtime/executor/codex_executor_tokens.go index 96db686a1..50e7f1583 100644 --- a/internal/runtime/executor/codex_executor_tokens.go +++ b/internal/runtime/executor/codex_executor_tokens.go @@ -22,6 +22,10 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("codex") body, updatesChanged := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) + originalTranslatedForPayload := append([]byte(nil), body...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload, _ = helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, opts.OriginalRequest, false, helps.APIKeyModelIsCompat(req)) + } body, err := helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged) if err != nil { @@ -37,6 +41,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth body = helps.SetBoolIfDifferent(body, "stream", false) body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts)) + body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body) enc, err := tokenizerForCodexModel(baseModel) if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: tokenizer init failed: %w", err) diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go index 65797a3ad..e8e0fc0e7 100644 --- a/internal/runtime/executor/codex_openai_images.go +++ b/internal/runtime/executor/codex_openai_images.go @@ -110,6 +110,9 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau reporter.SetTranslatedReasoningEffort(body, "codex") url := strings.TrimSuffix(baseURL, "/") + "/responses" + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), mainModel, "codex", "", prepared.Body, req, payloadOpts)) httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) if errCache != nil { return resp, errCache @@ -205,6 +208,9 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip reporter.SetTranslatedReasoningEffort(body, "codex") url := strings.TrimSuffix(baseURL, "/") + "/responses" + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), mainModel, "codex", "", prepared.Body, req, payloadOpts)) httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body) if errCache != nil { return nil, errCache @@ -334,6 +340,15 @@ func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *clip if errCache != nil { return resp, errCache } + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + body, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), model, "openai", body, contentType, req, payloadOpts) + if errPrepare != nil { + return resp, errPrepare + } + httpReq.Body = io.NopCloser(bytes.NewReader(body)) + httpReq.ContentLength = int64(len(body)) + httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } applyCodexDirectImageHeaders(httpReq, auth, apiKey, false, e.cfg) applyModelHeaderOverrides(httpReq.Header, model) if contentType != "" { @@ -392,6 +407,15 @@ func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth if errCache != nil { return nil, errCache } + payloadOpts := opts + payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat) + body, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), model, "openai", body, contentType, req, payloadOpts) + if errPrepare != nil { + return nil, errPrepare + } + httpReq.Body = io.NopCloser(bytes.NewReader(body)) + httpReq.ContentLength = int64(len(body)) + httpReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } applyCodexDirectImageHeaders(httpReq, auth, apiKey, true, e.cfg) applyModelHeaderOverrides(httpReq.Header, model) if contentType != "" { @@ -671,9 +695,6 @@ func (e *CodexExecutor) prepareCodexOpenAIImageBody(body []byte, req cliproxyexe return nil, errThinking } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - out = helps.ApplyPayloadConfigWithRequest(e.cfg, mainModel, "codex", codexOpenAIImageSourceFormat, "", out, body, requestedModel, requestPath, opts.Headers) out = helps.SetStringIfDifferent(out, "model", mainModel) out = helps.SetBoolIfDifferent(out, "stream", true) out, _ = sjson.DeleteBytes(out, "previous_response_id") diff --git a/internal/runtime/executor/codex_websockets_connection.go b/internal/runtime/executor/codex_websockets_connection.go index b9d061122..e51716513 100644 --- a/internal/runtime/executor/codex_websockets_connection.go +++ b/internal/runtime/executor/codex_websockets_connection.go @@ -132,14 +132,14 @@ func normalizeCodexWebsocketParallelToolCalls(body []byte, headers http.Header) } func buildCodexWebsocketRequestBody(body []byte) []byte { + return frameCodexWebsocketRequestBody(helps.SanitizeCodexInputItemIDs(body)) +} + +// Framing must not normalize the configured business payload. +func frameCodexWebsocketRequestBody(body []byte) []byte { if len(body) == 0 { return nil } - - // Match codex-rs websocket v2 semantics: every request is `response.create`. - // Incremental follow-up turns continue on the same websocket using - // `previous_response_id` + incremental `input`, not `response.append`. - body = helps.SanitizeCodexInputItemIDs(body) wsReqBody, errSet := sjson.SetBytes(body, "type", "response.create") if errSet == nil && len(wsReqBody) > 0 { return wsReqBody diff --git a/internal/runtime/executor/codex_websockets_duplex.go b/internal/runtime/executor/codex_websockets_duplex.go index 132e41693..32bcd0717 100644 --- a/internal/runtime/executor/codex_websockets_duplex.go +++ b/internal/runtime/executor/codex_websockets_duplex.go @@ -12,6 +12,7 @@ import ( "github.com/gorilla/websocket" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" @@ -200,7 +201,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( fail(cliproxyexecutor.NewUpstreamWebsocketReplayRequiredError()) return false } - payload = buildCodexWebsocketRequestBody(prepared.clientBody) + payload = frameCodexWebsocketRequestBody(prepared.clientBody) metadataMu.Lock() if len(pending) >= 16 { metadataMu.Unlock() @@ -277,6 +278,11 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( } switch gjson.GetBytes(payload, "type").String() { case "response.steer": + // Steering carries business input but must not inherit response.create defaults. + steerReq := req + steerReq.Payload = payload + payload = helps.NewPayloadFinalizer(e.cfg, "codex-websockets", thinking.ParseSuffix(req.Model).ModelName, "codex", "", payload, steerReq, opts)(payload) + payload, _ = sjson.SetBytes(payload, "type", "response.steer") parent := gjson.GetBytes(payload, "previous_response_id").String() metadataMu.Lock() settings := responseSettings[parent] @@ -295,7 +301,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex( steeringSettings[parent] = settings } metadataMu.Unlock() - // Control frames bypass ALL response.create translations and defaults. + // Control frames bypass response.create translations and built-in defaults. // Unknown fields and unsupported input are left to upstream validation. if !cliproxyexecutor.WebsocketAuthEnabled(streamCtx, auth.ID) { fail(fmt.Errorf("websocket credential is no longer enabled")) diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go index 7c6eca05d..9b47dab12 100644 --- a/internal/runtime/executor/codex_websockets_execute.go +++ b/internal/runtime/executor/codex_websockets_execute.go @@ -52,9 +52,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut return resp, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, "codex-websockets", baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + finalizePayload := helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.SetBoolIfDifferent(body, "stream", true) body, _ = sjson.DeleteBytes(body, "prompt_cache_retention") @@ -83,6 +81,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut if errPromptCache != nil { return resp, errPromptCache } + body = finalizePayload(helps.SanitizeCodexInputItemIDs(body)) reporter.SetTranslatedReasoningEffort(body, to.String()) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, nativeRequest, opts.Headers) applyCodexRoutingHint(ctx, wsHeaders, auth, baseModel, body, opts.Headers) @@ -115,7 +114,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut sess = newEphemeralCodexWebsocketSession() } - wsReqBody := buildCodexWebsocketRequestBody(body) + wsReqBody := frameCodexWebsocketRequestBody(body) wsReqLog := helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -226,7 +225,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut } readCh = sess.activate(conn) restoreMultiAgentV2 = !multiAgentV2Conflict && (optimizeMultiAgentV2 || sess.isMultiAgentV2Optimized(conn)) - wsReqBodyRetry := buildCodexWebsocketRequestBody(body) + wsReqBodyRetry := frameCodexWebsocketRequestBody(body) helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go index b6bcb49e8..406bfacde 100644 --- a/internal/runtime/executor/codex_websockets_stream.go +++ b/internal/runtime/executor/codex_websockets_stream.go @@ -75,7 +75,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } } - wsReqBody := buildCodexWebsocketRequestBody(clientBody) + wsReqBody := frameCodexWebsocketRequestBody(clientBody) wsReqLog := helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -193,7 +193,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr } readCh = sess.activate(conn) restoreMultiAgentV2 = !multiAgentV2Conflict && (optimizeMultiAgentV2 || sess.isMultiAgentV2Optimized(conn)) - wsReqBodyRetry := buildCodexWebsocketRequestBody(clientBody) + wsReqBodyRetry := frameCodexWebsocketRequestBody(clientBody) helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -774,9 +774,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, "codex-websockets", baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + finalizePayload := helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts) body = helps.SetStringIfDifferent(body, "model", baseModel) body = normalizeCodexInstructions(body, preserveNativeOutput) if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { @@ -802,6 +800,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex if errPromptCache != nil { return nil, errPromptCache } + body = finalizePayload(helps.SanitizeCodexInputItemIDs(body)) wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, preserveNativeOutput, opts.Headers) applyCodexRoutingHint(ctx, wsHeaders, auth, baseModel, body, opts.Headers) applyModelHeaderOverrides(wsHeaders, baseModel) diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index 8a1fe4785..17092f352 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -444,6 +444,15 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr cascadeID, ) + var errPayload error + protoBytes, logBody, errPayload = helps.FinalizeDevinPayload(protoBytes, func(body []byte) []byte { + original := helps.DevinPayloadDefaultsSource(body, payload) + return helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "devin", "", original, req, opts)(body) + }) + if errPayload != nil { + return nil, "", nil, errPayload + } + framed := helps.WrapConnectEnvelope(protoBytes) url := strings.TrimRight(baseURL, "/") + helps.DevinChatPath diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index 4c99b0735..eac599212 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -161,7 +161,6 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = capGeminiMaxOutputTokens(body, baseModel) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -185,6 +184,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r body, _ = sjson.DeleteBytes(body, "session_id") reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return resp, err @@ -287,7 +287,6 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = capGeminiMaxOutputTokens(body, baseModel) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -304,6 +303,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.DeleteBytes(body, "session_id") reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err @@ -439,11 +439,11 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = sanitizeGeminiInteractionsUnsupportedInputIDs(body) baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errRequest != nil { return resp, errRequest @@ -524,11 +524,11 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) fromProtocol := opts.SourceFormat.String() - body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = sanitizeGeminiInteractionsUnsupportedInputIDs(body) body = helps.SetBoolIfDifferent(body, "stream", true) baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errRequest != nil { return nil, errRequest @@ -682,6 +682,10 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") translatedReq := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, helps.APIKeyModelIsCompat(req)) + originalTranslatedForPayload := append([]byte(nil), translatedReq...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false, helps.APIKeyModelIsCompat(req)) + } translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -700,6 +704,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut baseURL := resolveGeminiBaseURL(auth) url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "countTokens") + translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq) requestBody := bytes.NewReader(translatedReq) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go index 6230fc19e..868b2ff7a 100644 --- a/internal/runtime/executor/gemini_executor_test.go +++ b/internal/runtime/executor/gemini_executor_test.go @@ -245,7 +245,7 @@ func TestGeminiExecutorCountTokensPrependsLeadingUser(t *testing.T) { } } -func TestGeminiExecutorAppliesPayloadRulesBeforeLeadingUserNormalization(t *testing.T) { +func TestGeminiExecutorAppliesPayloadRulesAfterLeadingUserNormalization(t *testing.T) { var upstreamBody []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, errRead := io.ReadAll(r.Body) @@ -281,11 +281,11 @@ func TestGeminiExecutorAppliesPayloadRulesBeforeLeadingUserNormalization(t *test if len(contents) != 3 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" { t.Fatalf("upstream roles malformed: %s", upstreamBody) } - if text := contents[0].Get("parts.0.text"); !text.Exists() || text.String() != "" { - t.Fatalf("synthetic leading user changed: %s", upstreamBody) + if text := contents[0].Get("parts.0.text"); !text.Exists() || text.String() != "payload override" { + t.Fatalf("payload override did not target final leading user: %s", upstreamBody) } - if got := contents[1].Get("parts.0.text").String(); got != "payload override" { - t.Fatalf("payload rule applied to %q, want original first model turn; body=%s", got, upstreamBody) + if got := contents[1].Get("parts.0.text").String(); got != "prior output" { + t.Fatalf("payload rule applied to %q, want unmodified original first model turn; body=%s", got, upstreamBody) } } diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 3054d707d..035d26040 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -313,6 +313,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au defer reporter.TrackFailure(ctx, &err) var body []byte + var originalTranslated []byte // Handle Imagen models with special request format if isImagenModel(baseModel) { @@ -321,6 +322,13 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au return resp, errImagen } body = imagenBody + originalTranslated = append([]byte(nil), imagenBody...) + if len(opts.OriginalRequest) > 0 { + originalTranslated, errImagen = convertToImagenRequest(opts.OriginalRequest) + if errImagen != nil { + return resp, errImagen + } + } } else { // Standard Gemini translation flow from := opts.SourceFormat @@ -331,7 +339,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au originalPayloadSource = opts.OriginalRequest } originalPayload := originalPayloadSource - originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) + originalTranslated = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) body = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier()) @@ -340,9 +348,6 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au } body = fixGeminiImageAspectRatio(baseModel, body) - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -366,6 +371,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au body, _ = sjson.DeleteBytes(body, "session_id") reporter.SetTranslatedReasoningEffort(body, "gemini") + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "gemini", opts.SourceFormat.String(), "", body, originalTranslated, helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers) httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return resp, errNewReq @@ -479,7 +485,6 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -506,6 +511,7 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip body, _ = sjson.DeleteBytes(body, "session_id") reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return resp, errNewReq @@ -606,7 +612,6 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -626,6 +631,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte body, _ = sjson.DeleteBytes(body, "session_id") reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return nil, errNewReq @@ -774,7 +780,6 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth body = fixGeminiImageAspectRatio(baseModel, body) requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String()) body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents") @@ -797,6 +802,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth body, _ = sjson.DeleteBytes(body, "session_id") reporter.SetTranslatedReasoningEffort(body, to.String()) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewReq != nil { return nil, errNewReq @@ -924,6 +930,10 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context to := sdktranslator.FromString("gemini") translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + originalTranslatedForPayload := append([]byte(nil), translatedReq...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false) + } translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -943,6 +953,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context baseURL := vertexBaseURL(location) url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, "countTokens") + translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq) httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq)) if errNewReq != nil { return cliproxyexecutor.Response{}, errNewReq @@ -1018,6 +1029,10 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * to := sdktranslator.FromString("gemini") translatedReq := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) + originalTranslatedForPayload := append([]byte(nil), translatedReq...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false) + } translatedReq, err := helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { @@ -1040,6 +1055,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth * } url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "countTokens") + translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq) httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq)) if errNewReq != nil { return cliproxyexecutor.Response{}, errNewReq diff --git a/internal/runtime/executor/helps/devin_payload.go b/internal/runtime/executor/helps/devin_payload.go new file mode 100644 index 000000000..19d2b75f0 --- /dev/null +++ b/internal/runtime/executor/helps/devin_payload.go @@ -0,0 +1,227 @@ +package helps + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "math" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "google.golang.org/protobuf/encoding/protowire" +) + +// Devin payload rules address the native business fields, never credentials or +// Connect framing. Encoding after the barrier does not inject defaults or sanitize. +type devinPayloadField struct { + name string + kind protowire.Type + repeated bool + binary bool + jsonValue bool + children map[protowire.Number]devinPayloadField +} + +var devinPayloadFields = map[protowire.Number]devinPayloadField{ + 2: {name: "system_prompt", kind: protowire.BytesType}, + 3: {name: "prompts", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "id", kind: protowire.BytesType}, + 2: {name: "source", kind: protowire.VarintType}, + 3: {name: "content", kind: protowire.BytesType}, + 6: {name: "tool_calls", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "id", kind: protowire.BytesType}, + 2: {name: "name", kind: protowire.BytesType}, + 3: {name: "arguments", kind: protowire.BytesType}, + }}, + 7: {name: "tool_call_id", kind: protowire.BytesType}, + 10: {name: "images", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "data", kind: protowire.BytesType}, + 2: {name: "mime_type", kind: protowire.BytesType}, + }}, + 11: {name: "thinking", kind: protowire.BytesType}, + 12: {name: "signature", kind: protowire.BytesType, binary: true}, + 18: {name: "signature_type", kind: protowire.BytesType}, + }}, + 8: {name: "completion_config", kind: protowire.BytesType, children: map[protowire.Number]devinPayloadField{ + 1: {name: "enabled", kind: protowire.VarintType}, + 2: {name: "max_tokens", kind: protowire.VarintType}, + 3: {name: "parameter_3", kind: protowire.VarintType}, + 5: {name: "temperature", kind: protowire.Fixed64Type}, + 7: {name: "top_k", kind: protowire.VarintType}, + 8: {name: "top_p", kind: protowire.Fixed64Type}, + }}, + 10: {name: "tools", kind: protowire.BytesType, repeated: true, children: map[protowire.Number]devinPayloadField{ + 1: {name: "name", kind: protowire.BytesType}, + 2: {name: "description", kind: protowire.BytesType}, + 3: {name: "parameters", kind: protowire.BytesType, jsonValue: true}, + }}, + 16: {name: "cascade_id", kind: protowire.BytesType}, + 21: {name: "model", kind: protowire.BytesType}, +} + +// DevinPayloadDefaultsSource maps caller field presence to the native business +// view so defaults can replace built-in values without replacing caller values. +func DevinPayloadDefaultsSource(native, interactions []byte) []byte { + out := []byte(`{}`) + for target, sources := range map[string][]string{ + "model": {"model"}, + "system_prompt": {"system_instruction", "systemInstruction"}, + "prompts": {"input"}, + "tools": {"tools"}, + "cascade_id": {"session_id", "sessionId", "conversation_id", "previous_interaction_id"}, + "completion_config.max_tokens": {"generation_config.max_output_tokens", "generationConfig.max_output_tokens"}, + "completion_config.temperature": {"generation_config.temperature", "generationConfig.temperature", "temperature"}, + "completion_config.top_k": {"generation_config.top_k", "generationConfig.top_k"}, + "completion_config.top_p": {"generation_config.top_p", "generationConfig.top_p"}, + } { + var value gjson.Result + for _, source := range sources { + value = gjson.GetBytes(interactions, source) + if value.Exists() { + break + } + } + if !value.Exists() { + continue + } + if target == "prompts" || target == "tools" { + value = gjson.GetBytes(native, target) + } + if value.Exists() { + out, _ = sjson.SetRawBytes(out, target, []byte(value.Raw)) + } + } + return out +} + +// FinalizeDevinPayload projects the already-normalized protobuf business fields +// to JSON for user rules, then replaces only those fields in the wire message. +func FinalizeDevinPayload(wire []byte, finalize PayloadFinalizer) ([]byte, []byte, error) { + view, errDecode := decodeDevinPayload(wire, devinPayloadFields) + if errDecode != nil { + return nil, nil, errDecode + } + body, errMarshal := json.Marshal(view) + if errMarshal != nil { + return nil, nil, errMarshal + } + configured := finalize(body) + if bytes.Equal(configured, body) { + return wire, configured, nil + } + body = configured + out, errEncode := encodeDevinPayload(wire, gjson.ParseBytes(body), devinPayloadFields) + return out, body, errEncode +} + +func decodeDevinPayload(wire []byte, fields map[protowire.Number]devinPayloadField) (map[string]any, error) { + out := make(map[string]any) + for len(wire) > 0 { + num, kind, tagLen := protowire.ConsumeTag(wire) + if tagLen < 0 { + return nil, protowire.ParseError(tagLen) + } + wire = wire[tagLen:] + n := protowire.ConsumeFieldValue(num, kind, wire) + if n < 0 { + return nil, protowire.ParseError(n) + } + field, ok := fields[num] + if ok { + var value any + switch kind { + case protowire.BytesType: + data, _ := protowire.ConsumeBytes(wire) + switch { + case field.children != nil: + decoded, errDecode := decodeDevinPayload(data, field.children) + if errDecode != nil { + return nil, errDecode + } + value = decoded + case field.binary: + value = base64.StdEncoding.EncodeToString(data) + case field.jsonValue && json.Valid(data): + value = json.RawMessage(data) + default: + value = string(data) + } + case protowire.VarintType: + value, _ = protowire.ConsumeVarint(wire) + case protowire.Fixed64Type: + bits, _ := protowire.ConsumeFixed64(wire) + value = math.Float64frombits(bits) + } + if field.repeated { + values, _ := out[field.name].([]any) + out[field.name] = append(values, value) + } else { + out[field.name] = value + } + } + wire = wire[n:] + } + return out, nil +} + +func encodeDevinPayload(original []byte, body gjson.Result, fields map[protowire.Number]devinPayloadField) ([]byte, error) { + var out []byte + // Credentials, device metadata, thread ordinals, and protocol flags stay opaque. + for len(original) > 0 { + num, kind, tagLen := protowire.ConsumeTag(original) + if tagLen < 0 { + return nil, protowire.ParseError(tagLen) + } + n := protowire.ConsumeFieldValue(num, kind, original[tagLen:]) + if n < 0 { + return nil, protowire.ParseError(n) + } + if _, ok := fields[num]; !ok { + out = append(out, original[:tagLen+n]...) + } + original = original[tagLen+n:] + } + for num := protowire.Number(1); num <= 21; num++ { + field, ok := fields[num] + if !ok { + continue + } + value := body.Get(field.name) + if !value.Exists() || value.Type == gjson.Null { + continue + } + values := []gjson.Result{value} + if field.repeated { + values = value.Array() + } + for _, item := range values { + out = protowire.AppendTag(out, num, field.kind) + switch field.kind { + case protowire.BytesType: + data := []byte(item.String()) + if field.children != nil { + var errEncode error + data, errEncode = encodeDevinPayload(nil, item, field.children) + if errEncode != nil { + return nil, errEncode + } + } else if field.binary { + var errDecode error + data, errDecode = base64.StdEncoding.DecodeString(item.String()) + if errDecode != nil { + return nil, fmt.Errorf("Devin payload %s: %w", field.name, errDecode) + } + } else if field.jsonValue { + data = []byte(item.Raw) + } + out = protowire.AppendBytes(out, data) + case protowire.VarintType: + out = protowire.AppendVarint(out, item.Uint()) + case protowire.Fixed64Type: + out = protowire.AppendFixed64(out, math.Float64bits(item.Float())) + } + } + } + return out, nil +} diff --git a/internal/runtime/executor/helps/payload_finalizer.go b/internal/runtime/executor/helps/payload_finalizer.go new file mode 100644 index 000000000..68bb79c05 --- /dev/null +++ b/internal/runtime/executor/helps/payload_finalizer.go @@ -0,0 +1,38 @@ +package helps + +import ( + "context" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" +) + +// PayloadFinalizer applies user rules to a fully prepared business payload. +// Each rebuilt attempt must start from the unconfigured body, not a previous result. +type PayloadFinalizer func([]byte) []byte + +// NewPayloadFinalizer snapshots matching context before built-in request mutations. +func NewPayloadFinalizer(cfg *config.Config, executor, model, protocol, root string, original []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) PayloadFinalizer { + requestedModel := PayloadRequestedModel(opts, req.Model) + requestPath := PayloadRequestPath(opts) + headers := opts.Headers.Clone() + source := append([]byte(nil), original...) + return func(body []byte) []byte { + return ApplyPayloadConfigWithRequestForExecutor(cfg, executor, model, protocol, opts.SourceFormat.String(), root, body, source, requestedModel, requestPath, headers) + } +} + +type payloadFinalizerKey struct{} + +// WithPayloadFinalizer carries the final barrier through shared request builders. +func WithPayloadFinalizer(ctx context.Context, finalize PayloadFinalizer) context.Context { + return context.WithValue(ctx, payloadFinalizerKey{}, finalize) +} + +// FinalizePayload runs immediately before serialization or transport framing. +func FinalizePayload(ctx context.Context, body []byte) []byte { + if finalize, ok := ctx.Value(payloadFinalizerKey{}).(PayloadFinalizer); ok && finalize != nil { + return finalize(body) + } + return body +} diff --git a/internal/runtime/executor/helps/payload_finalizer_test.go b/internal/runtime/executor/helps/payload_finalizer_test.go new file mode 100644 index 000000000..dcbe961f0 --- /dev/null +++ b/internal/runtime/executor/helps/payload_finalizer_test.go @@ -0,0 +1,122 @@ +package helps + +import ( + "bytes" + "context" + "mime/multipart" + "net/http" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "google.golang.org/protobuf/encoding/protowire" +) + +func TestPayloadFinalizerDefaultsUseOriginalAndNormalizeBeforeRules(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{ + Default: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "alias", Protocol: "openai", FromProtocol: "claude", Headers: map[string]string{"X-Test": "yes"}}}, Params: map[string]any{"missing": "user default", "present": "not applied"}}}, + Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "alias"}}, Params: map[string]any{"tools.0.function.parameters.properties.count.type": "number"}}}, + Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "alias"}}, Params: []string{"late"}}}, + }} + req := cliproxyexecutor.Request{Model: "alias"} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, Headers: http.Header{"X-Test": {"yes"}, "User-Agent": {"codex-cli/0.1"}}} + finalize := NewPayloadFinalizer(cfg, "openai", "upstream", "openai", "", []byte(`{"present":"caller"}`), req, opts) + body := []byte(`{"missing":"built-in","present":"caller","late":"injected","tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object","properties":{"count":{"type":"integer"}}}}}]}`) + out := FinalizePayload(WithPayloadFinalizer(context.Background(), finalize), body) + if gjson.GetBytes(out, "missing").String() != "user default" || gjson.GetBytes(out, "present").String() != "caller" || gjson.GetBytes(out, "late").Exists() || gjson.GetBytes(out, "tools.0.function.parameters.properties.count.type").String() != "number" { + t.Fatalf("unexpected final payload: %s", out) + } +} + +func TestPayloadFinalizerDevinBusinessFieldsAndCredentials(t *testing.T) { + wire := BuildDevinGetChatMessageRequest("secret-token", "device", "model", "system", []DevinPrompt{{MessageID: "id", Source: 1, Content: "hello"}}, []DevinTool{{Name: "f", Parameters: []byte(`{"type":"object"}`)}}, nil, 1024, "session", "cascade", nil) + unchanged, _, errUnchanged := FinalizeDevinPayload(wire, func(body []byte) []byte { return body }) + if errUnchanged != nil || !bytes.Equal(unchanged, wire) { + t.Fatalf("no-op changed protobuf: %v", errUnchanged) + } + final, view, errFinalize := FinalizeDevinPayload(wire, func(body []byte) []byte { + body, _ = sjson.DeleteBytes(body, "system_prompt") + body, _ = sjson.DeleteBytes(body, "tools") + body, _ = sjson.SetBytes(body, "completion_config.max_tokens", 0) + body, _ = sjson.SetBytes(body, "prompts.0.content", "configured") + return body + }) + if errFinalize != nil { + t.Fatal(errFinalize) + } + if gjson.GetBytes(view, "system_prompt").Exists() || gjson.GetBytes(view, "tools").Exists() { + t.Fatalf("filter undone: %s", view) + } + decoded, errDecode := decodeDevinPayload(final, devinPayloadFields) + if errDecode != nil { + t.Fatal(errDecode) + } + if decoded["completion_config"].(map[string]any)["max_tokens"] != uint64(0) { + t.Fatalf("default was re-injected: %v", decoded) + } + fieldOne := func(wire []byte) []byte { + for len(wire) > 0 { + num, kind, tagLen := protowire.ConsumeTag(wire) + n := protowire.ConsumeFieldValue(num, kind, wire[tagLen:]) + if num == 1 { + return wire[:tagLen+n] + } + wire = wire[tagLen+n:] + } + return nil + } + if !bytes.Equal(fieldOne(wire), fieldOne(final)) { + t.Fatal("payload rules changed credentials") + } +} + +func TestPayloadFinalizerMultipartPreservesFiles(t *testing.T) { + var buffer bytes.Buffer + writer := multipart.NewWriter(&buffer) + if errWrite := writer.WriteField("model", "original"); errWrite != nil { + t.Fatal(errWrite) + } + file, errFile := writer.CreateFormFile("image", "input.png") + if errFile != nil { + t.Fatal(errFile) + } + if _, errWrite := file.Write([]byte{0, 255, 13, 10}); errWrite != nil { + t.Fatal(errWrite) + } + if errClose := writer.Close(); errClose != nil { + t.Fatal(errClose) + } + cfg := &config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "original"}}, Params: map[string]any{"model": "configured"}}}}} + req := cliproxyexecutor.Request{Model: "original", Payload: buffer.Bytes()} + opts := cliproxyexecutor.Options{Headers: http.Header{"Content-Type": {writer.FormDataContentType()}}} + body, contentType, errFinalize := ApplyMediaPayloadConfig(cfg, "openai", "original", "openai", req.Payload, writer.FormDataContentType(), req, opts) + if errFinalize != nil { + t.Fatal(errFinalize) + } + view, _, errView := mediaPayloadJSON(body, contentType) + if errView != nil { + t.Fatal(errView) + } + if gjson.GetBytes(view, "model").String() != "configured" || gjson.GetBytes(view, "image.data").String() != "AP8NCg==" { + t.Fatalf("unexpected multipart business payload: %s", view) + } +} + +func TestPayloadRulesMatchFinalBodyAndTrackPaths(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*", Match: []map[string]any{{"max_tokens": 100}}}}, Params: map[string]any{"unexpected": true}}, + {Models: []config.PayloadModelRule{{Name: "*", Match: []map[string]any{{"max_tokens": 300}}}}, Params: map[string]any{"diagnostics.user": true}}}, + Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*", Match: []map[string]any{{"max_tokens": 300}}}}, Params: []string{"context_management", "messages.0"}}}, + }} + original := []byte(`{"max_tokens":100,"messages":[{},{}]}`) + body, touched := ApplyPayloadConfigWithTrackedPaths(cfg, "model", "claude", "claude", "", []byte(`{"max_tokens":300,"context_management":{"builtin":true},"messages":[{},{},{}]}`), original, "model", "", nil, "diagnostics", "context_management") + if gjson.GetBytes(body, "unexpected").Exists() || !gjson.GetBytes(body, "diagnostics.user").Bool() || gjson.GetBytes(body, "context_management").Exists() || gjson.GetBytes(body, "messages.#").Int() != 2 { + t.Fatalf("rules did not match final body: %s", body) + } + if !touched["diagnostics"] || !touched["context_management"] { + t.Fatalf("final tracking lost: %v", touched) + } +} diff --git a/internal/runtime/executor/helps/payload_media.go b/internal/runtime/executor/helps/payload_media.go new file mode 100644 index 000000000..47bbd95cf --- /dev/null +++ b/internal/runtime/executor/helps/payload_media.go @@ -0,0 +1,120 @@ +package helps + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/textproto" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/tidwall/gjson" +) + +// ApplyMediaPayloadConfig exposes multipart fields as JSON business values. File +// parts use {filename, content_type, data} with base64 data; framing stays opaque. +func ApplyMediaPayloadConfig(cfg *config.Config, executor, model, protocol string, body []byte, contentType string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, string, error) { + view, multipartBody, errDecode := mediaPayloadJSON(body, contentType) + if errDecode != nil { + return nil, "", errDecode + } + original := req.Payload + if len(opts.OriginalRequest) > 0 { + original = opts.OriginalRequest + } + originalView, _, errOriginal := mediaPayloadJSON(original, opts.Headers.Get("Content-Type")) + if errOriginal != nil { + return nil, "", errOriginal + } + out := NewPayloadFinalizer(cfg, executor, model, protocol, "", originalView, req, opts)(view) + if !multipartBody || bytes.Equal(out, view) { + return outOrBody(out, body, multipartBody), contentType, nil + } + var buffer bytes.Buffer + writer := multipart.NewWriter(&buffer) + for key, value := range gjson.ParseBytes(out).Map() { + values := []gjson.Result{value} + if value.IsArray() { + values = value.Array() + } + for _, item := range values { + if item.IsObject() && item.Get("filename").Exists() { + data, errData := base64.StdEncoding.DecodeString(item.Get("data").String()) + if errData != nil { + return nil, "", fmt.Errorf("multipart payload %s: %w", key, errData) + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": key, "filename": item.Get("filename").String()})) + header.Set("Content-Type", item.Get("content_type").String()) + part, errPart := writer.CreatePart(header) + if errPart != nil { + return nil, "", errPart + } + if _, errWrite := part.Write(data); errWrite != nil { + return nil, "", errWrite + } + } else if errWrite := writer.WriteField(key, item.String()); errWrite != nil { + return nil, "", errWrite + } + } + } + if errClose := writer.Close(); errClose != nil { + return nil, "", errClose + } + return buffer.Bytes(), writer.FormDataContentType(), nil +} + +func outOrBody(out, body []byte, multipartBody bool) []byte { + if multipartBody { + return body + } + return out +} + +func mediaPayloadJSON(body []byte, contentType string) ([]byte, bool, error) { + if json.Valid(body) { + return body, false, nil + } + mediaType, params, errParse := mime.ParseMediaType(contentType) + if errParse != nil || mediaType != "multipart/form-data" { + return body, false, nil + } + reader := multipart.NewReader(bytes.NewReader(body), params["boundary"]) + fields := make(map[string]any) + for { + part, errNext := reader.NextPart() + if errNext == io.EOF { + break + } + if errNext != nil { + return nil, false, errNext + } + data, errRead := io.ReadAll(part) + if errClose := part.Close(); errClose != nil { + return nil, false, errClose + } + if errRead != nil { + return nil, false, errRead + } + var value any = string(data) + if part.FileName() != "" { + value = map[string]string{"filename": part.FileName(), "content_type": part.Header.Get("Content-Type"), "data": base64.StdEncoding.EncodeToString(data)} + } + key := part.FormName() + if previous, ok := fields[key]; ok { + values, isArray := previous.([]any) + if !isArray { + values = []any{previous} + } + fields[key] = append(values, value) + } else { + fields[key] = value + } + } + view, errMarshal := json.Marshal(fields) + return view, true, errMarshal +} diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 877e07ebf..126e1e812 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -151,7 +151,6 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body, err = normalizeKimiToolMessageLinks(body) if err != nil { return resp, err @@ -161,6 +160,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req reporter.SetTranslatedReasoningEffort(body, e.Identifier()) url := helps.ResolveKimiChatURL(auth) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return resp, err @@ -290,7 +290,6 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut } requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body, err = normalizeKimiToolMessageLinks(body) if err != nil { return nil, err @@ -300,6 +299,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut reporter.SetTranslatedReasoningEffort(body, e.Identifier()) url := helps.ResolveKimiChatURL(auth) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err @@ -434,7 +434,6 @@ func (e *KimiExecutor) executeResponses(ctx context.Context, auth *cliproxyauth. requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers) var errNormalizePatch error body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body) if errNormalizePatch != nil { @@ -450,6 +449,7 @@ func (e *KimiExecutor) executeResponses(ctx context.Context, auth *cliproxyauth. reporter.SetTranslatedReasoningEffort(body, e.Identifier()) url := helps.ResolveKimiResponsesURL(auth) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers) httpReq, errNewRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewRequest != nil { return resp, errNewRequest @@ -566,7 +566,6 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers) var errNormalizePatch error body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body) if errNormalizePatch != nil { @@ -582,6 +581,7 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox reporter.SetTranslatedReasoningEffort(body, e.Identifier()) url := helps.ResolveKimiResponsesURL(auth) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers) httpReq, errNewRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if errNewRequest != nil { return nil, errNewRequest diff --git a/internal/runtime/executor/meta_executor_execute.go b/internal/runtime/executor/meta_executor_execute.go index 516ce7e4c..6de21936a 100644 --- a/internal/runtime/executor/meta_executor_execute.go +++ b/internal/runtime/executor/meta_executor_execute.go @@ -54,7 +54,6 @@ func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, req cliproxy requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, e.Identifier(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.SetBoolIfDifferent(body, "stream", stream) body, _ = sjson.DeleteBytes(body, "generate") @@ -73,6 +72,7 @@ func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, req cliproxy body = helps.SanitizeMetaWebSearchTools(body) body = helps.NormalizeCodexToolIntegerTypes(body, opts.Headers) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, e.Identifier(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) return &metaPreparedRequest{ applyPatch: applyPatch, baseModel: baseModel, diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index c213d41f0..49ba0eb39 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -125,7 +125,6 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) { translated = helps.NormalizeOpenAIToolResultsTextOnly(translated) } @@ -146,6 +145,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A reporter.SetTranslatedReasoningEffort(translated, to.String()) url := strings.TrimSuffix(baseURL, "/") + endpoint + translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) if err != nil { return resp, err @@ -240,6 +240,10 @@ func (e *OpenAICompatExecutor) executeImages(ctx context.Context, auth *cliproxy if contentType == "" { contentType = "application/json" } + payload, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), baseModel, "openai", payload, contentType, req, opts) + if errPrepare != nil { + return resp, errPrepare + } reporter.SetTranslatedReasoningEffort(payload, "openai") url := strings.TrimSuffix(baseURL, "/") + endpointPath @@ -345,7 +349,6 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) { translated = helps.NormalizeOpenAIToolResultsTextOnly(translated) } @@ -364,6 +367,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy reporter.SetTranslatedReasoningEffort(translated, to.String()) url := strings.TrimSuffix(baseURL, "/") + "/chat/completions" + translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) if err != nil { return nil, err @@ -609,6 +613,10 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl if contentType == "" { contentType = "application/json" } + payload, contentType, errPrepare = helps.ApplyMediaPayloadConfig(e.cfg, e.Identifier(), baseModel, "openai", payload, contentType, req, opts) + if errPrepare != nil { + return nil, errPrepare + } reporter.SetTranslatedReasoningEffort(payload, "openai") url := strings.TrimSuffix(baseURL, "/") + endpointPath @@ -717,6 +725,10 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau to := sdktranslator.FromString("openai") isCompat := helps.APIKeyModelIsCompat(req) translated, updatesChanged := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false, isCompat) + originalTranslatedForPayload := append([]byte(nil), translated...) + if len(opts.OriginalRequest) > 0 { + originalTranslatedForPayload, _ = helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false, isCompat) + } modelForCounting := baseModel @@ -725,6 +737,7 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau return cliproxyexecutor.Response{}, err } + translated = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translated) enc, err := helps.TokenizerForModel(modelForCounting) if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("openai compat executor: tokenizer init failed: %w", err) diff --git a/internal/runtime/executor/payload_barrier_test.go b/internal/runtime/executor/payload_barrier_test.go new file mode 100644 index 000000000..ef3f76764 --- /dev/null +++ b/internal/runtime/executor/payload_barrier_test.go @@ -0,0 +1,184 @@ +package executor + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestPayloadBarrierCodexImageFilter(t *testing.T) { + for _, transport := range []string{"http", "websocket"} { + for _, stream := range []bool{false, true} { + for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-luna"} { + t.Run(transport+"/"+model+map[bool]string{false: "/execute", true: "/stream"}[stream], func(t *testing.T) { + captured := make(chan []byte, 1) + completed := []byte(`{"type":"response.completed","response":{"id":"resp_barrier","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if transport == "websocket" { + upgrader := websocket.Upgrader{} + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + t.Error(errUpgrade) + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Error(errClose) + } + }() + _, body, errRead := conn.ReadMessage() + if errRead != nil { + t.Error(errRead) + return + } + captured <- body + if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil { + t.Error(errWrite) + } + return + } + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Error(errRead) + return + } + captured <- body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write(append(append([]byte("data: "), completed...), '\n', '\n')) + })) + defer server.Close() + cfg := &config.Config{Payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "gpt-5.6-sol"}}, Params: []string{`tools.#(type=="image_generation")#`, "instructions", "prompt_cache_key"}}}}} + var executor cliproxyauth.ProviderExecutor = NewCodexExecutor(cfg) + if transport == "websocket" { + executor = NewCodexWebsocketsExecutor(cfg) + } + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "codex", Attributes: map[string]string{"api_key": "test", "base_url": server.URL, "plan_type": "pro"}} + req := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":"hello","prompt_cache_key":"injected-cache"}`)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse} + if stream { + result, errStream := executor.ExecuteStream(context.Background(), auth, req, opts) + if errStream != nil { + t.Fatal(errStream) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + } + } else if _, errExecute := executor.Execute(context.Background(), auth, req, opts); errExecute != nil { + t.Fatal(errExecute) + } + body := <-captured + image := gjson.GetBytes(body, `tools.#(type=="image_generation")`).Exists() + if image != (model == "gpt-5.6-luna") { + t.Fatalf("image tool presence = %v: %s", image, body) + } + if model == "gpt-5.6-sol" && (gjson.GetBytes(body, "instructions").Exists() || gjson.GetBytes(body, "prompt_cache_key").Exists()) { + t.Fatalf("late injection undid filter: %s", body) + } + }) + } + } + } +} + +func TestPayloadBarrierAntigravityRebuiltAttempts(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "gemini-*"}}, Params: map[string]any{"generationConfig.maxOutputTokens": 123, "toolConfig.functionCallingConfig.mode": "CUSTOM"}}}, + Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "gemini-*"}}, Params: []string{"sessionId", "contents.0"}}}, + }} + req := cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview"} + body := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"first"}]},{"role":"user","parts":[{"text":"second"}]}]}}`) + ctx := helps.WithPayloadFinalizer(context.Background(), helps.NewPayloadFinalizer(cfg, "antigravity", req.Model, "antigravity", "request", body, req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini})) + executor := NewAntigravityExecutor(cfg) + auth := &cliproxyauth.Auth{Metadata: map[string]any{"project_id": "project-test"}} + for attempt := 0; attempt < 2; attempt++ { + request, errBuild := executor.buildRequest(ctx, auth, "token", req.Model, body, attempt == 1, "", "https://example.invalid", "injected-session") + if errBuild != nil { + t.Fatal(errBuild) + } + wire, errRead := io.ReadAll(request.Body) + if errRead != nil { + t.Fatal(errRead) + } + if errClose := request.Body.Close(); errClose != nil { + t.Fatal(errClose) + } + if gjson.GetBytes(wire, "request.generationConfig.maxOutputTokens").Int() != 123 { + t.Fatalf("built-in cleanup undid override: %s", wire) + } + if gjson.GetBytes(wire, "request.sessionId").Exists() { + t.Fatalf("session reinjected: %s", wire) + } + if got := gjson.GetBytes(wire, "request.contents.#").Int(); got != 1 { + t.Fatalf("filter applied more than once: %s", wire) + } + } +} + +func TestPayloadBarrierAIStudio(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"generationConfig.maxOutputTokens": 900000, "generationConfig.thinkingConfig.thinkingLevel": "low"}}}, + Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: []string{"contents"}}}, + }} + executor := NewAIStudioExecutor(cfg, "aistudio", nil) + for _, stream := range []bool{false, true} { + body, _, errTranslate := executor.translateRequest(context.Background(), cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(`{"contents":[{"role":"model","parts":[{"text":"hi"}]}]}`)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}, stream) + if errTranslate != nil { + t.Fatal(errTranslate) + } + if gjson.GetBytes(body, "contents").Exists() || gjson.GetBytes(body, "generationConfig.maxOutputTokens").Int() != 900000 || gjson.GetBytes(body, "generationConfig.thinkingConfig.thinkingLevel").String() != "low" { + t.Fatalf("late normalization undid rules: %s", body) + } + } +} + +func TestPayloadBarrierXAIWebsocketRetry(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"store": false, "instructions": "configured"}}}, + Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: []string{"input.0", "previous_response_id"}}}, + }} + req := cliproxyexecutor.Request{Model: "grok-4.3"} + body := []byte(`{"previous_response_id":"previous","input":[{"content":"first"},{"content":"second"}]}`) + finalize := helps.NewPayloadFinalizer(cfg, "xai", req.Model, "codex", "", body, req, cliproxyexecutor.Options{}) + first := buildXAIWebsocketRequestBody(body, finalize) + retry := buildXAIWebsocketRequestBody(body, finalize) + if !bytes.Equal(first, retry) { + t.Fatalf("retry changed configured body: %s != %s", first, retry) + } + if gjson.GetBytes(first, "store").Bool() || gjson.GetBytes(first, "instructions").String() != "configured" || gjson.GetBytes(first, "previous_response_id").Exists() || gjson.GetBytes(first, "input.#").Int() != 1 { + t.Fatalf("invalid final payload: %s", first) + } + if gjson.GetBytes(first, "type").String() != "response.create" { + t.Fatalf("missing frame type: %s", first) + } +} + +func TestPayloadBarrierClaudeDoesNotReplayFiltersOrReinjectIdentity(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{ + Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: []string{"system", "metadata", "diagnostics", "context_management", "messages.0"}}}, + Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"max_tokens": 1}}}, + }} + for _, stream := range []bool{false, true} { + body := executeClaudeContextManagementRequest(t, cfg, []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]}`), stream) + for _, path := range []string{"system", "metadata", "diagnostics", "context_management"} { + if gjson.GetBytes(body, path).Exists() { + t.Fatalf("%s was reinjected: %s", path, body) + } + } + if gjson.GetBytes(body, "messages.#").Int() != 2 || gjson.GetBytes(body, "max_tokens").Int() != 1 { + t.Fatalf("built-in processing changed final rule semantics: %s", body) + } + } +} diff --git a/internal/runtime/executor/payload_review_test.go b/internal/runtime/executor/payload_review_test.go new file mode 100644 index 000000000..e95f304f7 --- /dev/null +++ b/internal/runtime/executor/payload_review_test.go @@ -0,0 +1,203 @@ +package executor + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/wsrelay" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestClaudePayloadConditionsEvaluateOnceAtFinalBarrier(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{ + Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "*", Match: []map[string]any{{"max_tokens": 100}}}}, + Params: map[string]any{"max_tokens": 200, "temperature": 0.2, "diagnostics": map[string]any{"user": true}}, + }, { + Models: []config.PayloadModelRule{{Name: "*", Match: []map[string]any{{"max_tokens": 200}}}}, + Params: map[string]any{"top_p": 0.4}, + }}, + Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*", Match: []map[string]any{{"max_tokens": 200}}}}, Params: []string{"messages.0"}}}, + }} + for _, stream := range []bool{false, true} { + body := executeClaudeContextManagementRequest(t, cfg, []byte(`{"model":"claude-opus-5","max_tokens":100,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]}`), stream) + if gjson.GetBytes(body, "max_tokens").Int() != 200 || gjson.GetBytes(body, "temperature").Float() != 0.2 || gjson.GetBytes(body, "top_p").Float() != 0.4 || !gjson.GetBytes(body, "diagnostics.user").Bool() { + t.Fatalf("conditional rule lost fields (stream=%v): %s", stream, body) + } + if gjson.GetBytes(body, "messages.#").Int() != 2 { + t.Fatalf("conditional filter applied more than once (stream=%v): %s", stream, body) + } + } +} + +func TestAIStudioCountPayloadAfterCleanup(t *testing.T) { + cfg := &config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{ + Models: []config.PayloadModelRule{{Name: "*"}}, + Params: map[string]any{"generationConfig.temperature": 0.2, "tools": []any{map[string]any{"googleSearch": map[string]any{}}}, "safetySettings": []any{map[string]any{"category": "configured"}}}, + }}}} + req := core.Request{Model: "gemini-3.7-flash", Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), Metadata: map[string]any{"action": "countTokens"}} + connected := make(chan struct{}) + relay := wsrelay.NewManager(wsrelay.Options{ + ProviderFactory: func(*http.Request) (string, error) { return "count-test", nil }, + OnConnected: func(string) { close(connected) }, + }) + server := httptest.NewServer(relay.Handler()) + defer server.Close() + defer func() { + if errStop := relay.Stop(context.Background()); errStop != nil { + t.Error(errStop) + } + }() + conn, _, errDial := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+relay.Path(), nil) + if errDial != nil { + t.Fatal(errDial) + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Error(errClose) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + select { + case <-connected: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + for _, configured := range []bool{false, true} { + selected := &config.Config{} + if configured { + selected = cfg + } + captured := make(chan []byte, 1) + clientErrors := make(chan error, 1) + go func() { + var message wsrelay.Message + if errRead := conn.ReadJSON(&message); errRead != nil { + clientErrors <- errRead + return + } + captured <- []byte(message.Payload["body"].(string)) + clientErrors <- conn.WriteJSON(wsrelay.Message{ID: message.ID, Type: wsrelay.MessageTypeHTTPResp, Payload: map[string]any{"status": float64(http.StatusOK), "headers": map[string]any{"Content-Type": "application/json"}, "body": `{"totalTokens":1}`}}) + }() + executor := NewAIStudioExecutor(selected, "aistudio", relay) + if _, errCount := executor.CountTokens(ctx, &cliproxyauth.Auth{ID: "count-test", Provider: "aistudio"}, req, core.Options{SourceFormat: sdktranslator.FormatGemini}); errCount != nil { + t.Fatal(errCount) + } + if errClient := <-clientErrors; errClient != nil { + t.Fatal(errClient) + } + body := <-captured + for _, field := range []string{"generationConfig", "tools", "safetySettings"} { + if gjson.GetBytes(body, field).Exists() != configured { + t.Fatalf("count cleanup precedence for %s: %s", field, body) + } + } + } +} + +func TestCodexDuplexSteerPayloadBarrier(t *testing.T) { + for _, filter := range []bool{false, true} { + t.Run(map[bool]string{false: "override", true: "filter"}[filter], func(t *testing.T) { + captured := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if errUpgrade != nil { + t.Error(errUpgrade) + return + } + defer func() { _ = conn.Close() }() + if _, _, errRead := conn.ReadMessage(); errRead != nil { + t.Error(errRead) + return + } + if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"parent","output":[]}}`)); errWrite != nil { + t.Error(errWrite) + return + } + _, body, errRead := conn.ReadMessage() + if errRead != nil { + t.Error(errRead) + return + } + captured <- body + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + models := []config.PayloadModelRule{{Name: "*", Match: []map[string]any{{"type": "response.steer"}}}} + cfg := &config.Config{Codex: config.CodexConfig{ResponseSteering: true}, Payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: models, Params: map[string]any{"input": "configured", "type": "response.create"}}}}} + if filter { + cfg.Payload = config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: models, Params: []string{"input", "type"}}}} + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + input := make(chan core.WebsocketInput, 1) + ctx = core.WithWebsocketInput(core.WithDownstreamWebsocket(ctx), input) + executor := NewCodexWebsocketsExecutor(cfg) + executor.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "codex", Attributes: map[string]string{"api_key": "test", "base_url": server.URL, "websockets": "true"}} + result, errStream := executor.ExecuteStream(ctx, auth, core.Request{Model: "gpt-6-astra", Payload: []byte(`{"input":[]}`)}, core.Options{SourceFormat: sdktranslator.FormatCodex}) + if errStream != nil { + t.Fatal(errStream) + } + input <- core.WebsocketInput{Payload: []byte(`{"type":"response.steer","previous_response_id":"parent","input":"original"}`)} + for { + select { + case body := <-captured: + if gjson.GetBytes(body, "type").String() != "response.steer" || gjson.GetBytes(body, "previous_response_id").String() != "parent" { + t.Fatalf("steer framing changed: %s", body) + } + if filter && gjson.GetBytes(body, "input").Exists() || !filter && gjson.GetBytes(body, "input").String() != "configured" { + t.Fatalf("steer payload bypassed config: %s", body) + } + cancel() + for range result.Chunks { + } + return + case chunk, ok := <-result.Chunks: + if !ok || chunk.Err != nil { + t.Fatalf("stream ended before steer: %v", chunk.Err) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + }) + } +} + +func TestClaudePayloadConditionsObserveBuiltinThinkingRemoval(t *testing.T) { + for _, stream := range []bool{false, true} { + present := []config.PayloadModelRule{{Name: "*", Exist: []string{"thinking"}}} + absent := []config.PayloadModelRule{{Name: "*", NotExist: []string{"thinking"}}} + cfg := &config.Config{Payload: config.PayloadConfig{ + Default: []config.PayloadRule{{Models: absent, Params: map[string]any{"default_matched": true}}, {Models: present, Params: map[string]any{"default_unmatched": true}}}, + DefaultRaw: []config.PayloadRule{{Models: absent, Params: map[string]any{"default_raw_matched": `true`}}, {Models: present, Params: map[string]any{"default_raw_unmatched": `true`}}}, + Override: []config.PayloadRule{{Models: absent, Params: map[string]any{"override_matched": true}}, {Models: present, Params: map[string]any{"override_unmatched": true}}}, + OverrideRaw: []config.PayloadRule{{Models: absent, Params: map[string]any{"override_raw_matched": `true`}}, {Models: present, Params: map[string]any{"override_raw_unmatched": `true`}}}, + Filter: []config.PayloadFilterRule{{Models: absent, Params: []string{"metadata"}}, {Models: present, Params: []string{"system"}}}, + }} + payload := []byte(`{"model":"claude-opus-5","max_tokens":100,"thinking":{"type":"adaptive"},"tool_choice":{"type":"any"},"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hi"}]}`) + body := executeClaudeContextManagementRequest(t, cfg, payload, stream) + if gjson.GetBytes(body, "thinking").Exists() { + t.Fatalf("builtin did not remove forced-tool thinking: %s", body) + } + for _, prefix := range []string{"default", "default_raw", "override", "override_raw"} { + if !gjson.GetBytes(body, prefix+"_matched").Bool() || gjson.GetBytes(body, prefix+"_unmatched").Exists() { + t.Fatalf("%s matched pre-builtin conditions (stream=%v): %s", prefix, stream, body) + } + } + if gjson.GetBytes(body, "metadata").Exists() || !gjson.GetBytes(body, "system").Exists() { + t.Fatalf("filters matched pre-builtin conditions (stream=%v): %s", stream, body) + } + } +} diff --git a/internal/runtime/executor/xai_executor_execute.go b/internal/runtime/executor/xai_executor_execute.go index bd08febc5..de001c961 100644 --- a/internal/runtime/executor/xai_executor_execute.go +++ b/internal/runtime/executor/xai_executor_execute.go @@ -44,6 +44,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier()) url := strings.TrimSuffix(baseURL, "/") + "/responses" + prepared.body = prepared.finalizePayload(prepared.body) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body)) if err != nil { return resp, err @@ -195,6 +196,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier()) requestURL := strings.TrimSuffix(baseURL, "/") + "/responses/compact" + prepared.body = prepared.finalizePayload(prepared.body) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(prepared.body)) if err != nil { return nil, nil, nil, nil, err diff --git a/internal/runtime/executor/xai_executor_media.go b/internal/runtime/executor/xai_executor_media.go index f617c1c97..980423285 100644 --- a/internal/runtime/executor/xai_executor_media.go +++ b/internal/runtime/executor/xai_executor_media.go @@ -31,6 +31,7 @@ func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth } payload := normalizeXAIImageRefs(req.Payload) + payload = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", req.Payload, req, opts)(payload) url := strings.TrimSuffix(baseURL, "/") + endpointPath httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { @@ -84,6 +85,7 @@ func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth logXAIResolvedBaseURL(ctx, baseURL) payload := normalizeXAIImageRefs(req.Payload) + payload = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", req.Payload, req, opts)(payload) method := http.MethodPost endpointPath := xaiVideosGenerationsPath var body io.Reader = bytes.NewReader(payload) diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go index 131338c31..34606c74f 100644 --- a/internal/runtime/executor/xai_executor_request.go +++ b/internal/runtime/executor/xai_executor_request.go @@ -23,6 +23,7 @@ import ( ) type xaiPreparedRequest struct { + finalizePayload helps.PayloadFinalizer applyPatch *helps.ApplyPatchResponsesState baseModel string from sdktranslator.Format @@ -81,9 +82,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox return nil, err } - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - body = helps.ApplyPayloadConfigWithRequestForExecutor(e.cfg, e.Identifier(), baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + finalizePayload := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts) body = helps.SetStringIfDifferent(body, "model", baseModel) body = helps.SetBoolIfDifferent(body, "stream", stream) body, _ = sjson.DeleteBytes(body, "previous_response_id") @@ -158,6 +157,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox } return &xaiPreparedRequest{ + finalizePayload: finalizePayload, applyPatch: applyPatch, baseModel: baseModel, from: from, diff --git a/internal/runtime/executor/xai_executor_stream.go b/internal/runtime/executor/xai_executor_stream.go index 8ec809d14..d7918e1ee 100644 --- a/internal/runtime/executor/xai_executor_stream.go +++ b/internal/runtime/executor/xai_executor_stream.go @@ -38,6 +38,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier()) url := strings.TrimSuffix(baseURL, "/") + "/responses" + prepared.body = prepared.finalizePayload(prepared.body) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body)) if err != nil { return nil, err diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index 4b503f71f..daaa358d5 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -2675,11 +2675,14 @@ func TestXAIExecutorCompactUsesCompactEndpoint(t *testing.T) { if gotAccept != "application/json" { t.Fatalf("Accept = %q, want application/json", gotAccept) } - for _, field := range []string{"stream", "max_output_tokens", "temperature", "top_p", "top_k", "stop"} { + for _, field := range []string{"stream", "max_output_tokens", "temperature", "top_p", "stop"} { if gjson.GetBytes(gotBody, field).Exists() { t.Fatalf("%s exists in compact body: %s", field, string(gotBody)) } } + if got := gjson.GetBytes(gotBody, "top_k").Int(); got != 10 { + t.Fatalf("payload override top_k = %d, want 10", got) + } if got := gjson.GetBytes(gotBody, "input.0.encrypted_content").String(); got != validEncryptedContent { t.Fatalf("input.0.encrypted_content = %q, want valid sample; body=%s", got, string(gotBody)) } @@ -3092,6 +3095,7 @@ func TestXAIExecutorThinkingPayloadOverride(t *testing.T) { if errPrepare != nil { t.Fatalf("prepareResponsesRequest() error = %v", errPrepare) } + prepared.body = prepared.finalizePayload(prepared.body) if got := gjson.GetBytes(prepared.body, "reasoning.effort").String(); got != wantEffort { t.Fatalf("reasoning.effort = %q, want %q; body=%s", got, wantEffort, prepared.body) } diff --git a/internal/runtime/executor/xai_executor_tokens.go b/internal/runtime/executor/xai_executor_tokens.go index 0e4c9e2f5..2a3b5c9d4 100644 --- a/internal/runtime/executor/xai_executor_tokens.go +++ b/internal/runtime/executor/xai_executor_tokens.go @@ -22,6 +22,7 @@ func (e *XAIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("xai executor: tokenizer init failed: %w", err) } + prepared.body = prepared.finalizePayload(prepared.body) count, err := countXAIInputTokens(enc, prepared.body) if err != nil { return cliproxyexecutor.Response{}, fmt.Errorf("xai executor: token counting failed: %w", err) diff --git a/internal/runtime/executor/xai_websockets_executor.go b/internal/runtime/executor/xai_websockets_executor.go index 28402efff..93cebfef3 100644 --- a/internal/runtime/executor/xai_websockets_executor.go +++ b/internal/runtime/executor/xai_websockets_executor.go @@ -519,7 +519,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier()) wsHeaders := applyXAIWebsocketHeaders(ctx, http.Header{}, auth, token, prepared.sessionID, opts.Headers) - wsReqBody := buildXAIWebsocketRequestBody(prepared.body) + wsReqBody := buildXAIWebsocketRequestBody(prepared.body, prepared.finalizePayload) requestType := strings.TrimSpace(gjson.GetBytes(req.Payload, "type").String()) transcriptReset := strings.TrimSpace(gjson.GetBytes(wsReqBody, "previous_response_id").String()) == "" && (requestType != "response.append" || (idMapper != nil && idMapper.replayedCompactedTranscript)) @@ -628,7 +628,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox return nil, errBind } readCh = sess.activate(conn) - wsReqBodyRetry := buildXAIWebsocketRequestBody(prepared.body) + wsReqBodyRetry := buildXAIWebsocketRequestBody(prepared.body, prepared.finalizePayload) helps.RecordAPIWebsocketRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: wsURL, Method: "WEBSOCKET", @@ -1551,7 +1551,7 @@ func closeXAIWebsocketSession(sess *codexWebsocketSession, reason string) { } } -func buildXAIWebsocketRequestBody(body []byte) []byte { +func buildXAIWebsocketRequestBody(body []byte, finalizers ...helps.PayloadFinalizer) []byte { if len(body) == 0 { return nil } @@ -1564,6 +1564,10 @@ func buildXAIWebsocketRequestBody(body []byte) []byte { if strings.TrimSpace(gjson.GetBytes(wsReqBody, "previous_response_id").String()) != "" { wsReqBody, _ = sjson.DeleteBytes(wsReqBody, "instructions") } + if len(finalizers) > 0 && finalizers[0] != nil { + wsReqBody = finalizers[0](wsReqBody) + } + wsReqBody, _ = sjson.SetBytes(wsReqBody, "type", "response.create") return wsReqBody }