From 58d942829f05179412f879b338538e963cf9d4fd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:12:46 -0300 Subject: [PATCH] fix(translator): preserve reasoning-replay reasoning_content + repair 3 release-green test reds (#5122) Repairs 3 release-green test reds + test-masking; unblocks #5078. --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 3 +- open-sse/translator/helpers/openaiHelper.ts | 9 +++- open-sse/translator/index.ts | 35 ++++++++------ tests/snapshots/provider/translate-path.json | 46 +++++++++++++++++++ ...ex-quota-sync-no-proactive-refresh.test.ts | 8 +++- tests/unit/db-core-init.test.ts | 14 +++++- 7 files changed, 97 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4840b9eb13..eb5eb0d340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(sse): keep `output_text`-only Responses bodies from being dropped/false-502'd** — some upstreams return a shorthand Responses body whose answer is only in `output_text` with an empty `output[]`. `sanitizeResponsesApiResponse` discarded the text, so the response then tripped the malformed-200 guard. The sanitizer now synthesizes an `output[]` message item from a non-empty `output_text` (complements the Claude-native fix in #5108; both stem from #4942). - **fix(executors): preserve a lone caller-supplied `Anthropic-Version` header casing** — the case-variant dedupe (#4846) unconditionally rewrote `Anthropic-Version`/`Anthropic-Beta` to lowercase even when only one variant was present, clobbering the caller's header. Dedupe now runs only when both case variants coexist (the actual undici-merge collision it was meant to fix). - **fix(responses):** default `text.format` to `{ type: "text" }` for openai-compatible **responses** providers — some Responses-compatible upstreams (e.g. LM Studio) reject a `text` object missing `text.format` with a 400 `missing_required_parameter`; the default executor now fills the Responses-API default before forwarding (guarded to `openai-compatible-*responses*`, never overwriting an existing format). (thanks @StevanusPangau) +- **fix(translator): stop stripping client-provided `reasoning_content` for reasoning-replay providers** — the #4849 agentic-context strip (which drops `reasoning_content` from tool-call assistant turns to avoid O(n²) token growth) ran unconditionally, so replay providers (DeepSeek V4, Kimi K2, Qwen-Thinking, etc.) lost the client's reasoning and the reasoning-replay cache then overwrote it with a stale cached value (and such upstreams 400 without the original reasoning). The strip now skips reasoning-replay targets while non-reasoning providers keep the O(n²) protection. --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 62191b70e1..18ae9c2a3b 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_26_relgreen_db_test": "Release-green follow-up: tests/unit/db-core-init.test.ts 867->877 (+10 = the invalid-DATA_DIR test now captures the rejection and asserts both Error type AND message — restores net-neutral assert count after #5117's consolidation, satisfying check:test-masking — instead of a single assert.rejects).", "_rebaseline_2026_06_26_5074_fusion_editor": "PR #5074 own growth: src/app/(dashboard)/dashboard/combos/page.tsx 4485->4594 (+109 = the Fusion judgeModel + fusionTuning editor block — text/number inputs wired through updateFusionTuning, schema-validated) and tests/unit/combo-config.test.ts 800->881 (testFrozen add: 5 new Fusion-config schema tests). Cohesive UI block at the strategy-conditional editor; combos/page.tsx structural shrink tracked in #3501. Covered by combo-config.test.ts.", "_rebaseline_2026_06_26_5101_responses_textformat": "PR #5101 own growth: open-sse/executors/default.ts 859->876 (+17 = defaultResponsesTextFormat — fills the Responses-API default text.format for openai-compatible-*responses* providers so LM-Studio-style upstreams stop 400ing; guarded, never overwrites an existing format). Irreducible executor chokepoint next to applyJsonSchemaFallback; covered by tests/unit/responses-default-text-format.test.ts.", "_rebaseline_2026_06_26_5117_basereds": "Base-red repair #5117 own growth + sibling drift: open-sse/handlers/responseSanitizer.ts 1122->1139 (+17 = synthesize an output[] message item from an output_text-only Responses body so the answer is not dropped/false-502'd; #4942 regression, covered by tests/unit/response-sanitizer.test.ts). src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 866->868 (+2 sibling drift from a separately-merged PR, already on the release tip; rebaselined here to green the file-size gate).", @@ -253,7 +254,7 @@ "tests/unit/chatgpt-web.test.ts": 2855, "tests/unit/combo-routing-engine.test.ts": 3213, "tests/unit/combo-strategy-fallbacks.test.ts": 880, - "tests/unit/db-core-init.test.ts": 867, + "tests/unit/db-core-init.test.ts": 877, "tests/unit/db-migration-runner.test.ts": 1491, "tests/unit/db-settings-crud.test.ts": 941, "tests/unit/deepseek-web.test.ts": 1081, diff --git a/open-sse/translator/helpers/openaiHelper.ts b/open-sse/translator/helpers/openaiHelper.ts index cb92b2c427..5c2a9765f4 100644 --- a/open-sse/translator/helpers/openaiHelper.ts +++ b/open-sse/translator/helpers/openaiHelper.ts @@ -37,6 +37,11 @@ export function filterToOpenAIFormat(body, opts = {}) { // requested upstream, keep the `cache_control` field on each content block // instead of destructuring it away. `signature` is always stripped. const preserveCacheControl = opts?.preserveCacheControl === true; + // #4849 strips reasoning_content from tool-call assistant turns to stop O(n^2) + // context growth — but reasoning-replay providers (DeepSeek V4, Kimi K2, etc.) + // REQUIRE the client's reasoning_content to be passed back, so keep it for them + // (the caller sets this when the routed model needs reasoning replay). + const preserveReasoningContent = opts?.preserveReasoningContent === true; if (!body.messages || !Array.isArray(body.messages)) return body; body.messages = body.messages.map((msg) => { @@ -50,8 +55,10 @@ export function filterToOpenAIFormat(body, opts = {}) { // Keep assistant messages with tool_calls, but strip reasoning_content — // reasoning blobs inflate context on every subsequent agentic turn (O(n^2)). + // Exception: reasoning-replay providers must keep client-provided + // reasoning_content (they 400 without it), so preserve it when requested. if (msg.role === "assistant" && msg.tool_calls) { - if (msg.reasoning_content !== undefined) { + if (!preserveReasoningContent && msg.reasoning_content !== undefined) { const { reasoning_content, ...cleanMsg } = msg; return cleanMsg; } diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index c43cbaa399..16b6fdf1a3 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -264,6 +264,23 @@ export function translateRequest( } } + // Resolve reasoning-replay status up-front: it gates both the reasoning_content + // strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for + // replay providers) and the cache re-injection further down. + const normalizedProvider = String(provider ?? ""); + const normalizedModel = String(model ?? ""); + const resolvedCapabilities = getResolvedModelCapabilities({ + provider: normalizedProvider, + model: normalizedModel, + }); + const isReasoner = requiresReasoningReplay({ + provider: normalizedProvider, + model: normalizedModel, + thinkingEnabled: hasThinkingConfig(result), + supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), + interleavedField: resolvedCapabilities?.interleavedField ?? null, + }); + // Always normalize to clean OpenAI format when target is OpenAI // This handles hybrid requests (e.g., OpenAI messages + Claude tools) if (targetFormat === FORMATS.OPENAI) { @@ -274,6 +291,8 @@ export function translateRequest( preserveCacheControl: options?.preserveCacheControl === true && providerHonorsOpenAIFormatCacheControl(provider), + // #4849 regression guard: keep client reasoning_content for replay providers. + preserveReasoningContent: isReasoner, }); } @@ -333,19 +352,9 @@ export function translateRequest( // clients omit it from the conversation history. Without this, DeepSeek V4 // returns 400: "The reasoning_content in the thinking mode must be passed // back to the API." - const normalizedProvider = String(provider ?? ""); - const normalizedModel = String(model ?? ""); - const resolvedCapabilities = getResolvedModelCapabilities({ - provider: normalizedProvider, - model: normalizedModel, - }); - const isReasoner = requiresReasoningReplay({ - provider: normalizedProvider, - model: normalizedModel, - thinkingEnabled: hasThinkingConfig(result), - supportsReasoning: supportsReasoning({ provider: normalizedProvider, model: normalizedModel }), - interleavedField: resolvedCapabilities?.interleavedField ?? null, - }); + // isReasoner / normalizedProvider / normalizedModel / resolvedCapabilities were + // resolved up-front (before the OpenAI-format filter) so the #4849 reasoning strip + // could honor reasoning-replay providers. if (isReasoner && result.messages && Array.isArray(result.messages)) { const canReplayReasoningOnly = isReasoningOnlyReplayTarget(normalizedProvider, normalizedModel); diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 8a81378082..d569df6622 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1156,6 +1156,29 @@ "stream": "devin://acp/stdio" } }, + "dgrid": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://api.dgrid.ai/v1/chat/completions", + "stream": "https://api.dgrid.ai/v1/chat/completions" + } + }, "dify": { "format": "openai", "headers": { @@ -3126,6 +3149,29 @@ "stream": "https://www.phind.com/api/chat" } }, + "pioneer": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "x-api-key": "" + }, + "nonStream": { + "Content-Type": "application/json", + "x-api-key": "" + }, + "oauth": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "x-api-key": "" + } + }, + "url": { + "nonStream": "https://api.pioneer.ai/v1/chat/completions", + "stream": "https://api.pioneer.ai/v1/chat/completions" + } + }, "pollinations": { "format": "openai", "headers": { diff --git a/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts b/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts index 59db4a27a7..e82f9de0dc 100644 --- a/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts +++ b/tests/unit/codex-quota-sync-no-proactive-refresh.test.ts @@ -73,7 +73,11 @@ test("non-rotating OAuth provider is still refreshed proactively from quota-sync const origRefresh = exec.refreshCredentials; let refreshCalls = 0; exec.needsRefresh = () => true; - // null -> non-github -> surfaces a 401; proves the refresh was actually attempted. + // Refresh returns null with no usable access token left -> surfaces a 401; + // proves the refresh was actually attempted (the codex no-rotate gate did not + // block this non-rotating provider). Note: since #4786 a *present* accessToken + // would gracefully fall back for any OAuth provider, so we leave it empty here + // to keep the 401-throw branch reachable and the gate assertion meaningful. exec.refreshCredentials = async () => { refreshCalls++; return null; @@ -83,7 +87,7 @@ test("non-rotating OAuth provider is still refreshed proactively from quota-sync refreshAndUpdateCredentials({ id: "cursor-1", provider: "cursor", - accessToken: "a", + accessToken: "", refreshToken: "r", tokenExpiresAt: new Date(2000).toISOString(), providerSpecificData: {}, diff --git a/tests/unit/db-core-init.test.ts b/tests/unit/db-core-init.test.ts index 7841abd2b5..7b64dfa1d7 100644 --- a/tests/unit/db-core-init.test.ts +++ b/tests/unit/db-core-init.test.ts @@ -515,8 +515,18 @@ test("invalid DATA_DIR (a file where a dir is expected) surfaces as a startup fa // regular file is a non-permission misconfiguration (EEXIST/ENOTDIR), which // resolveWritableDataDir rethrows by design (only EACCES/EPERM fall back), so // the failure now surfaces at import time, not lazily from getDbInstance(). - await assert.rejects( - withEnv({ DATA_DIR: fileAsDir }, () => importFresh("src/lib/db/core.ts")), + let caught: unknown; + await withEnv({ DATA_DIR: fileAsDir }, () => importFresh("src/lib/db/core.ts")).then( + () => { + throw new Error("expected importing db/core with an invalid DATA_DIR to reject"); + }, + (err) => { + caught = err; + } + ); + assert.ok(caught instanceof Error, "an invalid DATA_DIR must surface as a thrown Error"); + assert.match( + String((caught as Error).message), /unable to open database file|ENOTDIR|EEXIST|not a directory|file already exists/i ); } finally {