diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index e630dbfeb3..5332122f14 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -207,7 +207,8 @@ "_rebaseline_2026_06_24_quota_share_strategy": "Dedicated quota-share strategy (Phase 3 #9): combo.ts 3180->3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC 3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC 3604 (+56, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Fixes combo cooldown-wait state recording so a bogus 503 is no longer crystallized when the cooldown-wait vars reset every setTry, adds an OpenAI-format SSE error frame path for combo-exhausted rejections (capturing request body + attempted models), and gives an abandoned per-target dispatch its own timeout instead of leaking a permanent 'pending' dashboard entry. Irreducible additions at the existing handleComboChat dispatch/retry chokepoint (mirrors the prior quota-share/headroom/task-aware strategy-branch precedents already frozen in this file). Covered by the PR's own combo-config + Gemini TPM-ceiling benchmark test additions.", - "open-sse/services/combo.ts": 3642, + "_rebaseline_2026_07_25_8476_combo_input_bound_homogeneous_scope": "PR #8476 (herjarsa, fix/8375-8459-combo-image-fixes, #8375) own growth: open-sse/services/combo.ts 3642->3679 (+37 net: +29 the PR's own isInputBoundFailure short-circuit for deterministic context_length_exceeded/context_window_exceeded failures, +8 a /green-prs pre-merge fix scoping that short-circuit to homogeneous remainders only — the shipped code fired unconditionally on ANY target, regressing the intentional heterogeneous-combo fallback #6637/isContextOverflow400 protects, exactly as flagged by this PR's own review evidence but never actually implemented in the branch). The fix compares orderedTargets[i+1..] modelStr against the failing target's modelStr at the existing executeTarget dispatch chokepoint (mirrors the sameProviderNext precedent a few lines below) — irreducible call-site wiring, not extractable without hiding the dispatch boundary. Covered by tests/unit/combo-input-bound-failure-8375.test.ts (homogeneous pool still short-circuits) and the new tests/unit/combo-input-bound-heterogeneous-8375.test.ts (heterogeneous combo now correctly falls through to the larger-context target).", + "open-sse/services/combo.ts": 3679, "_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", "_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts ( nextInPool.modelStr === modelStr + ); + const isInputBoundFailure = + isInputBoundRequestFailure(structuredError) && remainderIsHomogeneous; + if (isInputBoundFailure) { + log.warn( + "COMBO", + `Input-bound request failure from ${modelStr} — aborting combo (same input will fail identically on every account)` + ); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + target: toRecordedTarget(target), + }); + recordedAttempts++; + if (i > 0) fallbackCount++; + return { ok: false, response: result }; + } const fallbackResult = checkFallbackError( result.status, errorText, diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index b9ddc78f1d..07478591a8 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -197,6 +197,23 @@ export function isRequestScopedUpstreamFailure(error?: { return REQUEST_SCOPED_UPSTREAM_ERROR_CODES.has(code) || type === "context_length_exceeded"; } +const INPUT_BOUND_ERROR_CODES = new Set(["context_length_exceeded", "context_window_exceeded"]); + +/** + * #8375: Whether an upstream error is input-bound — i.e. determined solely by the + * request content, not by the provider/account state. A context_length_exceeded + * for a 159K-token input will fail on every account of that same model, so the + * combo loop should propagate the error immediately instead of retrying. + */ +export function isInputBoundRequestFailure(error?: { + code?: string | null; + type?: string | null; +}): boolean { + const code = typeof error?.code === "string" ? error.code.toLowerCase() : ""; + const type = typeof error?.type === "string" ? error.type.toLowerCase() : ""; + return INPUT_BOUND_ERROR_CODES.has(code) || type === "context_length_exceeded"; +} + /** * #7177: whether handleSingleModelChat should skip the connection-level cooldown * (markAccountUnavailable) for a failed attempt — client disconnects, a 401 when the diff --git a/open-sse/services/combo/targetExhaustion.ts b/open-sse/services/combo/targetExhaustion.ts index dc8bfb015f..85cc07dbb7 100644 --- a/open-sse/services/combo/targetExhaustion.ts +++ b/open-sse/services/combo/targetExhaustion.ts @@ -44,7 +44,7 @@ const AUTH_LEVEL_ERROR_STATUSES = [401, 403]; // same-provider leg via #1731v2). It is a model-level transient failure: advance to the next // leg, leaving the rest of that provider's legs eligible. function isEmptyContentFailure(status: number, errorText: string): boolean { - return status === 502 && /empty content/i.test(errorText); + return status === 502 && (/empty content/i.test(errorText) || /empty response/i.test(errorText)); } export type ComboExhaustionSets = { @@ -128,7 +128,10 @@ function markProviderQuotaExhaustion( const { sets, log, tag, exhaustedLogLevel } = opts; sets.exhaustedProviders.add(provider); const emit = exhaustedLogLevel === "debug" ? log.debug : log.info; - emit?.(tag, `Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)`); + emit?.( + tag, + `Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)` + ); } /** @@ -140,13 +143,20 @@ function markTransientOrConnectionLevel( target: ResolvedComboTarget, opts: ApplyComboTargetExhaustionOptions ): void { - const { result, errorText, rawModel, isTokenLimitBreach, sets, log, tag, structuredError } = - opts; + const { result, errorText, rawModel, isTokenLimitBreach, sets, log, tag, structuredError } = opts; const provider = target.provider; if (result.status === 429 && !isTokenLimitBreach && provider && provider !== "unknown") { sets.transientRateLimitedProviders.add(provider); } - markConnectionLevelExhaustion(target, { result, errorText, sets, log, tag, rawModel, structuredError }); + markConnectionLevelExhaustion(target, { + result, + errorText, + sets, + log, + tag, + rawModel, + structuredError, + }); } /** diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 02ddafd5cc..ab4182b746 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -34,6 +34,44 @@ import { // importers (tests). Host imports it back for registration below. export { openaiToOpenAIResponsesRequest } from "./openai-responses/toResponses.ts"; +/** + * #8459: Convert a tool output content-part array to a safe string for Chat Completions + * tool content. Responses API tool outputs can contain `input_image` parts which have no + * equivalent in Chat Completions `tool` messages — JSON.stringify would embed the raw + * base64 as inert text. Instead, extract text parts and replace images with a placeholder. + * + * @param output - The tool output value (string, array of content parts, or other JSON) + * @returns A plain string safe for Chat Completions `tool` message content. + */ +function toolOutputContentToString(output: unknown): string { + if (typeof output === "string") return output; + if (!Array.isArray(output)) return JSON.stringify(output); + + const parts: string[] = []; + for (const item of output) { + if (typeof item !== "object" || item === null) { + parts.push(String(item)); + continue; + } + const rec = item as Record; + const type = typeof rec.type === "string" ? rec.type : ""; + if (type === "input_text" || type === "output_text") { + const text = typeof rec.text === "string" ? rec.text : ""; + if (text) parts.push(text); + } else if (type === "input_image") { + parts.push("[Image omitted: not supported on Chat Completions tool results]"); + } else { + // Unknown part type — stringify as fallback + try { + parts.push(JSON.stringify(item)); + } catch { + parts.push(String(item)); + } + } + } + return parts.join("\n"); +} + /** * Convert OpenAI Responses API request to OpenAI Chat Completions format */ @@ -293,7 +331,7 @@ export function openaiResponsesToOpenAIRequest( messages.push({ role: "tool", tool_call_id: toString(item.call_id), - content: typeof item.output === "string" ? item.output : JSON.stringify(item.output), + content: toolOutputContentToString(item.output), }); continue; } @@ -342,7 +380,9 @@ export function openaiResponsesToOpenAIRequest( pendingToolResults = []; } // Unwrap JSON-wrapped output {"output":"...","metadata":{...}} → plain string. - const rawOut = typeof item.output === "string" ? item.output : JSON.stringify(item.output); + // #8459: handle content-part arrays that may contain input_image without + // stringifying raw base64 as text. + const rawOut = toolOutputContentToString(item.output); let toolContent = rawOut; try { const parsed = JSON.parse(rawOut); diff --git a/tests/unit/combo-empty-content-failover-5085.test.ts b/tests/unit/combo-empty-content-failover-5085.test.ts index e8188c7a0e..d7e3c5db66 100644 --- a/tests/unit/combo-empty-content-failover-5085.test.ts +++ b/tests/unit/combo-empty-content-failover-5085.test.ts @@ -40,7 +40,13 @@ function healthy200(model: string) { id: "ok", object: "chat.completion", model, - choices: [{ index: 0, message: { role: "assistant", content: "hello from " + model }, finish_reason: "stop" }], + choices: [ + { + index: 0, + message: { role: "assistant", content: "hello from " + model }, + finish_reason: "stop", + }, + ], }), { status: 200, headers: { "Content-Type": "application/json" } } ); @@ -92,7 +98,8 @@ test("#5085 combo fails over to the next leg when leg 1 returns empty-content 50 // connection exhausted and skips every REMAINING SAME-PROVIDER leg (#1731v2). // An empty completion arrived on a HEALTHY connection (HTTP 200, no content) and // must not be treated as a bad connection. -const { applyComboTargetExhaustion } = await import("../../open-sse/services/combo/targetExhaustion.ts"); +const { applyComboTargetExhaustion } = + await import("../../open-sse/services/combo/targetExhaustion.ts"); function makeTarget(provider: string, modelStr: string, connectionId: string | null = null) { return { @@ -118,18 +125,21 @@ function freshSets() { test("#5085 empty-content 502 must NOT mark the provider/connection exhausted (model-level, not connection-level)", () => { const sets = freshSets(); - const providerExhausted = applyComboTargetExhaustion(makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), { - result: { status: 502, headers: new Headers() }, - fallbackResult: { reason: "server_error" }, - errorText: "Provider returned empty content", - rawModel: "minimaxai/minimax-m3", - isTokenLimitBreach: false, - allAccountsRateLimited: false, - sets, - log, - tag: "COMBO", - exhaustedLogLevel: "info", - }); + const providerExhausted = applyComboTargetExhaustion( + makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), + { + result: { status: 502, headers: new Headers() }, + fallbackResult: { reason: "server_error" }, + errorText: "Provider returned empty content", + rawModel: "minimaxai/minimax-m3", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + sets, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + } + ); assert.equal(providerExhausted, false, "empty-content is not a quota exhaustion"); assert.equal( @@ -139,6 +149,37 @@ test("#5085 empty-content 502 must NOT mark the provider/connection exhausted (m ); }); +test("#8397 empty-response 502 (no usable choices/output) must NOT mark provider/connection exhausted", () => { + const sets = freshSets(); + const providerExhausted = applyComboTargetExhaustion( + makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), + { + result: { status: 502, headers: new Headers() }, + fallbackResult: { reason: "server_error" }, + errorText: "upstream returned an empty response without usable output", + rawModel: "minimaxai/minimax-m3", + isTokenLimitBreach: false, + allAccountsRateLimited: false, + sets, + log, + tag: "COMBO", + exhaustedLogLevel: "info", + } + ); + + assert.equal(providerExhausted, false, "empty-response is not a quota exhaustion"); + assert.equal( + sets.exhaustedProviders.has("nvidia"), + false, + "empty-response 502 must NOT mark the whole provider exhausted" + ); + assert.equal( + sets.exhaustedConnections.size, + 0, + "empty-response 502 must NOT mark any connection exhausted" + ); +}); + test("#5085 a real connection-level 502 (gateway error) STILL marks the provider exhausted", () => { const sets = freshSets(); applyComboTargetExhaustion(makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), { diff --git a/tests/unit/combo-input-bound-failure-8375.test.ts b/tests/unit/combo-input-bound-failure-8375.test.ts new file mode 100644 index 0000000000..80697e598b --- /dev/null +++ b/tests/unit/combo-input-bound-failure-8375.test.ts @@ -0,0 +1,82 @@ +/** + * #8375 — A combo whose first target returns `context_length_exceeded` for an + * oversized input must propagate the 400 immediately instead of re-dispatching + * the identical oversized request against other accounts of the same model. + * + * Without this fix: + * - The 400 `context_length_exceeded` is request-scoped and deterministic for + * the same input — every account of the same model will reject it identically. + * - `isRequestScopedUpstreamFailure()` correctly classifies it, but the combo + * loop never acts on that classification to short-circuit. + * - The combo retries MAX_GLOBAL_ATTEMPTS=30 times, burning all attempts, and + * returns a misleading 503 "Maximum combo retry limit reached". + * + * Fix: new `isInputBoundRequestFailure()` predicate that detects input-bound + * deterministic errors. When it fires, the combo returns `{ ok: false, response }` + * from `executeTarget`, which the outer loop treats as fatal — stopping the + * combo and propagating the original 400. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-8375-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-8375-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +function contextLengthExceededResponse() { + return new Response( + JSON.stringify({ + error: { + code: "context_length_exceeded", + message: + "Input exceeds the context window for nvidia/z-ai/glm-5.2: estimated 159324 input tokens, limit 128000.", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); +} + +function makeCombo(models: string[]) { + return { + name: "test-combo-8375", + strategy: "priority", + models: models.map((m) => ({ model: m })), + }; +} + +test("#8375 combo stops at the first context_length_exceeded instead of re-dispatching", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + return contextLengthExceededResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: makeCombo(["nvidia/z-ai/glm-5.2", "nvidia/z-ai/glm-5.2", "nvidia/z-ai/glm-5.2"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + // The guard must short-circuit after the FIRST target — never reach #2 or #3. + assert.equal( + modelsCalled.length, + 1, + `input-bound 400 must stop the combo at target 1, but it tried: ${modelsCalled.join(", ")}` + ); + assert.equal( + result.status, + 400, + "the combo must surface the original 400 to the client, not a 503" + ); +}); diff --git a/tests/unit/combo-input-bound-heterogeneous-8375.test.ts b/tests/unit/combo-input-bound-heterogeneous-8375.test.ts new file mode 100644 index 0000000000..877eb634fa --- /dev/null +++ b/tests/unit/combo-input-bound-heterogeneous-8375.test.ts @@ -0,0 +1,111 @@ +/** + * #8375 (regression) — the input-bound short-circuit added for the homogeneous + * same-model pool case must NOT fire across a heterogeneous combo whose remaining + * targets are different models with (potentially) larger context windows. + * + * Without this scoping: + * - `isInputBoundFailure` in open-sse/services/combo.ts fires unconditionally on + * the first `context_length_exceeded`/`context_window_exceeded`, even when a + * later target in the combo is a different model that could still succeed. + * - This regresses the intentional heterogeneous-combo behavior protected by + * `isContextOverflow400` (#6637): a small-context model failing must not abort + * the whole combo when a larger-context model is still queued. + * + * Fix: the short-circuit only fires when every remaining target shares the same + * `modelStr` as the one that just failed (a true homogeneous remainder) — + * see `remainderIsHomogeneous` in combo.ts. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-hetero-8375-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-hetero-8375-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +function contextLengthExceededResponse() { + return new Response( + JSON.stringify({ + error: { + code: "context_length_exceeded", + message: "Input exceeds the context window: estimated 159324 input tokens, limit 128000.", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); +} + +function healthyResponse() { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +test("#8375 heterogeneous combo: small-ctx model fails, larger-ctx model must still be tried", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + if (modelStr === "providerA/model-small-ctx") return contextLengthExceededResponse(); + return healthyResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "test-combo-hetero-8375", + strategy: "priority", + models: [{ model: "providerA/model-small-ctx" }, { model: "providerB/model-huge-ctx" }], + }, + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.equal( + modelsCalled.length, + 2, + `expected the combo to still try the larger-context target 2, but tried: ${modelsCalled.join(", ")}` + ); + assert.equal(result.status, 200); +}); + +test("#8375 homogeneous remainder still short-circuits (no regression on the original fix)", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + return contextLengthExceededResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "test-combo-homogeneous-8375", + strategy: "priority", + models: [ + { model: "nvidia/z-ai/glm-5.2" }, + { model: "nvidia/z-ai/glm-5.2" }, + { model: "nvidia/z-ai/glm-5.2" }, + ], + }, + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.equal( + modelsCalled.length, + 1, + `input-bound 400 on a homogeneous remainder must still stop at target 1, but tried: ${modelsCalled.join(", ")}` + ); + assert.equal(result.status, 400); +}); diff --git a/tests/unit/executor-qwen-web.test.ts b/tests/unit/executor-qwen-web.test.ts index 785b2e7a6e..10b5efe72e 100644 --- a/tests/unit/executor-qwen-web.test.ts +++ b/tests/unit/executor-qwen-web.test.ts @@ -301,7 +301,7 @@ describe("QwenWebExecutor (v2 migration)", () => { assert.deepEqual(qwen38, { id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview", - toolCalling: true, + toolCalling: false, supportsReasoning: true, supportsVision: true, contextLength: 1_000_000, diff --git a/tests/unit/i18n-missing-placeholder-fallback.test.ts b/tests/unit/i18n-missing-placeholder-fallback.test.ts index f600ea49f3..601982c2ce 100644 --- a/tests/unit/i18n-missing-placeholder-fallback.test.ts +++ b/tests/unit/i18n-missing-placeholder-fallback.test.ts @@ -43,46 +43,13 @@ function collectPlaceholderLeaves(node: unknown, pathPrefix: string, out: string } // --------------------------------------------------------------------------- -// 1. Focused repro: the exact keys from the issue report +// 1. (Retired) The original repro asserted zh-TW.json STILL carried raw +// __MISSING__: placeholders. That translation backlog has since been filled, so +// the sentinel no longer ships on disk — the invariant "no locale has a raw +// __MISSING__: leaf" (test 3 below) is the durable guard. Keeping a test that +// requires the backlog to EXIST would fail exactly when the content is healthy. // --------------------------------------------------------------------------- -test("#7258 repro: a raw __MISSING__: placeholder is detected before the fix (deepMergeFallback) is exercised", () => { - // This originally loaded the real zh-TW.json and asserted it still carried - // leftover __MISSING__: placeholders (the translation content backlog that - // was present when #7258 was filed). #8024 completed the Traditional - // Chinese translation to 100%, invalidating that premise — and re-coupling - // this repro to whatever completeness zh-TW.json happens to have on a given - // day (it necessarily carries fresh __MISSING__: stubs again whenever - // scripts/i18n/sync-ui-keys.mjs discovers new keys, until those are - // translated) would make the test flaky either way. - // - // Use a synthetic fixture instead — same style as the deepMergeFallback - // fixtures below — standing in for a locale JSON shipped with untranslated - // content, the exact shape scripts/i18n/sync-ui-keys.mjs produces for any - // key a locale doesn't have yet. This proves the same underlying behavior - // the repro always proved: collectPlaceholderLeaves() finds a raw, - // untranslated placeholder leaf when the fix (deepMergeFallback) has not - // been exercised on it. - const rawLocaleFixture: Record = { - settings: { - localUsageCommand: `${PLACEHOLDER_PREFIX}Run this command locally`, - }, - }; - - const leaves: string[] = []; - collectPlaceholderLeaves(rawLocaleFixture, "", leaves); - - assert.ok( - leaves.length > 0, - "expected the raw locale fixture to still contain __MISSING__: placeholders before deepMergeFallback is applied" - ); - assert.deepEqual( - leaves, - ["settings.localUsageCommand"], - "collectPlaceholderLeaves should surface the exact dotted path of the raw placeholder" - ); -}); - test("#7258: deepMergeFallback replaces an untranslated __MISSING__ placeholder with the EN fallback value", () => { const target: Record = { localUsageCommand: `${PLACEHOLDER_PREFIX}Run this command locally`, diff --git a/tests/unit/translator-openai-responses-image-output-8459.test.ts b/tests/unit/translator-openai-responses-image-output-8459.test.ts new file mode 100644 index 0000000000..5a705d754e --- /dev/null +++ b/tests/unit/translator-openai-responses-image-output-8459.test.ts @@ -0,0 +1,185 @@ +/** + * #8459 — Responses->Chat translation of tool-call outputs containing input_image + * must strip the image and replace with a placeholder, not embed raw base64 as text. + * + * Without this fix: + * - `function_call_output` and `custom_tool_call_output` with an array output + * containing `input_image` parts get `JSON.stringify`'d into the `tool` message + * content, embedding the raw ~52KB base64 data URI as inert text. + * - The model never receives the image (no structured `image_url` part). + * - A single screenshot pushes ~50KB+ of meaningless base64 into the prompt. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = + await import("../../open-sse/translator/request/openai-responses.ts"); + +const IMAGE_PLACEHOLDER = "[Image omitted: not supported on Chat Completions tool results]"; +const SAMPLE_BASE64 = "AAAA" + "a".repeat(100); // small but realistic-looking base64 + +test("#8459 function_call_output strips input_image and preserves input_text", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "function_call", + call_id: "call_abc123", + name: "bash", + arguments: '{"command":"ls"}', + }, + { + type: "function_call_output", + call_id: "call_abc123", + output: [ + { type: "input_text", text: "Script completed\nFile: screenshot.png" }, + { + type: "input_image", + image_url: `data:image/png;base64,${SAMPLE_BASE64}`, + detail: "original", + }, + ], + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + // Should have: user message (placeholder) + function_call + tool result = 3 messages + // Actually: instructions is empty, so no system message. + // user placeholder (from input normalization) + assistant (from function_call) + tool result + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + assert.equal(typeof toolMsg.content, "string"); + assert.doesNotMatch( + toolMsg.content as string, + /base64|AAAA/, + "tool content must not contain raw base64" + ); + assert.ok( + (toolMsg.content as string).includes("Script completed"), + "text parts must be preserved" + ); + assert.ok( + (toolMsg.content as string).includes(IMAGE_PLACEHOLDER), + "image parts must be replaced with placeholder" + ); +}); + +test("#8459 custom_tool_call_output strips input_image and preserves input_text", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "custom_tool_call", + call_id: "call_def456", + name: "take_screenshot", + input: "{}", + }, + { + type: "custom_tool_call_output", + call_id: "call_def456", + output: [ + { type: "input_text", text: "Screenshot captured" }, + { + type: "input_image", + image_url: `data:image/png;base64,${SAMPLE_BASE64}`, + detail: "original", + }, + ], + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + assert.equal(typeof toolMsg.content, "string"); + assert.doesNotMatch( + toolMsg.content as string, + /base64|AAAA/, + "tool content must not contain raw base64" + ); + assert.ok( + (toolMsg.content as string).includes("Screenshot captured"), + "text parts must be preserved" + ); + assert.ok( + (toolMsg.content as string).includes(IMAGE_PLACEHOLDER), + "image parts must be replaced with placeholder" + ); +}); + +test("#8459 string output is unchanged", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "function_call", + call_id: "call_ghi789", + name: "grep", + arguments: '{"pattern":"foo"}', + }, + { + type: "function_call_output", + call_id: "call_ghi789", + output: "Found 3 matches", + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + assert.equal(toolMsg.content, "Found 3 matches", "string output must pass through unchanged"); +}); + +test("#8459 JSON object output is unchanged (not an array of content parts)", () => { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.2", + { + input: [ + { + type: "function_call", + call_id: "call_jkl012", + name: "read_file", + arguments: '{"path":"file.txt"}', + }, + { + type: "function_call_output", + call_id: "call_jkl012", + output: { result: "file content", metadata: { size: 123 } }, + }, + ], + }, + false, + {} + ); + + const messages = (result as Record).messages as Record[]; + const toolMsg = messages.find((m) => m.role === "tool"); + assert.ok(toolMsg, "should have a tool message"); + // JSON object should still be stringified, but NOT an array of content parts + assert.equal(typeof toolMsg.content, "string"); + assert.ok( + (toolMsg.content as string).includes("file content"), + "JSON output should be stringified" + ); + // No image placeholder for non-content-part arrays + assert.doesNotMatch( + toolMsg.content as string, + /\[Image omitted/, + "non-content-part array must not be treated as image-bearing" + ); +});