mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
fix: combo input-bound, Responses->Chat image strip, qwen-web toolCalling, empty-response exhaustion (#8476)
* test(tail): retire stale i18n __MISSING__ repro + fix qianfan website URL
Base-red slice 6, rebased onto the advanced release/v3.8.49 (91fd5f9). The oauth
grok-cli #7610 guard was already fixed on the base by #8027 (it reads the warning
from grokCliAuthJson.ts) — dropped from this slice to avoid a conflicting duplicate.
Remaining two, still red on the current base:
- i18n #7258: the "focused repro" asserted zh-TW.json STILL carries raw __MISSING__:
placeholders. That backlog was filled (the "no locale has a raw __MISSING__: leaf"
invariant is the durable guard); retired the now-inverted repro.
- qianfan: Baidu renamed the product page (product/wenxinworkshop -> product-s/
qianfan_home); updated the expected website URL.
Validated (clean env): i18n 4/0, qianfan 5/0; oauth-modal-grok 2/0 already green on base.
* fix(resilience): short-circuit combo on input-bound failures (context_length_exceeded) (#8375)
isInputBoundRequestFailure() predicate detects deterministic input-bound
errors (context_length_exceeded/context_window_exceeded). The combo loop
propagates the original 400 immediately instead of burning MAX_GLOBAL_ATTEMPTS
retrying identical oversized inputs against every account.
Test: combo-input-bound-failure-8375.test.ts (1 test, 2 assertions)
* fix(resilience): add early-exit in combo dispatcher for input-bound failures (#8375)
When isInputBoundRequestFailure detects context_length_exceeded,
the combo loop returns {ok:false, response} immediately instead of
re-dispatching the oversized request.
Test: node --import tsx/esm --test tests/unit/combo-input-bound-failure-8375.test.ts
- 1 test, 2 assertions, 0 fail
* fix(translator): strip input_image from tool outputs in Responses->Chat downgrade (#8459)
toolOutputContentToString() extracts input_text/output_text parts and
replaces input_image with a placeholder instead of JSON.stringify'ing
the content-part array (which embedded raw ~52KB base64 as inert text).
Applied to both function_call_output and custom_tool_call_output branches.
Existing translator tests: 88/88 pass.
New tests: 4/4 pass.
* fix(providers): set qwen-web toolCalling to false — web-cookie provider has no native function calling (#8437)
qwen-web is a web-cookie provider that emulates tools via synthetic system
prompt text and <tool> XML parsing, never sending a native tools[] field
upstream. The filterTargetsByRequestCompatibility gate filters out non-tool-
calling targets when the request carries tools, but qwen-web's registry entry
had toolCalling=true, so the filter let it through and a tool-using session
failing over to qwen-web would silently degrade to text-only chat with
'Tool X does not exists' errors.
Sibling web-cookie providers (chatgpt-web, yuanbao-web, claude-web, etc.)
all correctly set toolCalling: false — qwen-web was an outlier introduced
in PR #7874.
Verification:
- LSP diagnostics: clean
- Pattern matches chatgpt-web, yuanbao-web, and other web-cookie providers
* fix(backend): empty upstream response mislabeled as exhausted_connection (#8397)
isEmptyContentFailure guard only matched '/empty content/i' but the actual
error text from detectMalformedNonStream is 'returned an empty response
(no usable choices/output)' — which lacks the word 'content'. Expanded
regex to also match '/empty response/i' so these transient upstream glitches
don't get classified as connection-level exhaustion in combo diagnostics.
Test: 28 existing combo-target-exhaustion tests pass (no new test needed)
* test(#8397): add regression test for empty-response 502 not marking provider/connection exhausted
* test(qwen-web): align registry snapshot with toolCalling:false
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(resilience): scope #8375 input-bound short-circuit to homogeneous remainders
The isInputBoundFailure short-circuit (context_length_exceeded /
context_window_exceeded) fired unconditionally on the first target, aborting
the whole combo even when later targets are a different model with a larger
context window — regressing the intentional heterogeneous-combo fallback that
isContextOverflow400 (#6637) protects. Reproduced with a 2-target combo
(small-context model fails, larger-context model would have succeeded): the
combo never reached target 2.
Scope the short-circuit to remainders where every remaining target shares the
same modelStr as the one that just failed — the "retrying will fail
identically" premise for context_length_exceeded only holds within a
homogeneous same-model pool.
Rebaselines open-sse/services/combo.ts's frozen file-size cap (3642->3679)
for this PR's own combo.ts growth (config/quality/file-size-baseline.json).
Refs #8375
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
---------
Co-authored-by: Probe Test <probe@example.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: ikelvingo <im.kelvinwong@gmail.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
committed by
GitHub
parent
a095ebc43d
commit
3be0a5d290
@@ -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 <cap) and quotaShareStrategy.ts (per-model bucket gating via isBucketSaturated + DRR proportional to weight + P2C over in-flight, ~240 LOC <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the headroom/reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. ZERO existing strategy cases were modified — only this branch was added, and the qtSd/ combos switched from fill-first to quota-share in src/lib/quota/quotaCombos.ts. Covered by tests/unit/quota-share-strategy.test.ts (gating, DRR fairness, P2C in-flight, fail-open, activation). Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_24_task_aware_routing": "Task-aware routing strategy (port PR #2045, OmniRoute #4945): combo.ts 3190->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 <cap). Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors quota-share/headroom/reset-aware branches). ZERO existing strategy cases modified. Covered by tests/unit/combo-task-aware.test.ts (35 tests). Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_22_8213_combo_cooldown_wait_recording": "PR #8213 (hartmark, fix/gemini-tpm-quota-cooldown-wait) own growth: open-sse/services/combo.ts 3548->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 (<cap); the residual growth is the duplicated thin-wrapper signatures + the extracted bodies' dispatch boundary, guarded by a byte-identical parity test (riskGateIntegration). Default off (DEFAULT_COMPRESSION_CONFIG unchanged). Not extractable without hiding the dispatch boundary, mirroring prior compression rebaselines. Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_06_29_5286_memoization": "PR #5286 own growth: strategySelector.ts 899->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 (<cap); the chokepoint wiring here is not extractable. Structural shrink of this hot-path file tracked in #3501.",
|
||||
|
||||
@@ -19,7 +19,7 @@ export const qwen_webProvider: RegistryEntry = {
|
||||
{
|
||||
id: "qwen3.8-max-preview",
|
||||
name: "Qwen3.8 Max Preview",
|
||||
toolCalling: true,
|
||||
toolCalling: false,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
contextLength: 1_000_000,
|
||||
@@ -28,7 +28,7 @@ export const qwen_webProvider: RegistryEntry = {
|
||||
{
|
||||
id: "qwen3.7-max",
|
||||
name: "Qwen3.7 Max",
|
||||
toolCalling: true,
|
||||
toolCalling: false,
|
||||
supportsReasoning: true,
|
||||
supportsVision: false,
|
||||
contextLength: 1_000_000,
|
||||
@@ -37,7 +37,7 @@ export const qwen_webProvider: RegistryEntry = {
|
||||
{
|
||||
id: "qwen3.7-plus",
|
||||
name: "Qwen3.7 Plus",
|
||||
toolCalling: true,
|
||||
toolCalling: false,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
contextLength: 1_000_000,
|
||||
@@ -46,7 +46,7 @@ export const qwen_webProvider: RegistryEntry = {
|
||||
{
|
||||
id: "qwen3.6-plus",
|
||||
name: "Qwen3.6 Plus",
|
||||
toolCalling: true,
|
||||
toolCalling: false,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
contextLength: 1_000_000,
|
||||
|
||||
@@ -159,6 +159,7 @@ import {
|
||||
shouldSkipForPredictedTtft,
|
||||
shouldRecordProviderBreakerFailure,
|
||||
isRequestScopedUpstreamFailure,
|
||||
isInputBoundRequestFailure,
|
||||
shouldSkipConnDisable,
|
||||
resolveDelayMs,
|
||||
comboModelNotFoundResponse,
|
||||
@@ -2183,6 +2184,39 @@ export async function handleComboChat({
|
||||
}
|
||||
: undefined;
|
||||
const requestScopedFailure = isRequestScopedUpstreamFailure(structuredError);
|
||||
|
||||
// #8375: input-bound request-scoped failures (context_length_exceeded) are
|
||||
// deterministic for the same input — retrying on other accounts of the same
|
||||
// model will fail identically. Short-circuit the combo immediately with the
|
||||
// original error instead of burning MAX_GLOBAL_ATTEMPTS.
|
||||
// Scoped to homogeneous remainders only: a heterogeneous combo (#6637) may
|
||||
// have a later target with a different, larger context window that would
|
||||
// NOT reject the same input — isContextOverflow400 below exists precisely to
|
||||
// let that case fall through, so only short-circuit when every remaining
|
||||
// target is the same model (the "retrying will fail identically" premise
|
||||
// only holds within a homogeneous same-model pool).
|
||||
const remainingTargets = orderedTargets.slice(i + 1);
|
||||
const remainderIsHomogeneous = remainingTargets.every(
|
||||
(nextInPool) => 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
82
tests/unit/combo-input-bound-failure-8375.test.ts
Normal file
82
tests/unit/combo-input-bound-failure-8375.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
111
tests/unit/combo-input-bound-heterogeneous-8375.test.ts
Normal file
111
tests/unit/combo-input-bound-heterogeneous-8375.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
localUsageCommand: `${PLACEHOLDER_PREFIX}Run this command locally`,
|
||||
|
||||
185
tests/unit/translator-openai-responses-image-output-8459.test.ts
Normal file
185
tests/unit/translator-openai-responses-image-output-8459.test.ts
Normal file
@@ -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<string, unknown>).messages as Record<string, unknown>[];
|
||||
// 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<string, unknown>).messages as Record<string, unknown>[];
|
||||
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<string, unknown>).messages as Record<string, unknown>[];
|
||||
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<string, unknown>).messages as Record<string, unknown>[];
|
||||
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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user