From 41208fa3984cdf0550b292f1c658a723ca822e15 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Mon, 10 Aug 2026 20:10:29 -0300 Subject: [PATCH] feat(images): add full combo strategy execution for image generation (#9239) (#9499) Co-authored-by: diegosouzapw --- .../9239-image-combo-strategy-execution.md | 7 + open-sse/services/imageCombo.ts | 199 +++++++++++++ src/app/api/v1/images/generations/route.ts | 19 ++ tests/unit/combo/image-combo.test.ts | 276 ++++++++++++++++++ 4 files changed, 501 insertions(+) create mode 100644 changelog.d/features/9239-image-combo-strategy-execution.md create mode 100644 open-sse/services/imageCombo.ts create mode 100644 tests/unit/combo/image-combo.test.ts diff --git a/changelog.d/features/9239-image-combo-strategy-execution.md b/changelog.d/features/9239-image-combo-strategy-execution.md new file mode 100644 index 0000000000..58d966dfe8 --- /dev/null +++ b/changelog.d/features/9239-image-combo-strategy-execution.md @@ -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. diff --git a/open-sse/services/imageCombo.ts b/open-sse/services/imageCombo.ts new file mode 100644 index 0000000000..ada834ff50 --- /dev/null +++ b/open-sse/services/imageCombo.ts @@ -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, + auth: { + request: Request; + policy: { apiKeyInfo?: { id?: string; name?: string } | null }; + }, + startTime: number, + log: typeof logger +): Promise { + // 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" }, + }); +} \ No newline at end of file diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index 0037d538cf..41916a55e6 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -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 `/` form so the // custom-model lookup and handler's resolvedProvider extraction resolve correctly. diff --git a/tests/unit/combo/image-combo.test.ts b/tests/unit/combo/image-combo.test.ts new file mode 100644 index 0000000000..7e7a459e9d --- /dev/null +++ b/tests/unit/combo/image-combo.test.ts @@ -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` + ); + } +}); \ No newline at end of file