mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 17:22:17 +03:00
Compare commits
6 Commits
feat/9490-
...
feat/9239-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ebae131cc | ||
|
|
b6a3eb5c40 | ||
|
|
66a1db0bac | ||
|
|
67242b7b92 | ||
|
|
1818e1a553 | ||
|
|
510b593b71 |
@@ -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.
|
||||
199
open-sse/services/imageCombo.ts
Normal file
199
open-sse/services/imageCombo.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
276
tests/unit/combo/image-combo.test.ts
Normal file
276
tests/unit/combo/image-combo.test.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user