Files
OmniRoute/open-sse/services/speechCombo.ts
sha367 09680013de fix(providers): resolve combo names on /v1/audio/speech and /v1/videos/generations (#10471)
* 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>
2026-08-18 10:50:31 -03:00

183 lines
6.9 KiB
TypeScript

/**
* 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<string, unknown>,
auth: {
request: Request;
policy: { apiKeyInfo?: { id?: string; name?: string } | null };
},
startTime: number
): 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`);
}
// 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" },
});
}