mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
* fix(providers): resolve combo names on /v1/audio/speech and /v1/videos/generations `GET /v1/models` advertises combos with `owned_by: combo`, and chat, embeddings, transcriptions (#9134) and images (#8986, #9239) all resolve those names. Speech and video did not: both rejected a combo name at model validation, before any resolution could happen. POST /v1/audio/speech {"model":"my-combo","input":"hi"} -> 400 Invalid speech model: my-combo. Use format: provider/model POST /v1/videos/generations {"model":"my-combo","prompt":"a cube"} -> 400 Invalid video model: my-combo. Use format: provider/model A client picking a model out of /v1/models therefore could not tell which entries the catalogue would actually accept, and callers ended up hardcoding vendor ids for these two routes while using combo names everywhere else. Both routes now mirror the images route: detect a combo name before the provider lookup and divert to a strategy executor. The two new executors follow imageCombo — expand targets with resolveComboTargets(), filter to targets the route can actually serve, walk them in priority order, and return the first success or the last failure, with 400/401/403 treated as terminal. Two details differ from the image strategy: Speech filters at model level rather than provider level. parseSpeechModel() resolves a provider prefix without checking that the model behind it can speak, so `openai/gpt-4o` would otherwise be accepted as a target and fail only once dispatched. The filter now checks the provider's own model list, and keeps targets from dynamic provider nodes that do not enumerate models. Speech also returns the handler's Response untouched instead of building a JSON body, because that route streams audio; only the ADD-only meta headers are attached, exactly as the direct path does. The failure branch is the only place the body is read. successfulMediaGenerationResponse() gains optional `strategy` and `fallbackAttempts` so the video strategy can report them the way imageCombo does, rather than duplicating the cost calculation. Both are omitted on the direct single-model path, where neither is meaningful. Tests mirror tests/unit/combo/image-combo.test.ts for both routes: combo not found, no capable targets, empty combo, and targets present with no provider connection. 16/16 pass across the three combo test files. * fix(providers): preserve local overrides, custom models and per-target prompt rules through video combo dispatch executeVideoCombo() diverged from the direct /v1/videos/generations route in three ways: it dropped the ComfyUI-style local-override credential lookup for authType:"none" targets, its capability filter only matched the built-in video registry (skipping custom OpenAI-compatible provider nodes tagged with the "videos" endpoint), and the route validated the prompt against the unresolved combo name before combo targets were expanded — rejecting prompt-optional I2V targets that never got the chance to opt out. Extracts the shared resolution rules (resolveVideoModelTarget, isVideoPromptOptional, resolveLocalOverrideCredentials) into src/app/api/v1/_shared/videoModelResolution.ts so the direct route and the combo executor apply identical rules, moves the combo-name diversion ahead of the prompt-required check so validation runs against the real resolved target, and adds per-target prompt validation inside the combo loop so a missing prompt only rules out that target instead of the whole combo. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
183 lines
6.9 KiB
TypeScript
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" },
|
|
});
|
|
}
|