mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
* fix(providers): resolve combo names on /v1/audio/speech and /v1/videos/generations `GET /v1/models` advertises combos with `owned_by: combo`, and chat, embeddings, transcriptions (#9134) and images (#8986, #9239) all resolve those names. Speech and video did not: both rejected a combo name at model validation, before any resolution could happen. POST /v1/audio/speech {"model":"my-combo","input":"hi"} -> 400 Invalid speech model: my-combo. Use format: provider/model POST /v1/videos/generations {"model":"my-combo","prompt":"a cube"} -> 400 Invalid video model: my-combo. Use format: provider/model A client picking a model out of /v1/models therefore could not tell which entries the catalogue would actually accept, and callers ended up hardcoding vendor ids for these two routes while using combo names everywhere else. Both routes now mirror the images route: detect a combo name before the provider lookup and divert to a strategy executor. The two new executors follow imageCombo — expand targets with resolveComboTargets(), filter to targets the route can actually serve, walk them in priority order, and return the first success or the last failure, with 400/401/403 treated as terminal. Two details differ from the image strategy: Speech filters at model level rather than provider level. parseSpeechModel() resolves a provider prefix without checking that the model behind it can speak, so `openai/gpt-4o` would otherwise be accepted as a target and fail only once dispatched. The filter now checks the provider's own model list, and keeps targets from dynamic provider nodes that do not enumerate models. Speech also returns the handler's Response untouched instead of building a JSON body, because that route streams audio; only the ADD-only meta headers are attached, exactly as the direct path does. The failure branch is the only place the body is read. successfulMediaGenerationResponse() gains optional `strategy` and `fallbackAttempts` so the video strategy can report them the way imageCombo does, rather than duplicating the cost calculation. Both are omitted on the direct single-model path, where neither is meaningful. Tests mirror tests/unit/combo/image-combo.test.ts for both routes: combo not found, no capable targets, empty combo, and targets present with no provider connection. 16/16 pass across the three combo test files. * fix(providers): preserve local overrides, custom models and per-target prompt rules through video combo dispatch executeVideoCombo() diverged from the direct /v1/videos/generations route in three ways: it dropped the ComfyUI-style local-override credential lookup for authType:"none" targets, its capability filter only matched the built-in video registry (skipping custom OpenAI-compatible provider nodes tagged with the "videos" endpoint), and the route validated the prompt against the unresolved combo name before combo targets were expanded — rejecting prompt-optional I2V targets that never got the chance to opt out. Extracts the shared resolution rules (resolveVideoModelTarget, isVideoPromptOptional, resolveLocalOverrideCredentials) into src/app/api/v1/_shared/videoModelResolution.ts so the direct route and the combo executor apply identical rules, moves the combo-name diversion ahead of the prompt-required check so validation runs against the real resolved target, and adds per-target prompt validation inside the combo loop so a missing prompt only rules out that target instead of the whole combo. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
216 lines
7.9 KiB
TypeScript
216 lines
7.9 KiB
TypeScript
/**
|
|
* Video Combo Strategy Execution
|
|
*
|
|
* Mirrors imageCombo for /v1/videos/generations: expands combo targets via
|
|
* resolveComboTargets(), filters to video-capable targets (built-in registry
|
|
* models plus custom OpenAI-compatible provider nodes tagged with the
|
|
* "videos" endpoint — same coverage as the direct route), runs each through
|
|
* handleVideoGeneration() in priority order, and returns the first success or
|
|
* the last failure.
|
|
*
|
|
* Terminal-vs-retryable classification matches the image strategy: 400/401/403
|
|
* stop the walk (a bad model or a banned key will not get better on the next
|
|
* target), everything else advances. A missing prompt against a
|
|
* prompt-required target is an exception to that rule: it is per-target (some
|
|
* combo targets may be prompt-optional I2V models), so it is treated as a
|
|
* retryable skip rather than a terminal failure.
|
|
*/
|
|
import { getComboByName, getCombos } from "@/lib/db/combos";
|
|
import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts";
|
|
import { getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
|
|
import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts";
|
|
import {
|
|
getProviderCredentialsWithQuotaPreflight,
|
|
clearRecoveredProviderState,
|
|
} from "@/sse/services/auth";
|
|
import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit";
|
|
import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts";
|
|
import {
|
|
isMediaGenerationFailure,
|
|
promptRequiredResponse,
|
|
successfulMediaGenerationResponse,
|
|
} from "@/app/api/v1/_shared/mediaGenerationRoute";
|
|
import type { MediaGenerationResultLike } from "@/app/api/v1/_shared/mediaGenerationRoute";
|
|
import {
|
|
isVideoPromptOptional,
|
|
resolveLocalOverrideCredentials,
|
|
resolveVideoModelTarget,
|
|
} from "@/app/api/v1/_shared/videoModelResolution";
|
|
import type { VideoModelTarget } from "@/app/api/v1/_shared/videoModelResolution";
|
|
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 a video generation request.
|
|
*/
|
|
export async function executeVideoCombo(
|
|
comboName: string,
|
|
body: Record<string, unknown>,
|
|
auth: {
|
|
request: Request;
|
|
policy: { apiKeyInfo?: { id?: string; name?: string } | null };
|
|
},
|
|
startTime: number,
|
|
log: typeof logger
|
|
): Promise<Response> {
|
|
const combo = await getComboByName(comboName);
|
|
if (!combo) {
|
|
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`);
|
|
}
|
|
|
|
// Resolve every target once — built-in registry first, then custom
|
|
// OpenAI-compatible provider nodes tagged with the "videos" endpoint —
|
|
// and filter to video-capable ones. Resolving up front (rather than in the
|
|
// execution loop below) lets prompt validation run against the real
|
|
// expanded target set instead of the unresolved combo name.
|
|
const videoTargets: Array<{ modelStr: string; resolved: VideoModelTarget }> = [];
|
|
for (const t of targets) {
|
|
if (!t.modelStr) continue;
|
|
const resolved = await resolveVideoModelTarget(t.modelStr);
|
|
if (resolved.provider) {
|
|
videoTargets.push({ modelStr: t.modelStr, resolved });
|
|
}
|
|
}
|
|
|
|
if (videoTargets.length === 0) {
|
|
return errorResponse(
|
|
HTTP_STATUS.BAD_REQUEST,
|
|
`No video-capable targets in combo "${comboName}"`
|
|
);
|
|
}
|
|
|
|
let lastError: { status: number; error: string } | null = null;
|
|
let fallbackCount = 0;
|
|
|
|
for (const { modelStr, resolved } of videoTargets) {
|
|
const { provider: targetProvider, model: targetModel, isCustomModel } = resolved;
|
|
if (!targetProvider) {
|
|
lastError = { status: 400, error: `Invalid video model: ${modelStr}` };
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
|
|
// Prompt requirements are per-target: some combo targets (I2V models) are
|
|
// prompt-optional and others are not, so a missing prompt only rules out
|
|
// this target rather than the whole combo.
|
|
if (!isVideoPromptOptional(resolved)) {
|
|
const promptError = promptRequiredResponse(body);
|
|
if (promptError) {
|
|
lastError = { status: 400, error: `[${targetProvider}] Prompt is required` };
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Local providers (authType "none") carry no credential by default, but a
|
|
// configured per-connection override (e.g. a ComfyUI base URL) must still
|
|
// be honored, exactly as the direct route treats them.
|
|
const providerConfig = getVideoProvider(targetProvider);
|
|
let credentials = null;
|
|
if (providerConfig && providerConfig.authType !== "none") {
|
|
try {
|
|
credentials = await getProviderCredentialsWithQuotaPreflight(
|
|
resolveVideoCredentialProvider(targetProvider)
|
|
);
|
|
} catch {
|
|
lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` };
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
|
|
if (!credentials) {
|
|
lastError = { status: 400, error: `No credentials for video provider: ${targetProvider}` };
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
|
|
if (isAllRateLimitedCredentials(credentials)) {
|
|
lastError = { status: 429, error: `[${targetProvider}] All accounts rate limited` };
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
} else if (isCustomModel) {
|
|
try {
|
|
credentials = await getProviderCredentialsWithQuotaPreflight(
|
|
targetProvider,
|
|
null,
|
|
null,
|
|
targetModel
|
|
);
|
|
} catch {
|
|
lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` };
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
|
|
if (!credentials) {
|
|
lastError = {
|
|
status: 400,
|
|
error: `No credentials for custom video provider: ${targetProvider}`,
|
|
};
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
|
|
if (isAllRateLimitedCredentials(credentials)) {
|
|
lastError = { status: 429, error: `[${targetProvider}] All accounts rate limited` };
|
|
fallbackCount += 1;
|
|
continue;
|
|
}
|
|
} else if (providerConfig?.authType === "none") {
|
|
credentials = await resolveLocalOverrideCredentials(targetProvider);
|
|
}
|
|
|
|
const result: MediaGenerationResultLike = await handleVideoGeneration({
|
|
body: { ...body, model: modelStr },
|
|
credentials,
|
|
log,
|
|
...(isCustomModel && { resolvedProvider: targetProvider }),
|
|
});
|
|
|
|
if (!isMediaGenerationFailure(result)) {
|
|
await clearRecoveredProviderState(credentials);
|
|
return successfulMediaGenerationResponse({
|
|
result: { data: result.data },
|
|
billingMode: "video",
|
|
provider: targetProvider,
|
|
model: modelStr,
|
|
startTime,
|
|
duration: body.duration,
|
|
strategy: "priority",
|
|
fallbackAttempts: fallbackCount,
|
|
});
|
|
}
|
|
|
|
const status = (result as { status?: number }).status || 500;
|
|
const error =
|
|
typeof (result as { error?: unknown }).error === "string"
|
|
? (result as { error: string }).error
|
|
: "Video generation failed";
|
|
|
|
if (status === 400 || status === 401 || status === 403) {
|
|
return errorResponse(status, `[${targetProvider}] ${error}`);
|
|
}
|
|
|
|
lastError = { status, error: `[${targetProvider}] ${error}` };
|
|
fallbackCount += 1;
|
|
}
|
|
|
|
const errorPayload = toJsonErrorPayload(
|
|
lastError?.error || "All combo targets failed",
|
|
"Video combo targets all failed"
|
|
);
|
|
return new Response(JSON.stringify(errorPayload), {
|
|
status: lastError?.status || 502,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|