Files
diegosouzapw--omniroute/tests/unit/combo-empty-content-failover-5085.test.ts
2026-07-13 13:39:12 +08:00

163 lines
5.8 KiB
TypeScript

/**
* #5085 — A multi-leg combo whose first leg returns a 502 "Provider returned
* empty content" must FAIL OVER to the next leg within the same request, not
* surface the 502 to the caller. An empty completion is a fake-success failure
* (HTTP 200 with no content → rewritten to 502 in chatCore), and for a combo
* whose whole purpose is resilience it should behave like any other transient
* leg failure and advance.
*
* Reproduction: a `priority` combo with two legs on DIFFERENT providers. Leg 1
* returns the empty-content 502 (exactly the body buildErrorBody produces in
* chatCore's isEmptyContentResponse branch); leg 2 returns a healthy 200. The
* combo must try leg 2 and surface its 200 — never return the leg-1 502.
*/
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-5085-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-5085-test-secret";
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const noop = () => {};
const log = { info: noop, warn: noop, debug: noop, error: noop };
// Mirrors chatCore's empty-content branch: buildErrorBody(502, "Provider returned empty content").
function emptyContent502() {
return new Response(
JSON.stringify({ error: { message: "Provider returned empty content", type: "bad_gateway" } }),
{ status: 502, headers: { "Content-Type": "application/json" } }
);
}
function healthy200(model: string) {
return new Response(
JSON.stringify({
id: "ok",
object: "chat.completion",
model,
choices: [{ index: 0, message: { role: "assistant", content: "hello from " + model }, finish_reason: "stop" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
function makeCombo(models: string[]) {
return {
name: "test-combo-5085",
strategy: "priority",
models: models.map((m) => ({ model: m })),
};
}
test("#5085 combo fails over to the next leg when leg 1 returns empty-content 502", async () => {
const modelsCalled: string[] = [];
const handleSingleModel = async (_body: unknown, modelStr: string) => {
modelsCalled.push(modelStr);
// First leg (different provider) returns the empty-content 502; second leg is healthy.
if (modelsCalled.length === 1) return emptyContent502();
return healthy200(modelStr);
};
const result = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["nvidia/minimaxai/minimax-m3", "openai/gpt-4o-mini"]),
handleSingleModel,
log,
settings: {},
allCombos: [],
});
assert.equal(
modelsCalled.length,
2,
`empty-content 502 on leg 1 must advance to leg 2, but tried: ${modelsCalled.join(", ")}`
);
assert.equal(
result.status,
200,
"the combo must surface the healthy second leg's 200, not the leg-1 empty-content 502"
);
});
// ── Precise unit test of the exhaustion classifier ──────────────────────────
// The integration test above already passes because the empty-content leg and
// the healthy leg are on different providers. The real defect is provider-level:
// an empty-content 502 is currently classified as a CONNECTION-level failure
// (502 ∈ CONNECTION_LEVEL_ERROR_STATUSES), which marks the whole provider/
// 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");
function makeTarget(provider: string, modelStr: string, connectionId: string | null = null) {
return {
kind: "model" as const,
stepId: "s",
executionKey: "e",
modelStr,
provider,
providerId: provider,
connectionId,
weight: 1,
label: null,
};
}
function freshSets() {
return {
exhaustedProviders: new Set<string>(),
exhaustedConnections: new Set<string>(),
transientRateLimitedProviders: new Set<string>(),
};
}
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",
});
assert.equal(providerExhausted, false, "empty-content is not a quota exhaustion");
assert.equal(
sets.exhaustedProviders.has("nvidia"),
false,
"empty-content 502 must NOT mark the whole provider exhausted — remaining same-provider legs must still be tried"
);
});
test("#5085 a real connection-level 502 (gateway error) STILL marks the provider exhausted", () => {
const sets = freshSets();
applyComboTargetExhaustion(makeTarget("nvidia", "nvidia/minimaxai/minimax-m3"), {
result: { status: 502, headers: new Headers() },
fallbackResult: { reason: "server_error" },
errorText: "Bad gateway: upstream connection reset",
rawModel: "minimaxai/minimax-m3",
isTokenLimitBreach: false,
allAccountsRateLimited: false,
sets,
log,
tag: "COMBO",
exhaustedLogLevel: "info",
});
assert.equal(
sets.exhaustedProviders.has("nvidia"),
true,
"a genuine gateway 502 must still mark the provider connection-exhausted (#1731v2 preserved)"
);
});