Files
OmniRoute/src/lib/providers/validation.ts
Maxim Seshuk 7a369ba6e3 feat(audio): add Soniox STT + TTS provider (#9579)
Merge-train validated (tip 6ce4effef8). Vitest failures confirmed as base-red (#9679).
2026-08-07 20:54:14 -03:00

460 lines
18 KiB
TypeScript

import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import {
isClaudeCodeCompatibleProvider,
isAnthropicCompatibleProvider,
isLocalProvider,
isOpenAICompatibleProvider,
isSelfHostedChatProvider,
providerAllowsOptionalApiKey,
WEB_COOKIE_PROVIDERS,
} from "@/shared/constants/providers";
import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal";
import { validateImageProviderApiKey } from "@/lib/providers/imageValidation";
import { usesCcWireImage } from "@omniroute/open-sse/services/ccWireImageBuiltins.ts";
import {
isAlibabaRegionalProvider,
resolveAlibabaProviderBaseUrl,
} from "@/shared/constants/alibabaProviderRegions";
import { buildProviderHeaders, buildProviderUrl } from "@omniroute/open-sse/services/provider.ts";
import {
OPENAI_LIKE_FORMATS,
GEMINI_LIKE_FORMATS,
normalizeBaseUrl,
addModelsSuffix,
resolveBaseUrl,
} from "./validation/urlHelpers";
import { toValidationErrorResult } from "./validation/transport";
import {
validateDeepSeekWebProvider,
validateQwenWebProvider,
validateGrokWebProvider,
validateChatGptWebProvider,
validatePerplexityWebProvider,
validateBlackboxWebProvider,
validateKimiWebProvider,
} from "./validation/webProvidersA";
import {
validateMuseSparkWebProvider,
validateAdaptaWebProvider,
validateClaudeWebProvider,
validateGeminiWebProvider,
validateCopilotM365WebProvider,
validateCopilotWebProvider,
validateT3WebProvider,
validateJulesProvider,
validateDevinCloudAgentProvider,
validateInnerAiProvider,
validateNotionWebProvider,
} from "./validation/webProvidersB";
import {
validateHerokuProvider,
validateDatabricksProvider,
validateDataRobotProvider,
validateSnowflakeProvider,
validateGigachatProvider,
validateAzureOpenAIProvider,
validateAzureAiProvider,
validateWatsonxProvider,
validateOciProvider,
validateSapProvider,
} from "./validation/cloudProviders";
import {
validateDeepgramProvider,
validateAssemblyAIProvider,
validateRevAiProvider,
validateSonioxProvider,
validateElevenLabsProvider,
validateInworldProvider,
validateKieProvider,
validateAwsPollyProvider,
validateBailianCodingPlanProvider,
validateQwenCloudTokenPlanProvider,
validateRekaProvider,
validateMaritalkProvider,
validateNlpCloudProvider,
validateRunwayProvider,
validateNousResearchProvider,
validatePoeProvider,
} from "./validation/audioMiscProviders";
import { validateSearchProvider, SEARCH_VALIDATOR_CONFIGS } from "./validation/searchProviders";
import {
validateClarifaiProvider,
validateEmbeddingApiProvider,
validateRerankApiProvider,
} from "./validation/embeddingProviders";
import {
validateBedrockProvider,
validateOpenAILikeProvider,
validateCommandCodeProvider,
validateGeminiLikeProvider,
validateHuggingFaceProvider,
validateOpenAICompatibleProvider,
} from "./validation/openaiFormat";
import {
validateAnthropicLikeProvider,
validateAnthropicCompatibleProvider,
validateClaudeCodeCompatibleProvider,
} from "./validation/anthropicFormat";
import {
validateWebCookieProvider,
bytezValidationResultFromStatus,
validateBytezProvider,
} from "./validation/webCookie";
import {
validateV0VercelProvider,
validateAuggieProvider,
validateQoderProvider,
validateKiroProvider,
validateGitlabProvider,
validateVertexProvider,
validateVertexPartnerProvider,
validateLongcatProvider,
validateNvidiaProvider,
validateZaiProvider,
validateXiaomiMimoProvider,
buildGitlawbValidators,
} from "./validation/specialtyInline";
// validateCommandCodeProvider + validateClaudeCodeCompatibleProvider have external importers
// (provider-nodes/validate route + tests) — re-export to preserve the historical public surface.
export { validateCommandCodeProvider, validateClaudeCodeCompatibleProvider };
// isRetryableProxyTarget + isSecurityBlockError now live in ./validation/transport. Re-export them
// here to preserve the historical public surface (tests + route handlers import them via this module).
export { isRetryableProxyTarget, isSecurityBlockError } from "./validation/transport";
// validateWebCookieProvider + bytezValidationResultFromStatus have external importers (tests +
// the web-cookie fallback suites) — re-export to preserve the historical public surface.
export { validateWebCookieProvider, bytezValidationResultFromStatus };
// validateWebCookieProvider, bytezValidationResultFromStatus, validateBytezProvider, and
// validateKiroApiKeyRuntimeProbe now live in ./validation/webCookie and ./validation/kiro.
// They are re-exported above to preserve the historical public surface.
export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) {
const requiresApiKey = !providerAllowsOptionalApiKey(provider);
const isLocal = isLocalProvider(provider);
if (!provider || (requiresApiKey && !apiKey)) {
return { valid: false, error: "Provider and API key required", unsupported: false };
}
if (isOpenAICompatibleProvider(provider)) {
try {
return await validateOpenAICompatibleProvider({ apiKey, providerSpecificData });
} catch (error: any) {
return toValidationErrorResult(error);
}
}
if (isAnthropicCompatibleProvider(provider)) {
try {
if (isClaudeCodeCompatibleProvider(provider)) {
return await validateClaudeCodeCompatibleProvider({ apiKey, providerSpecificData });
}
return await validateAnthropicCompatibleProvider({
apiKey,
providerSpecificData,
isLocal,
});
} catch (error: any) {
return toValidationErrorResult(error);
}
}
// buildOpengatewayValidator + buildGitlawbValidators now live in ./validation/specialtyInline
// (god-file decomposition). The host still owns the SPECIALTY_VALIDATORS map below; only the
// validator bodies were extracted as leaf functions taking `isLocal` where the original
// closure captured it.
// ── Specialty provider validation ──
const SPECIALTY_VALIDATORS = {
"v0-vercel": ({ apiKey, providerSpecificData }: any) =>
validateV0VercelProvider({ apiKey, providerSpecificData, isLocal }),
jules: validateJulesProvider,
// "devin" is the Cognition cloud-agent provider (distinct from the "devin-cli"
// LLM/ACP provider, which is already registered in providerRegistry). Wired here
// for parity with the "jules" cloud-agent entry above — see #6142.
devin: validateDevinCloudAgentProvider,
auggie: validateAuggieProvider,
qoder: validateQoderProvider,
kiro: validateKiroProvider,
"command-code": validateCommandCodeProvider,
huggingface: validateHuggingFaceProvider,
// #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to
// its catalog, so the generic chat probe can't validate a fresh key.
bytez: validateBytezProvider,
deepgram: validateDeepgramProvider,
assemblyai: validateAssemblyAIProvider,
"rev-ai": validateRevAiProvider,
soniox: validateSonioxProvider,
"fal-ai": ({ apiKey, providerSpecificData }: any) =>
validateImageProviderApiKey({ provider: "fal-ai", apiKey, providerSpecificData }),
"stability-ai": ({ apiKey, providerSpecificData }: any) =>
validateImageProviderApiKey({ provider: "stability-ai", apiKey, providerSpecificData }),
"black-forest-labs": ({ apiKey, providerSpecificData }: any) =>
validateImageProviderApiKey({ provider: "black-forest-labs", apiKey, providerSpecificData }),
recraft: ({ apiKey, providerSpecificData }: any) =>
validateImageProviderApiKey({ provider: "recraft", apiKey, providerSpecificData }),
topaz: ({ apiKey, providerSpecificData }: any) =>
validateImageProviderApiKey({ provider: "topaz", apiKey, providerSpecificData }),
elevenlabs: validateElevenLabsProvider,
inworld: validateInworldProvider,
kie: validateKieProvider,
"aws-polly": validateAwsPollyProvider,
"bailian-coding-plan": validateBailianCodingPlanProvider,
"qwen-cloud-token-plan": validateQwenCloudTokenPlanProvider,
heroku: validateHerokuProvider,
databricks: validateDatabricksProvider,
datarobot: validateDataRobotProvider,
watsonx: validateWatsonxProvider,
oci: validateOciProvider,
sap: validateSapProvider,
bedrock: validateBedrockProvider,
modal: ({ apiKey, providerSpecificData }: any) => {
// Modal is bring-your-own-deploy — it requires a Base URL pointing to the user's
// OpenAI-compatible Modal app. Without it, validateOpenAILikeProvider would build an
// empty probe URL and trip parseOutboundUrl with a raw guard error ("Invalid outbound
// URL: "). Surface an actionable message instead. See #9102.
const baseUrl = (providerSpecificData?.baseUrl || "").trim();
if (!baseUrl) {
return {
valid: false,
error:
"Modal requires a Base URL pointing to your OpenAI-compatible Modal app " +
"(e.g. https://<workspace>--<app>.modal.run/v1). " +
"Fill in the \"Base URL override\" field.",
};
}
return validateOpenAILikeProvider({
provider: "modal",
apiKey,
providerSpecificData,
baseUrl: normalizeBaseUrl(baseUrl),
modelId: MODAL_DEFAULT_VALIDATION_MODEL_ID,
isLocal,
});
},
"nous-research": validateNousResearchProvider,
poe: validatePoeProvider,
clarifai: validateClarifaiProvider,
reka: validateRekaProvider,
maritalk: validateMaritalkProvider,
nlpcloud: validateNlpCloudProvider,
runwayml: validateRunwayProvider,
snowflake: validateSnowflakeProvider,
gigachat: validateGigachatProvider,
"deepseek-web": validateDeepSeekWebProvider,
"grok-web": validateGrokWebProvider,
"qwen-web": validateQwenWebProvider,
"kimi-web": validateKimiWebProvider,
"chatgpt-web": validateChatGptWebProvider,
"perplexity-web": validatePerplexityWebProvider,
"blackbox-web": validateBlackboxWebProvider,
"muse-spark-web": validateMuseSparkWebProvider,
"inner-ai": validateInnerAiProvider,
"adapta-web": validateAdaptaWebProvider,
"claude-web": validateClaudeWebProvider,
"gemini-web": validateGeminiWebProvider,
"notion-web": validateNotionWebProvider,
"copilot-m365-web": validateCopilotM365WebProvider,
"copilot-web": validateCopilotWebProvider,
"t3-web": validateT3WebProvider,
"azure-openai": validateAzureOpenAIProvider,
"azure-ai": validateAzureAiProvider,
"voyage-ai": ({ apiKey, providerSpecificData }: any) => {
const embeddingProvider = getEmbeddingProvider("voyage-ai");
return validateEmbeddingApiProvider({
apiKey,
providerSpecificData,
url: embeddingProvider?.baseUrl,
modelId: embeddingProvider?.models?.[0]?.id || "voyage-4-lite",
});
},
"jina-ai": ({ apiKey, providerSpecificData }: any) => {
const rerankProvider = getRerankProvider("jina-ai");
return validateRerankApiProvider({
apiKey,
providerSpecificData,
url: rerankProvider?.baseUrl,
modelId: rerankProvider?.models?.[0]?.id || "jina-reranker-v3",
});
},
gitlab: ({ apiKey, providerSpecificData }: any) =>
validateGitlabProvider({ apiKey, providerSpecificData, isLocal }),
vertex: validateVertexProvider,
"vertex-partner": validateVertexPartnerProvider,
longcat: ({ apiKey, providerSpecificData }: any) =>
validateLongcatProvider({ apiKey, providerSpecificData, isLocal }),
nvidia: validateNvidiaProvider,
zai: validateZaiProvider,
"xiaomi-mimo": ({ apiKey, providerSpecificData }: any) =>
validateXiaomiMimoProvider({ apiKey, providerSpecificData, isLocal }),
// Gitlawb Opengateway — Xiaomi MiMo compatible, same /models endpoint limitation.
// Bypass /models probe in favor of chat/completions, matching xiaomi-mimo's pattern.
// Uses a factory to share validation logic across Opengateway provider variants.
...buildGitlawbValidators(
[
["gitlawb", "https://opengateway.gitlawb.com/v1/xiaomi-mimo", "mimo-v2.5-pro"],
["gitlawb-gmi", "https://opengateway.gitlawb.com/v1/gmi-cloud", "XiaomiMiMo/MiMo-V2.5-Pro"],
],
isLocal
),
// Search providers — use factored validator
...Object.fromEntries(
Object.entries(SEARCH_VALIDATOR_CONFIGS).map(([id, configFn]) => [
id,
({ apiKey, providerSpecificData }: any) => {
const { url, init } = configFn(apiKey, providerSpecificData);
return validateSearchProvider(url, init, providerSpecificData, isLocal);
},
])
),
};
if (SPECIALTY_VALIDATORS[provider]) {
try {
return await SPECIALTY_VALIDATORS[provider]({ apiKey, providerSpecificData });
} catch (error: any) {
return toValidationErrorResult(error);
}
}
// Web-cookie providers WITHOUT a dedicated specialty validator above fall back to the generic
// session-ping check (AUTH_007 SESSION_EXPIRED on 401/403). Providers that DO have a rich
// per-provider validator (grok-web, chatgpt-web, claude-web, …) are handled by
// SPECIALTY_VALIDATORS first and must not be shadowed by this generic probe (issue: the
// #4023 dispatch was placed too early and intercepted every web-cookie provider).
if (WEB_COOKIE_PROVIDERS[provider]) {
try {
return await validateWebCookieProvider({ provider, apiKey, providerSpecificData });
} catch (error: any) {
return toValidationErrorResult(error);
}
}
const entry = getRegistryEntry(provider);
if (!entry) {
if (isSelfHostedChatProvider(provider)) {
return await validateOpenAILikeProvider({
provider,
apiKey,
baseUrl: resolveBaseUrl(null, providerSpecificData),
providerSpecificData,
modelId: "local-model",
modelsUrl: addModelsSuffix(providerSpecificData?.baseUrl || ""),
isLocal,
});
}
return { valid: false, error: "Provider validation not supported", unsupported: true };
}
const modelId = entry.models?.[0]?.id || null;
// (#532) Use testKeyBaseUrl if defined — some providers validate keys on a different endpoint
// than where requests are sent (e.g. opencode-go validates on zen/v1, not zen/go/v1)
const validationEntry = entry.testKeyBaseUrl
? { ...entry, baseUrl: entry.testKeyBaseUrl }
: entry;
const usesAlibabaRegionalEndpoint = isAlibabaRegionalProvider(provider);
const baseUrl = usesAlibabaRegionalEndpoint
? resolveAlibabaProviderBaseUrl(provider, providerSpecificData, validationEntry.baseUrl)
: resolveBaseUrl(validationEntry, providerSpecificData);
try {
if (OPENAI_LIKE_FORMATS.has(entry.format)) {
return await validateOpenAILikeProvider({
apiKey,
baseUrl,
headers: entry.headers || {},
providerSpecificData,
modelId,
modelsUrl: usesAlibabaRegionalEndpoint ? "" : entry.testKeyModelsUrl || entry.modelsUrl,
isLocal,
});
}
if (entry.format === "claude") {
// Built-in CC-wire-image providers (e.g. agentrouter, #6056/#6255) gate
// their WAF on the dynamic Claude-Code fingerprint (User-Agent,
// `?beta=true` chat path, anthropic-beta/x-app/X-Stainless-* headers).
// The real chat-request path already routes through
// buildProviderUrl/buildProviderHeaders for this; the validation probe
// must use the SAME wire image or a genuinely valid key gets 403'd as
// "unauthorized client detected" (#6377).
if (usesCcWireImage(provider)) {
const requestBaseUrl = buildProviderUrl(provider, modelId, true, { baseUrl });
const requestHeaders = buildProviderHeaders(provider, { apiKey }, true);
return await validateAnthropicLikeProvider({
apiKey,
baseUrl: requestBaseUrl,
modelId,
headers: requestHeaders,
providerSpecificData,
isLocal,
});
}
const requestBaseUrl = `${baseUrl}${entry.urlSuffix || ""}`;
const requestHeaders = {
...(entry.headers || {}),
};
if ((entry.authHeader || "").toLowerCase() === "x-api-key") {
requestHeaders["x-api-key"] = apiKey;
} else {
requestHeaders["Authorization"] = `Bearer ${apiKey}`;
}
return await validateAnthropicLikeProvider({
apiKey,
baseUrl: requestBaseUrl,
modelId,
headers: requestHeaders,
providerSpecificData,
isLocal,
});
}
if (GEMINI_LIKE_FORMATS.has(entry.format)) {
return await validateGeminiLikeProvider({
apiKey,
baseUrl,
providerSpecificData,
authType: entry.authType,
isLocal,
});
}
if (entry.format === "antigravity") {
const expiresAt =
providerSpecificData?.tokenExpiresAt ||
providerSpecificData?.expiresAt ||
providerSpecificData?.expiry_date ||
providerSpecificData?.expiryDate;
const expiryMs =
typeof expiresAt === "number"
? expiresAt
: typeof expiresAt === "string" && expiresAt.trim()
? Date.parse(expiresAt)
: Number.NaN;
if (Number.isFinite(expiryMs) && expiryMs > 0 && expiryMs < Date.now()) {
return {
valid: false,
error: "Antigravity OAuth token has expired. Re-import or refresh the CLI login.",
unsupported: false,
};
}
return { valid: true, error: null, unsupported: false };
}
return { valid: false, error: "Provider validation not supported", unsupported: true };
} catch (error: any) {
return toValidationErrorResult(error);
}
}