Files
OmniRoute/tests/unit/combo/image-combo.test.ts
Paco Cartones 8d16a50df5 fix(api): keep the images wrapper on combo routes and default Codex to b64_json (#12362)
POST /v1/images/generations through a combo returned a bare array instead of the OpenAI {created, data} payload: executeImageCombo() unwrapped one level too many, and the n used for cost calculation read the same double-nested shape, so it was always 0. The combo path now returns the handler payload unchanged, matching the direct-model path.

Second half: Codex image results emitted a data: URI in url whenever response_format was not b64_json, but OpenAI returns b64_json for the gpt-image-* family — clients that omit the field, Codex CLI's built-in image_gen among them, could decode neither shape. Codex now defaults to b64_json; an explicit response_format: "url" keeps its previous behaviour. Both land together because fixing one leaves Codex CLI failing at the other.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
2026-09-02 03:14:09 -03:00

353 lines
12 KiB
TypeScript

/**
* Tests for image combo strategy execution (#9239)
*
* Tests executeImageCombo and route diversion in generations/route.ts.
*
* These tests set up a temp DATA_DIR with a seeded combo in the DB so the
* executeImageCombo function can resolve combo targets through the real
* DB path. Tests focus on combo resolution, filtering, and error paths.
*/
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-image-combo-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.JWT_SECRET = "test-jwt-secret-for-image-combo-tests";
// Ensure the test dir exists
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
const core = await import("@/lib/db/core.ts");
const { createCombo } = await import("@/lib/db/combos");
const { createProviderConnection } = await import("@/lib/db/providers");
const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo");
type LogEntry = { level: string; tag: unknown; msg: unknown };
function createLog() {
const entries: LogEntry[] = [];
const record =
(level: string) =>
(tag: unknown, msg: unknown): number =>
entries.push({ level, tag, msg });
return {
info: record("info"),
warn: record("warn"),
error: record("error"),
debug: record("debug"),
entries,
};
}
function createRequest(model: string): Request {
return new Request("http://localhost:20128/v1/images/generations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model, prompt: "a cat" }),
});
}
function createMockAuth() {
return {
request: createRequest("test-combo"),
policy: { apiKeyInfo: { id: "test-key", name: "test-key" } },
};
}
async function cleanupTestDataDir() {
let lastError: unknown;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
return;
} catch (error: unknown) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
if (lastError) throw lastError;
}
test.beforeEach(async () => {
await cleanupTestDataDir();
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(async () => {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
await cleanupTestDataDir();
});
// ---------------------------------------------------------------------------
// executeImageCombo — combo resolution and error paths
// ---------------------------------------------------------------------------
test("returns 400 when combo is not found", async () => {
const log = createLog();
const response = await executeImageCombo(
"nonexistent-combo",
{ model: "nonexistent-combo", prompt: "a cat" },
createMockAuth(),
Date.now(),
log
);
assert.equal(response.status, 400);
const body = await response.json();
const bodyStr = JSON.stringify(body);
assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces");
});
test("returns 400 when combo has no image-capable targets", async () => {
// Create a combo with a chat-only model (not in image registry)
await createCombo({
name: "chat-only-combo",
strategy: "priority",
models: ["openai/gpt-4o"],
});
const log = createLog();
const response = await executeImageCombo(
"chat-only-combo",
{ model: "chat-only-combo", prompt: "a cat" },
createMockAuth(),
Date.now(),
log
);
assert.equal(response.status, 400);
const body = await response.json();
const bodyStr = JSON.stringify(body);
assert.ok(bodyStr.includes("No images-capable targets"), "Tells user no image targets");
assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces");
});
test("returns 400 when combo has no usable targets", async () => {
await createCombo({
name: "empty-combo",
strategy: "priority",
models: [],
});
const log = createLog();
const response = await executeImageCombo(
"empty-combo",
{ model: "empty-combo", prompt: "a cat" },
createMockAuth(),
Date.now(),
log
);
assert.equal(response.status, 400);
});
test("cannot resolve credentials for a combo with image models but no provider connections", async () => {
// Create a combo with real image registry models (openai/gpt-image-2 is real)
// but no provider connection exists in the test DB — should 400 on credential resolution
await createCombo({
name: "img-no-conn",
strategy: "priority",
models: ["openai/gpt-image-2", "openai/gpt-image-1.5"],
});
const log = createLog();
const response = await executeImageCombo(
"img-no-conn",
{ model: "img-no-conn", prompt: "a cat", n: 1 },
createMockAuth(),
Date.now(),
log
);
// Should fail because no provider connection for "openai" exists
assert.equal(response.status, 400);
const body = await response.json();
const bodyStr = JSON.stringify(body);
assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces");
});
// ---------------------------------------------------------------------------
// executeImageCombo — handling of combo with image-capable targets
// but no credentials (tests that filtering and iteration logic works)
// ---------------------------------------------------------------------------
test("correctly filters models: only image-registry models pass, chat-only models are skipped", async () => {
// Combo mixing image-capable and non-image models
await createCombo({
name: "mixed-combo",
strategy: "priority",
models: ["openai/gpt-image-2", "openai/gpt-4o", "openai/gpt-image-1.5"],
});
const log = createLog();
const response = await executeImageCombo(
"mixed-combo",
{ model: "mixed-combo", prompt: "a cat" },
createMockAuth(),
Date.now(),
log
);
// Should get 400 because no credentials exist, but the filtering
// should have removed gpt-4o from consideration
assert.equal(response.status, 400);
const body = await response.json();
// The error should mention credentials, not "No images-capable targets"
// because gpt-image-2 and gpt-image-1.5 ARE image-capable
const bodyStr = JSON.stringify(body);
assert.ok(
!bodyStr.includes("No images-capable targets"),
"Image-capable targets were found, error is about credentials not filtering"
);
});
// ---------------------------------------------------------------------------
// Route diversion — generations/route.ts pattern
// ---------------------------------------------------------------------------
test("non-combo bare model names pass through model resolution unchanged", async () => {
// A bare model name with no slash that is NOT a combo should not cause issues
// This tests the combo detection logic: `!body.model.includes("/")` + getComboByName
// Verify the route patch handles non-combo bare names gracefully
const log = createLog();
const response = await executeImageCombo(
"some-random-name",
{ model: "some-random-name", prompt: "a cat" },
createMockAuth(),
Date.now(),
log
);
// Should get 400 since "some-random-name" is not a combo
assert.equal(response.status, 400);
const body = await response.json();
const bodyStr = JSON.stringify(body);
assert.ok(
bodyStr.includes("not found") || bodyStr.includes("not a valid"),
"Combo not found error"
);
});
test("provider/model format (with slash) is not treated as a combo name", async () => {
// Models like "openai/gpt-image-2" have a slash, so they won't be checked as combos
// This is the route patch's first guard: `!body.model.includes("/")`
//
// We test by trying to execute a combo named "openai/gpt-image-2":
// - executeImageCombo directly doesn't check for slash (it's the route's job)
// - But if someone calls with a slash-containing name that isn't a combo, it 400s
const log = createLog();
const response = await executeImageCombo(
"openai/gpt-image-2",
{ model: "openai/gpt-image-2", prompt: "a cat" },
createMockAuth(),
Date.now(),
log
);
assert.equal(response.status, 400);
const body = await response.json();
const bodyStr = JSON.stringify(body);
assert.ok(bodyStr.includes("not found") || bodyStr.includes("not a valid"), "Not a combo name");
});
// ---------------------------------------------------------------------------
// Error response security — no stack trace leaks
// ---------------------------------------------------------------------------
test("all error responses from executeImageCombo sanitize stack traces", async () => {
// Test multiple error scenarios and verify none leak stack traces
const scenarios = [
{ name: "nonexistent", comboName: "no-such-combo-at-all" },
{ name: "chat-only", comboName: "another-chat-combo" },
];
// Create a non-image combo
await createCombo({
name: "another-chat-combo",
strategy: "priority",
models: ["openai/gpt-4o"],
});
const log = createLog();
for (const scenario of scenarios) {
const response = await executeImageCombo(
scenario.comboName,
{ model: scenario.comboName, prompt: "a cat" },
createMockAuth(),
Date.now(),
log
);
assert.ok(response.status >= 400, `Scenario "${scenario.name}" returns error status`);
const body = await response.json();
const bodyStr = JSON.stringify(body);
assert.ok(
!bodyStr.includes("at ") || !bodyStr.includes("/src/"),
`Scenario "${scenario.name}" does not leak stack traces`
);
}
});
// ---------------------------------------------------------------------------
// Success path — public response shape (#12268)
// ---------------------------------------------------------------------------
function buildCodexSSE(items: Array<Record<string, unknown>>): string {
const frames = items.map((item) => JSON.stringify({ type: "response.output_item.done", item }));
return frames.map((frame) => `event: response.output_item.done\ndata: ${frame}\n`).join("\n");
}
test("combo success keeps the OpenAI {created, data} wrapper and Codex defaults to b64_json (#12268)", async () => {
// Codex CLI hardcodes the model name `gpt-image-2`; a combo is what lets it
// reach a codex target. The combo response must match the direct-model
// response shape byte-for-byte or the client aborts while decoding `created`.
await createProviderConnection({
provider: "codex",
authType: "apikey",
apiKey: "codex-token",
name: "codex-image-combo",
isActive: true,
testStatus: "active",
providerSpecificData: {},
});
await createCombo({
name: "gpt-image-2",
strategy: "priority",
models: ["codex/gpt-5.6-sol"],
});
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
buildCodexSSE([
{
type: "image_generation_call",
id: "ig_combo_1",
status: "completed",
revised_prompt: "a green tree icon",
result: "aVZCT1J3MEtHZ28=",
},
]),
{ status: 200, headers: { "content-type": "text/event-stream" } }
);
try {
const log = createLog();
const response = await executeImageCombo(
"gpt-image-2",
{ model: "gpt-image-2", prompt: "a green tree icon, white background, minimal flat" },
createMockAuth(),
Date.now(),
log
);
assert.equal(response.status, 200);
const body = await response.json();
assert.ok(!Array.isArray(body), "combo path must not return a bare array");
assert.equal(typeof body.created, "number");
assert.ok(Array.isArray(body.data));
assert.equal(body.data.length, 1);
assert.equal(body.data[0].b64_json, "aVZCT1J3MEtHZ28=");
assert.equal(body.data[0].url, undefined);
assert.equal(body.data[0].revised_prompt, "a green tree icon");
} finally {
globalThis.fetch = originalFetch;
}
});