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/); +});