Compare commits

...

6 Commits

Author SHA1 Message Date
diegosouzapw
7ebae131cc Merge remote-tracking branch 'origin/release/v3.8.50' into HEAD 2026-08-09 00:30:12 -03:00
diegosouzapw
b6a3eb5c40 Merge remote-tracking branch 'origin/release/v3.8.50' into feat/9239-image-combo-strategy-execution
# Conflicts:
#	src/app/api/v1/images/generations/route.ts
2026-08-08 11:42:37 -03:00
Diego Rodrigues de Sa e Souza
66a1db0bac Merge branch 'release/v3.8.50' into feat/9239-image-combo-strategy-execution 2026-08-05 16:23:14 -03:00
Diego Rodrigues de Sa e Souza
67242b7b92 Merge branch 'release/v3.8.50' into feat/9239-image-combo-strategy-execution 2026-08-05 13:20:42 -03:00
Diego Rodrigues de Sa e Souza
1818e1a553 Merge branch 'release/v3.8.50' into feat/9239-image-combo-strategy-execution 2026-08-05 11:59:55 -03:00
diegosouzapw
510b593b71 feat(images): add full combo strategy execution for image generation (#9239) 2026-08-05 03:50:22 -03:00
4 changed files with 501 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
feat(images): execute full combo strategy + fallback in /v1/images/generations (#9239)
Add open-sse/services/imageCombo.ts that expands combo targets, filters
to images-capable, executes priority strategy with handleImageGeneration
per target, and returns first success or last failure. Route patches
detect combo names before model resolution and divert to the new
execution path.

View File

@@ -0,0 +1,199 @@
/**
* Image Combo Strategy Execution
*
* Executes a full Combo strategy for image generation requests. Expands combo
* targets via resolveComboTargets(), filters to images-capable targets, runs
* each target via handleImageGeneration() using a priority strategy, provides
* per-credential resolution, and returns the first success or last failure.
*
* #9239
*/
import { getComboByName, getCombos } from "@/lib/db/combos";
import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts";
import { getImageModelEntry, parseImageModel } from "@omniroute/open-sse/config/imageRegistry.ts";
import {
getProviderCredentialsWithQuotaPreflight,
clearRecoveredProviderState,
} from "@/sse/services/auth";
import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import { calculateModalCost } from "@/lib/usage/costCalculator";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import * as logger from "@/sse/utils/logger";
/**
* Execute a full combo strategy for an image generation request.
*
* 1. Resolve combo targets via resolveComboTargets.
* 2. Filter to images-capable targets (those with an entry in the image registry).
* 3. Iterate targets in priority order; for each target, resolve credentials and
* call handleImageGeneration. Return the first success or the last failure.
* 4. Attach combo name, selected target, and fallback count to response headers.
*/
export async function executeImageCombo(
comboName: string,
body: Record<string, unknown>,
auth: {
request: Request;
policy: { apiKeyInfo?: { id?: string; name?: string } | null };
},
startTime: number,
log: typeof logger
): Promise<Response> {
// 1. Resolve combo targets
const combo = await getComboByName(comboName);
if (!combo) {
// Model name is not a combo; the caller should handle this as a direct model
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Combo not found: ${comboName}`
);
}
const allCombos = await getCombos();
const targets = resolveComboTargets(combo as never, allCombos as never);
if (!targets || targets.length === 0) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Combo "${comboName}" has no usable targets`
);
}
// 2. Filter to images-capable targets
const imageTargets = targets.filter((t) => {
if (!t.modelStr) return false;
const entry = getImageModelEntry(t.modelStr);
return entry !== null;
});
if (imageTargets.length === 0) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No images-capable targets in combo "${comboName}"`
);
}
// 3. Iterate targets in priority order (first healthy target wins)
let lastError: { status: number; error: string } | null = null;
let successResult: { data: unknown; provider: string; model: string } | null = null;
let fallbackCount = 0;
let selectedProvider = "";
let selectedModel = "";
for (const target of imageTargets) {
const { provider: targetProvider, model: targetModel } = parseImageModel(target.modelStr);
if (!targetProvider) {
lastError = { status: 400, error: `Invalid image model: ${target.modelStr}` };
fallbackCount += 1;
continue;
}
// Resolve provider credentials
let credentials = null;
try {
credentials = await getProviderCredentialsWithQuotaPreflight(targetProvider);
} catch {
// DB unavailable — skip this target
lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` };
fallbackCount += 1;
continue;
}
if (!credentials) {
lastError = { status: 400, error: `No credentials for image provider: ${targetProvider}` };
fallbackCount += 1;
continue;
}
if (isAllRateLimitedCredentials(credentials)) {
lastError = {
status: 429,
error: `[${targetProvider}] All accounts rate limited`,
};
fallbackCount += 1;
continue;
}
// Execute image generation for this target
const result = await handleImageGeneration({
body: { ...body, model: target.modelStr },
credentials,
log,
signal: auth.request?.signal || null,
});
if (result.success) {
await clearRecoveredProviderState(credentials);
selectedProvider = targetProvider;
selectedModel = target.modelStr;
successResult = {
data: result.data,
provider: targetProvider,
model: target.modelStr,
};
break;
}
// Classify the failure
const status = result.status || 500;
const error = typeof result.error === "string" ? result.error : "Image generation failed";
// Terminal failures (400 bad model, 403 banned, etc.) — stop iterating
// Non-terminal failures (429, 5xx) — try next target
if (status === 400 || status === 403 || status === 401) {
return errorResponse(
status,
`[${targetProvider}] ${error}`
);
}
lastError = { status, error: `[${targetProvider}] ${error}` };
fallbackCount += 1;
}
// 4. Build response
if (successResult) {
const n = Math.max(
Number(body.n) || 1,
(
successResult.data as { data?: { data?: unknown[] } }
).data?.data?.length || 0
);
const costUsd = await calculateModalCost(
"image",
selectedProvider,
selectedModel,
{ n }
);
const headers = new Headers({ "Content-Type": "application/json" });
attachOmniRouteMetaHeaders(headers, {
provider: selectedProvider,
model: selectedModel,
costUsd,
latencyMs: Date.now() - startTime,
requestId: generateRequestId(),
strategy: "priority",
fallbackAttempts: fallbackCount,
});
return new Response(
JSON.stringify((successResult.data as { data: unknown }).data),
{ status: 200, headers }
);
}
// All targets failed — return the last error
const errorPayload = toJsonErrorPayload(
lastError?.error || "All combo targets failed",
"Image combo targets all failed"
);
return new Response(JSON.stringify(errorPayload), {
status: lastError?.status || 502,
headers: { "Content-Type": "application/json" },
});
}

View File

@@ -19,6 +19,7 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1ImageGenerationSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getComboByName } from "@/lib/db/combos";
import { getAllCustomModels } from "@/lib/db/models";
import { resolveProxyForConnection } from "@/lib/db/settings";
import { resolveImageRouteModel } from "@/lib/images/imageRouteModel";
@@ -116,6 +117,24 @@ async function postHandler(request, context) {
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
// #9239: Detect combo name and divert to full image combo execution.
// Checks before resolveImageRouteModel so we skip single-target flattening.
if (body.model && typeof body.model === "string" && !body.model.includes("/")) {
const combo = await getComboByName(body.model as string);
if (combo) {
const { executeImageCombo } = await import(
"@omniroute/open-sse/services/imageCombo"
);
return executeImageCombo(
body.model as string,
body,
{ request, policy },
startTime,
log
);
}
}
// #3205/#3215: resolve a combo/alias name (`image`) or a user-prefixed custom image
// model (`myImg/gpt-image-2`) to its internal `<nodeId>/<model>` form so the
// custom-model lookup and handler's resolvedProvider extraction resolve correctly.

View File

@@ -0,0 +1,276 @@
/**
* 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 { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo");
function createLog() {
const entries: any[] = [];
return {
info: (tag: any, msg: any) => entries.push({ level: "info", tag, msg }),
warn: (tag: any, msg: any) => entries.push({ level: "warn", tag, msg }),
error: (tag: any, msg: any) => entries.push({ level: "error", tag, msg }),
debug: (tag: any, msg: any) => entries.push({ level: "debug", tag, msg }),
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: any;
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
return;
} catch (error: any) {
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`
);
}
});