From 09680013debc839c9de3ff8523e5687eebe5c2ef Mon Sep 17 00:00:00 2001 From: sha367 Date: Tue, 18 Aug 2026 17:50:31 +0400 Subject: [PATCH] fix(providers): resolve combo names on /v1/audio/speech and /v1/videos/generations (#10471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- open-sse/services/speechCombo.ts | 182 +++++++++++++++ open-sse/services/videoCombo.ts | 215 ++++++++++++++++++ .../api/v1/_shared/mediaGenerationRoute.ts | 9 + .../api/v1/_shared/videoModelResolution.ts | 81 +++++++ src/app/api/v1/audio/speech/route.ts | 13 ++ src/app/api/v1/videos/generations/route.ts | 81 +++---- tests/unit/combo/speech-combo.test.ts | 124 ++++++++++ tests/unit/combo/video-combo.test.ts | 149 ++++++++++++ tests/unit/video-combo-route.test.ts | 215 ++++++++++++++++++ 9 files changed, 1016 insertions(+), 53 deletions(-) create mode 100644 open-sse/services/speechCombo.ts create mode 100644 open-sse/services/videoCombo.ts create mode 100644 src/app/api/v1/_shared/videoModelResolution.ts create mode 100644 tests/unit/combo/speech-combo.test.ts create mode 100644 tests/unit/combo/video-combo.test.ts create mode 100644 tests/unit/video-combo-route.test.ts diff --git a/open-sse/services/speechCombo.ts b/open-sse/services/speechCombo.ts new file mode 100644 index 0000000000..1d73337784 --- /dev/null +++ b/open-sse/services/speechCombo.ts @@ -0,0 +1,182 @@ +/** + * Speech Combo Strategy Execution + * + * Mirrors imageCombo for /v1/audio/speech: expands combo targets via + * resolveComboTargets(), filters to speech-capable targets, runs each through + * handleAudioSpeech() in priority order, and returns the first success or the + * last failure. + * + * Unlike the image and video strategies, the speech handler returns a Response + * carrying an audio stream rather than a JSON result object, so success is read + * off `response.ok` and the upstream body is passed through untouched — only + * ADD-only meta headers are attached, matching the direct route. + */ +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; +import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts"; +import { resolveDynamicAudioProviders } from "@/app/api/v1/_shared/audioProviderNodes"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts"; +import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta"; +import { generateRequestId } from "@/shared/utils/requestId"; +import { calculateModalCost } from "@/lib/usage/costCalculator"; +import { getClientIpFromRequest } from "@/lib/ipUtils"; +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"; + +/** + * Execute a full combo strategy for a text-to-speech request. + */ +export async function executeSpeechCombo( + comboName: string, + body: Record, + auth: { + request: Request; + policy: { apiKeyInfo?: { id?: string; name?: string } | null }; + }, + startTime: number +): Promise { + 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`); + } + + // Dynamic provider nodes are resolved once and reused for every target, the + // same list the direct route builds. + const dynamicProviders = await resolveDynamicAudioProviders("/audio/speech", "audio-speech"); + + // Filter at model level, not provider level. parseSpeechModel resolves a + // provider prefix without checking that the model behind it can speak, so a + // chat model on a speech-capable provider (openai/gpt-4o) would otherwise be + // accepted as a target and only fail once dispatched. + const speechTargets = targets.filter((t) => { + if (!t.modelStr) return false; + const { provider, model } = parseSpeechModel(t.modelStr, dynamicProviders); + if (!provider) return false; + const config = + getSpeechProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null; + if (!config) return false; + // Dynamic provider nodes do not always enumerate their models; when the + // list is absent there is nothing to check against, so the target stands. + if (!Array.isArray(config.models) || config.models.length === 0) return true; + return config.models.some((m: { id: string }) => m.id === model || m.id === t.modelStr); + }); + + if (speechTargets.length === 0) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No speech-capable targets in combo "${comboName}"` + ); + } + + const clientIp = getClientIpFromRequest(auth.request); + let lastError: { status: number; error: string } | null = null; + let fallbackCount = 0; + + for (const target of speechTargets) { + const { provider: targetProvider, model: resolvedModel } = parseSpeechModel( + target.modelStr, + dynamicProviders + ); + if (!targetProvider) { + lastError = { status: 400, error: `Invalid speech model: ${target.modelStr}` }; + fallbackCount += 1; + continue; + } + + const providerConfig = + getSpeechProvider(targetProvider) || + dynamicProviders.find((dp) => dp.id === targetProvider) || + null; + + let credentials = null; + if (providerConfig && providerConfig.authType !== "none") { + const credentialKey = providerConfig.credentialProviderId || targetProvider; + try { + credentials = await getProviderCredentialsWithQuotaPreflight(credentialKey); + } catch { + lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (!credentials) { + lastError = { status: 400, error: `No credentials for provider: ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (isAllRateLimitedCredentials(credentials)) { + lastError = { status: 429, error: `[${targetProvider}] All accounts rate limited` }; + fallbackCount += 1; + continue; + } + } + + const response = await handleAudioSpeech({ + body: { ...body, model: target.modelStr }, + credentials, + resolvedProvider: providerConfig, + resolvedModel, + clientIp, + }); + + if (response?.ok) { + await clearRecoveredProviderState(credentials); + const characters = typeof body.input === "string" ? body.input.length : 0; + const costUsd = await calculateModalCost( + "audio", + targetProvider, + resolvedModel || target.modelStr, + { characters } + ); + return attachOmniRouteMetaToResponse(response, { + provider: targetProvider, + model: resolvedModel || target.modelStr, + costUsd, + latencyMs: Date.now() - startTime, + requestId: generateRequestId(), + strategy: "priority", + fallbackAttempts: fallbackCount, + }); + } + + const status = response?.status || 500; + // The body is read only on the failure path, where it is small and about to + // be discarded anyway; a successful audio stream is never consumed here. + let error = `Speech generation failed (HTTP ${status})`; + try { + const text = await response?.clone().text(); + if (text) error = text.slice(0, 300); + } catch { + // non-text or already-consumed body — keep the status-line message + } + + 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", + "Speech combo targets all failed" + ); + return new Response(JSON.stringify(errorPayload), { + status: lastError?.status || 502, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/open-sse/services/videoCombo.ts b/open-sse/services/videoCombo.ts new file mode 100644 index 0000000000..9ab9f84c67 --- /dev/null +++ b/open-sse/services/videoCombo.ts @@ -0,0 +1,215 @@ +/** + * 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, + auth: { + request: Request; + policy: { apiKeyInfo?: { id?: string; name?: string } | null }; + }, + startTime: number, + log: typeof logger +): Promise { + 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" }, + }); +} diff --git a/src/app/api/v1/_shared/mediaGenerationRoute.ts b/src/app/api/v1/_shared/mediaGenerationRoute.ts index 6d756c9987..bcec963477 100644 --- a/src/app/api/v1/_shared/mediaGenerationRoute.ts +++ b/src/app/api/v1/_shared/mediaGenerationRoute.ts @@ -112,6 +112,8 @@ export async function successfulMediaGenerationResponse({ model, startTime, duration, + strategy, + fallbackAttempts, }: { result: { data: unknown }; billingMode: "audio" | "video"; @@ -119,6 +121,11 @@ export async function successfulMediaGenerationResponse({ model: string; startTime: number; duration: unknown; + // Set by combo execution so the response reports which strategy picked the + // target and how many earlier targets were skipped. Omitted on the direct + // single-model path, where neither is meaningful. + strategy?: string; + fallbackAttempts?: number; }) { const seconds = Number(duration) || 0; const costUsd = await calculateModalCost(billingMode, provider, model, { seconds }); @@ -129,6 +136,8 @@ export async function successfulMediaGenerationResponse({ costUsd, latencyMs: Date.now() - startTime, requestId: generateRequestId(), + ...(strategy ? { strategy } : {}), + ...(fallbackAttempts !== undefined ? { fallbackAttempts } : {}), }); return new Response(JSON.stringify(result.data), { diff --git a/src/app/api/v1/_shared/videoModelResolution.ts b/src/app/api/v1/_shared/videoModelResolution.ts new file mode 100644 index 0000000000..f3a2da8668 --- /dev/null +++ b/src/app/api/v1/_shared/videoModelResolution.ts @@ -0,0 +1,81 @@ +import { getAllCustomModels } from "@/lib/db/models"; +import { parseVideoModel } from "@omniroute/open-sse/config/videoRegistry.ts"; +import { getProviderCredentialsWithQuotaPreflight } from "@/sse/services/auth"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; + +export type VideoModelTarget = { + provider: string | null; + model: string | null; + isCustomModel: boolean; +}; + +/** + * Resolve a `provider/model` string to a video target, checking the built-in + * video registry first and falling back to custom OpenAI-compatible provider + * nodes tagged with the "videos" endpoint (mirrors the direct + * /v1/videos/generations route's custom-model scan). Shared by the direct + * route and the combo executor so both paths cover the same target set. + */ +export async function resolveVideoModelTarget( + modelStr: string | null | undefined +): Promise { + const parsed = parseVideoModel(modelStr ?? null); + if (parsed.provider) { + return { provider: parsed.provider, model: parsed.model, isCustomModel: false }; + } + + if (!modelStr) { + return { provider: null, model: null, isCustomModel: false }; + } + + try { + const customModelsMap = (await getAllCustomModels()) as Record; + for (const [providerId, models] of Object.entries(customModelsMap)) { + if (!Array.isArray(models)) continue; + for (const model of models as Array<{ id?: string; supportedEndpoints?: unknown }>) { + if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue; + if (!model.supportedEndpoints.includes("videos")) continue; + const fullId = `${providerId}/${model.id}`; + if (fullId === modelStr) { + return { provider: providerId, model: model.id, isCustomModel: true }; + } + } + } + } catch { + // registry read failure — fall through to "not found" + } + + return { provider: null, model: null, isCustomModel: false }; +} + +/** + * I2V models that accept an omitted prompt (the source image already carries + * enough context). Mirrors the allow-list the direct route applies before + * promptRequiredResponse(); kept here so combo targets get identical rules. + */ +export function isVideoPromptOptional(parsed: { provider: string | null; model: string | null }) { + return ( + (parsed.model === "happyhorse-1.1-i2v" && + (parsed.provider === "alibaba" || + parsed.provider === "bailian-coding-plan" || + parsed.provider === "qwen-cloud-token-plan" || + parsed.provider === "qwen-cloud")) || + (parsed.provider === "qwen-cloud" && parsed.model === "wan2.7-i2v") || + (parsed.provider === "alibaba" && + (parsed.model === "wan2.7-i2v-2026-04-25" || parsed.model === "wan2.6-i2v-flash")) + ); +} + +/** + * #6928: best-effort per-connection base-URL override lookup for local no-auth + * media providers (ComfyUI). Returns null instead of failing when no connection + * exists — local providers must keep working with zero configuration. Shared + * by the direct route and the combo executor so a configured ComfyUI base URL + * is honored the same way regardless of which path dispatched the request. + */ +export async function resolveLocalOverrideCredentials(provider: string) { + const localCredentials = await getProviderCredentialsWithQuotaPreflight(provider); + return localCredentials && !isAllRateLimitedCredentials(localCredentials) + ? localCredentials + : null; +} diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 278d04aa2e..799a933fed 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -55,6 +55,19 @@ async function postHandler(request, context) { const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; + // Detect a combo name and divert to full speech combo execution, mirroring + // the images route. Checks before parseSpeechModel so a combo name is never + // rejected as an invalid `provider/model` id — /v1/models advertises these + // names, so refusing them here made the catalogue dishonest. + if (body.model && typeof body.model === "string" && !body.model.includes("/")) { + const { getComboByName } = await import("@/lib/db/combos"); + const combo = await getComboByName(body.model); + if (combo) { + const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo"); + return executeSpeechCombo(body.model, body, { request, policy }, startTime); + } + } + // Provider nodes eligible for speech: this route's own audio type plus general // chat/responses gateways. Remote hosts are opt-in (default OFF). const dynamicProviders = await resolveDynamicAudioProviders("/audio/speech", "audio-speech"); diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index ef32fe0818..7e391f6923 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -1,12 +1,11 @@ import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; -import { getAllCustomModels } from "@/lib/db/models"; import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, } from "@/sse/services/auth"; -import { parseVideoModel, getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts"; +import { getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import * as log from "@/sse/utils/logger"; @@ -25,6 +24,11 @@ import { } from "@/app/api/v1/_shared/mediaGenerationRoute"; import type { MediaGenerationResultLike } from "@/app/api/v1/_shared/mediaGenerationRoute"; import { getSpecialtyModelsResponse } from "@/app/api/v1/_shared/specialtyCatalog"; +import { + isVideoPromptOptional, + resolveLocalOverrideCredentials, + resolveVideoModelTarget, +} from "@/app/api/v1/_shared/videoModelResolution"; export const dynamic = "force-dynamic"; @@ -46,18 +50,6 @@ export async function GET(request?: Request) { ); } -/** - * #6928: best-effort per-connection base-URL override lookup for local no-auth - * media providers (ComfyUI). Returns null instead of failing when no connection - * exists — local providers must keep working with zero configuration. - */ -async function resolveLocalOverrideCredentials(provider) { - const localCredentials = await getProviderCredentialsWithQuotaPreflight(provider); - return localCredentials && !isAllRateLimitedCredentials(localCredentials) - ? localCredentials - : null; -} - /** * POST /v1/videos/generations — generate videos */ @@ -68,52 +60,30 @@ async function postHandler(request, context) { } const body = parsed.body; const startTime = Date.now(); - const parsedModel = parseVideoModel(body.model); - - const promptOptional = - (parsedModel.model === "happyhorse-1.1-i2v" && - (parsedModel.provider === "alibaba" || - parsedModel.provider === "bailian-coding-plan" || - parsedModel.provider === "qwen-cloud-token-plan" || - parsedModel.provider === "qwen-cloud")) || - (parsedModel.provider === "qwen-cloud" && parsedModel.model === "wan2.7-i2v") || - (parsedModel.provider === "alibaba" && - (parsedModel.model === "wan2.7-i2v-2026-04-25" || parsedModel.model === "wan2.6-i2v-flash")); - if (!promptOptional) { - const promptError = promptRequiredResponse(body); - if (promptError) return promptError; - } // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; - // Parse model to get provider - let { provider, model: requestedModel } = parsedModel; - let isCustomModel = false; - if (!provider) { - // Custom OpenAI-compatible provider nodes (mirrors images route): scan the - // dynamic model registry for a matching `${nodeId}/${modelId}` entry. - try { - const customModelsMap = (await getAllCustomModels()) as Record; - for (const [providerId, models] of Object.entries(customModelsMap)) { - if (!Array.isArray(models)) continue; - for (const model of models) { - if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue; - if (!model.supportedEndpoints.includes("videos")) continue; - const fullId = `${providerId}/${model.id}`; - if (fullId === body.model) { - provider = providerId; - requestedModel = model.id; - isCustomModel = true; - break; - } - } - } - } catch { - // registry read failure — fall through to invalid-model error below + // Detect a combo name and divert to full video combo execution, mirroring + // the images route. Checks before the provider lookup — and before the + // prompt-required check below — so a combo name is never rejected as an + // invalid `provider/model` id or against the wrong model's prompt rules: + // /v1/models advertises these names, and prompt requirements depend on the + // resolved target, which for a combo is only known after expansion. + if (body.model && typeof body.model === "string" && !body.model.includes("/")) { + const { getComboByName } = await import("@/lib/db/combos"); + const combo = await getComboByName(body.model); + if (combo) { + const { executeVideoCombo } = await import("@omniroute/open-sse/services/videoCombo"); + return executeVideoCombo(body.model, body, { request, policy }, startTime, log); } } + + // Parse model to get provider — checks the built-in registry, then custom + // OpenAI-compatible provider nodes tagged with the "videos" endpoint. + const resolvedTarget = await resolveVideoModelTarget(body.model); + const { provider, model: requestedModel, isCustomModel } = resolvedTarget; if (!provider) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -121,6 +91,11 @@ async function postHandler(request, context) { ); } + if (!isVideoPromptOptional(resolvedTarget)) { + const promptError = promptRequiredResponse(body); + if (promptError) return promptError; + } + // Check provider config for auth bypass const providerConfig = getVideoProvider(provider); diff --git a/tests/unit/combo/speech-combo.test.ts b/tests/unit/combo/speech-combo.test.ts new file mode 100644 index 0000000000..d011de5ba4 --- /dev/null +++ b/tests/unit/combo/speech-combo.test.ts @@ -0,0 +1,124 @@ +/** + * Tests for speech combo strategy execution + * + * Mirrors tests/unit/combo/image-combo.test.ts. executeSpeechCombo takes no + * logger argument — the speech handler returns a Response directly rather than + * a result object, so there is nothing for the strategy to log through. + */ +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-speech-combo-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-speech-combo-tests"; + +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + +const core = await import("@/lib/db/core.ts"); +const { createCombo } = await import("@/lib/db/combos"); +const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo"); + +function createRequest(model: string): Request { + return new Request("http://localhost:20128/v1/audio/speech", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, input: "hello there" }), + }); +} + +function createMockAuth() { + return { + request: createRequest("test-combo"), + policy: { apiKeyInfo: { id: "test-key", name: "test-key" } }, + }; +} + +async function cleanupTestDataDir() { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + return; + } catch (error: unknown) { + 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(); +}); + +test("returns 400 when combo is not found", async () => { + const response = await executeSpeechCombo( + "nonexistent-combo", + { model: "nonexistent-combo", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); + +test("returns 400 when combo has no speech-capable targets", async () => { + await createCombo({ + name: "chat-only-combo", + strategy: "priority", + models: ["openai/gpt-4o"], + }); + + const response = await executeSpeechCombo( + "chat-only-combo", + { model: "chat-only-combo", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(bodyStr.includes("No speech-capable targets"), "Tells user no speech 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 response = await executeSpeechCombo( + "empty-combo", + { model: "empty-combo", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.equal(response.status, 400); +}); + +test("fails cleanly when speech targets exist but no provider connection does", async () => { + await createCombo({ + name: "spc-no-conn", + strategy: "fill-first", + models: ["deepgram/aura-asteria-en"], + }); + + const response = await executeSpeechCombo( + "spc-no-conn", + { model: "spc-no-conn", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.ok(response.status >= 400, "Surfaces a failure rather than a fake success"); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); diff --git a/tests/unit/combo/video-combo.test.ts b/tests/unit/combo/video-combo.test.ts new file mode 100644 index 0000000000..d429ada930 --- /dev/null +++ b/tests/unit/combo/video-combo.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for video combo strategy execution + * + * Mirrors tests/unit/combo/image-combo.test.ts. Seeds a temp DATA_DIR with a + * combo in the DB so executeVideoCombo resolves targets through the real DB + * path, and covers combo resolution, target 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-video-combo-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-video-combo-tests"; + +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + +const core = await import("@/lib/db/core.ts"); +const { createCombo } = await import("@/lib/db/combos"); +const { executeVideoCombo } = await import("@omniroute/open-sse/services/videoCombo"); + +type LogEntry = { level: string; tag: unknown; msg: unknown }; + +function createLog() { + const entries: LogEntry[] = []; + const record = + (level: string) => + (tag: unknown, msg: unknown): number => + entries.push({ level, tag, msg }); + return { + info: record("info"), + warn: record("warn"), + error: record("error"), + debug: record("debug"), + entries, + }; +} + +function createRequest(model: string): Request { + return new Request("http://localhost:20128/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, prompt: "a red cube" }), + }); +} + +function createMockAuth() { + return { + request: createRequest("test-combo"), + policy: { apiKeyInfo: { id: "test-key", name: "test-key" } }, + }; +} + +async function cleanupTestDataDir() { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + return; + } catch (error: unknown) { + 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(); +}); + +test("returns 400 when combo is not found", async () => { + const log = createLog(); + const response = await executeVideoCombo( + "nonexistent-combo", + { model: "nonexistent-combo", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); + +test("returns 400 when combo has no video-capable targets", async () => { + await createCombo({ + name: "chat-only-combo", + strategy: "priority", + models: ["openai/gpt-4o"], + }); + + const log = createLog(); + const response = await executeVideoCombo( + "chat-only-combo", + { model: "chat-only-combo", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(bodyStr.includes("No video-capable targets"), "Tells user no video 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 executeVideoCombo( + "empty-combo", + { model: "empty-combo", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 400); +}); + +test("fails cleanly when video targets exist but no provider connection does", async () => { + await createCombo({ + name: "vid-no-conn", + strategy: "fill-first", + models: ["runwayml/gen4_turbo"], + }); + + const log = createLog(); + const response = await executeVideoCombo( + "vid-no-conn", + { model: "vid-no-conn", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.ok(response.status >= 400, "Surfaces a failure rather than a fake success"); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); diff --git a/tests/unit/video-combo-route.test.ts b/tests/unit/video-combo-route.test.ts new file mode 100644 index 0000000000..5f9561382d --- /dev/null +++ b/tests/unit/video-combo-route.test.ts @@ -0,0 +1,215 @@ +/** + * Route-level regression tests for #10471: combo names resolved on + * /v1/videos/generations. Exercises the actual POST handler (not + * executeVideoCombo directly) so the combo-name diversion, local-override + * credential preservation, custom-model coverage, and per-target prompt + * validation are proven end to end — the way a real client hits the route. + */ +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-video-combo-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "video-combo-route-test-secret"; +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-jwt-secret-for-video-combo-route-tests"; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { createCombo } = await import("../../src/lib/db/combos.ts"); +const videoRoute = await import("../../src/app/api/v1/videos/generations/route.ts"); + +const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; + +function createResponse(body: BodyInit | null, init?: ResponseInit) { + return new Response(body, init); +} + +function immediateButSafeTimeout( + callback: (...args: unknown[]) => void, + ms?: number, + ...args: unknown[] +) { + if (ms === 20_000 || ms === 5_000 || ms === 2_000) { + return originalSetTimeout(callback as TimerHandler, 0, ...args); + } + return originalSetTimeout(callback as TimerHandler, ms, ...args); +} + +function postVideo(body: Record) { + return videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + ); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; +}); + +test.after(() => { + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("video route diverts a combo name to the combo executor and honors the ComfyUI local-override base URL", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + const OVERRIDE = "http://custom-comfy:9999"; + await providersDb.createProviderConnection({ + provider: "comfyui", + authType: "none", + providerSpecificData: { baseUrl: OVERRIDE }, + }); + await createCombo({ + name: "vid-local-override-combo", + strategy: "priority", + models: ["comfyui/animatediff"], + }); + + const seenUrls: string[] = []; + globalThis.fetch = (async (url: unknown) => { + const stringUrl = String(url); + seenUrls.push(stringUrl); + + if (stringUrl.endsWith("/prompt")) { + return createResponse(JSON.stringify({ prompt_id: "combo-prompt-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stringUrl.includes("/history/combo-prompt-1")) { + return createResponse( + JSON.stringify({ + "combo-prompt-1": { + outputs: { 1: [{ filename: "out.webp", subfolder: "", type: "output" }] }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return createResponse(new Uint8Array([1, 2, 3]), { status: 200 }); + }) as typeof fetch; + + const response = await postVideo({ model: "vid-local-override-combo", prompt: "a red cube" }); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("X-OmniRoute-Provider"), "comfyui"); + assert.equal(response.headers.get("X-OmniRoute-Model"), "comfyui/animatediff"); + // Fallback-attempts header is only emitted when a prior target failed first; + // this combo has a single target that succeeds on the first try. + assert.equal(response.headers.get("X-OmniRoute-Fallback-Attempts"), null); + assert.match(response.headers.get("X-OmniRoute-Decision") ?? "", /strategy=priority/); + assert.ok(seenUrls.length > 0, "the ComfyUI client made at least one request"); + assert.ok( + seenUrls.every((u) => u.startsWith(OVERRIDE)), + `expected every request to use the connection override ${OVERRIDE}, got: ${seenUrls.join(", ")}` + ); +}); + +test("video route resolves a custom video model reached through combo dispatch", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + await modelsDb.addCustomModel( + "combo-custom-video-provider", + "combo-video-v1", + "Combo Video v1", + "manual", + "chat-completions", + ["videos"] + ); + await providersDb.createProviderConnection({ + provider: "combo-custom-video-provider", + authType: "apikey", + apiKey: "combo-custom-key", + providerSpecificData: { baseUrl: "https://combo-custom.example.com/v1/videos/generations" }, + }); + await createCombo({ + name: "vid-custom-model-combo", + strategy: "priority", + models: ["combo-custom-video-provider/combo-video-v1"], + }); + + let captured: { url: string; body: unknown; headers: unknown } | null = null; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const requestBody = init?.body ? JSON.parse(String(init.body)) : {}; + captured = { url: String(url), body: requestBody, headers: init?.headers }; + return createResponse( + JSON.stringify({ + created: Math.floor(Date.now() / 1000), + data: [{ url: "https://combo-custom.example.com/generated.mp4", format: "mp4" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; + + const response = await postVideo({ + model: "vid-custom-model-combo", + prompt: "a cat playing piano", + duration: 5, + }); + + const payload = (await response.json()) as { data: Array<{ url?: string }> }; + assert.equal(response.status, 200); + assert.equal(payload.data[0].url, "https://combo-custom.example.com/generated.mp4"); + + assert.ok(captured, "fetch should have been called for the resolved custom model"); + assert.equal( + captured!.url, + "https://combo-custom.example.com/v1/videos/generations" + ); + assert.equal(captured!.headers.Authorization, "Bearer combo-custom-key"); + // The upstream call strips the provider prefix — resolvedProvider flowed + // through the combo path the same way it does on the direct route. + assert.equal(captured!.body.model, "combo-video-v1"); +}); + +test("video route validates the prompt against the resolved combo target, not the combo name", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + // alibaba/wan2.7-i2v-2026-04-25 is prompt-optional (I2V). No provider + // connection is seeded, so credential resolution will fail — but it must + // fail with a credentials error, never "Prompt is required": the direct + // route already treats this model as prompt-optional, and combo dispatch + // must apply the same per-target rule instead of rejecting on the + // unresolved combo name before expansion. + await createCombo({ + name: "vid-i2v-optional-combo", + strategy: "priority", + models: ["alibaba/wan2.7-i2v-2026-04-25"], + }); + + const response = await postVideo({ model: "vid-i2v-optional-combo" }); + const payload = (await response.json()) as { error?: { message?: string } }; + + assert.ok(response.status >= 400, "no credentials configured — the target must fail"); + assert.ok( + !payload.error?.message?.includes("Prompt is required"), + `expected a credentials failure, not a prompt-required rejection; got: ${payload.error?.message}` + ); + assert.match(payload.error?.message ?? "", /No credentials/i); +}); + +test("video route still enforces the prompt requirement for a combo targeting a non-optional model", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + await createCombo({ + name: "vid-t2v-required-combo", + strategy: "priority", + models: ["comfyui/animatediff"], + }); + + const response = await postVideo({ model: "vid-t2v-required-combo" }); + const payload = (await response.json()) as { error?: { message?: string } }; + + assert.equal(response.status, 400); + assert.match(payload.error?.message ?? "", /Prompt is required/); +});