Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/features/9239-image-combo-strategy-execution.md
Original file line number Diff line number Diff line change
@@ -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 changes: 199 additions & 0 deletions open-sse/services/imageCombo.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions src/app/api/v1/images/generations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading