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 { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch, } from "@/shared/network/safeOutboundFetch"; import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel"; import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; import { validateImageProviderApiKey } from "@/lib/providers/imageValidation"; import { OPENAI_LIKE_FORMATS, GEMINI_LIKE_FORMATS, normalizeBaseUrl, addModelsSuffix, resolveBaseUrl, } from "./validation/urlHelpers"; import { STANDARD_USER_AGENT, directHttpsRequest, buildBearerHeaders, } from "./validation/headers"; import { validationRead, validationWrite, toValidationErrorResult, } from "./validation/transport"; import { validateDeepSeekWebProvider, validateQwenWebProvider, validateGrokWebProvider, validateChatGptWebProvider, validatePerplexityWebProvider, validateBlackboxWebProvider, } from "./validation/webProvidersA"; import { validateMuseSparkWebProvider, validateAdaptaWebProvider, validateClaudeWebProvider, validateGeminiWebProvider, validateCopilotWebProvider, validateT3WebProvider, validateJulesProvider, validateInnerAiProvider, } from "./validation/webProvidersB"; import { validateHerokuProvider, validateDatabricksProvider, validateDataRobotProvider, validateSnowflakeProvider, validateGigachatProvider, validateAzureOpenAIProvider, validateAzureAiProvider, validateWatsonxProvider, validateOciProvider, validateSapProvider, } from "./validation/cloudProviders"; import { validateDeepgramProvider, validateAssemblyAIProvider, validateElevenLabsProvider, validateInworldProvider, validateKieProvider, validateAwsPollyProvider, validateBailianCodingPlanProvider, 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"; // 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"; /** * Validates web-cookie providers by performing a ping request to check if the session is still valid. * Returns SESSION_EXPIRED error code if the upstream returns 401/403. */ export async function validateWebCookieProvider({ provider, apiKey, providerSpecificData = {}, }: any) { try { const entry = getRegistryEntry(provider); if (!entry) { return { valid: false, error: "Provider not found in registry", unsupported: true }; } // For web-cookie providers, apiKey contains the cookie string const cookie = (apiKey || "").trim(); if (!cookie) { return { valid: false, error: "Cookie required for web-cookie provider", unsupported: false }; } // Attempt a minimal request to check if the session is valid // Use /models endpoint or a minimal completion request depending on the provider const baseUrl = entry.baseUrl || ""; const testUrl = `${baseUrl}/models`; const res = await directHttpsRequest( testUrl, { method: "GET", headers: { "User-Agent": STANDARD_USER_AGENT, }, }, 10_000 ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "SESSION_EXPIRED", errorCode: "AUTH_007", unsupported: false, }; } // Any other response (200, 404, 405, 429, ...) means the cookie was accepted — // a 401/403 from the /models probe is the only definitive "session expired" signal // for web-cookie auth, so a non-auth status is treated as a valid session. return { valid: true, error: null, unsupported: false }; } catch (error: any) { return toValidationErrorResult(error); } } 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); } } /** * Build Opengateway-style validators (xiaomi-mimo compatible). * These providers share a POST /chat/completions auth check pattern and differ * only in default baseUrl and test model name. */ function buildOpengatewayValidator(defaultBaseUrl: string, model: string) { return async ({ apiKey, providerSpecificData }: any) => { try { const baseUrl = normalizeBaseUrl(providerSpecificData?.baseUrl || defaultBaseUrl); const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`; const res = await validationWrite( chatUrl, { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ model, messages: [{ role: "user", content: "test" }], max_tokens: 1, }), }, isLocal ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; } // Any non-auth response (200, 400, 422, 429) means auth passed return { valid: true, error: null }; } catch (error: any) { return toValidationErrorResult(error); } }; } // Same as buildOpengatewayValidator but returns an object spreadable into SPECIALTY_VALIDATORS. // isLocal is captured via closure from the outer function scope. function buildGitlawbValidators( configs: [string, string, string][] ): Record> { return Object.fromEntries( configs.map(([id, baseUrl, model]) => [id, buildOpengatewayValidator(baseUrl, model)]) ); } // ── Specialty provider validation ── const SPECIALTY_VALIDATORS = { jules: validateJulesProvider, qoder: async ({ apiKey, providerSpecificData }: any) => { // Bifurcate validation: PAT tokens use Cosy auth against api1.qoder.sh; // regular API keys validate against dashscope (OpenAI-compatible endpoint). const key = (apiKey || "").trim(); if (key.startsWith("pt-")) { return validateQoderCliPat({ apiKey: key, providerSpecificData }); } // Non-PAT token → validate against dashscope (Alibaba Cloud). // The executor routes these tokens to dashscope.aliyuncs.com, so the // validation must test against dashscope, NOT the Cosy PAT endpoint. try { const dashscopeUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/models"; const res = await validationRead( dashscopeUrl, { headers: { Authorization: `Bearer ${key}`, }, }, false ); if (res.ok) return { valid: true, error: null }; if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid Qoder API key. Make sure you're using a valid API key from Qoder / Alibaba Cloud Dashscope.", }; } // 4xx/5xx other than auth — treat as valid bypass to prevent false // negatives from transient dashscope issues (consistent with PAT path). return { valid: true, error: null }; } catch (err: unknown) { return toValidationErrorResult(err); } }, "command-code": validateCommandCodeProvider, huggingface: validateHuggingFaceProvider, deepgram: validateDeepgramProvider, assemblyai: validateAssemblyAIProvider, "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, heroku: validateHerokuProvider, databricks: validateDatabricksProvider, datarobot: validateDataRobotProvider, watsonx: validateWatsonxProvider, oci: validateOciProvider, sap: validateSapProvider, bedrock: validateBedrockProvider, modal: ({ apiKey, providerSpecificData }: any) => validateOpenAILikeProvider({ provider: "modal", apiKey, providerSpecificData, baseUrl: normalizeBaseUrl(providerSpecificData?.baseUrl || ""), modelId: "Qwen/Qwen3-4B-Thinking-2507-FP8", 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, "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, "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: async ({ apiKey, providerSpecificData }: any) => { try { const configuredBaseUrl = typeof providerSpecificData?.baseUrl === "string" ? providerSpecificData.baseUrl.trim() : ""; const root = (configuredBaseUrl || "https://gitlab.com").replace(/\/$/, ""); const res = await validationWrite( `${root}/api/v4/code_suggestions/direct_access`, { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: "{}", }, isLocal ); if (res.status === 401) { return { valid: false, error: "Invalid API key" }; } return { valid: true, error: null }; } catch (error: any) { return toValidationErrorResult(error); } }, vertex: async ({ apiKey }: any) => { try { const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = await import("@omniroute/open-sse/executors/vertex.ts"); // Express-mode API keys are opaque strings sent directly as the ?key= query param — there is // no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it). if (isExpressApiKey(apiKey)) { return { valid: true, error: null }; } const sa = parseSAFromApiKey(apiKey); // Validates credentials by successfully successfully exchanging them for a JWT from Google Identity await getAccessToken(sa); return { valid: true, error: null }; } catch (error: any) { return { valid: false, error: "Invalid Service Account JSON: " + error.message }; } }, "vertex-partner": async ({ apiKey }: any) => { try { const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = await import("@omniroute/open-sse/executors/vertex.ts"); if (isExpressApiKey(apiKey)) { return { valid: true, error: null }; } const sa = parseSAFromApiKey(apiKey); await getAccessToken(sa); return { valid: true, error: null }; } catch (error: any) { return { valid: false, error: "Invalid Service Account JSON: " + error.message }; } }, // LongCat AI — does not expose /v1/models; validate via chat completions directly (#592) longcat: async ({ apiKey, providerSpecificData }: any) => { try { const res = await validationWrite( "https://api.longcat.chat/openai/v1/chat/completions", { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ model: "longcat", messages: [{ role: "user", content: "test" }], max_tokens: 1, }), }, isLocal ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; } // Any non-auth response (200, 400, 422) means auth passed return { valid: true, error: null }; } catch (error: any) { return toValidationErrorResult(error); } }, // NVIDIA NIM (#2463) — bypass the /models probe in favor of a direct // chat/completions probe. NVIDIA NIM's /models endpoint returns model // catalogs that vary by region and key-tier, and some keys 404 on it, // which the generic flow misreads. The chat probe is also a stronger // sanity check for streaming/key correctness. nvidia: async ({ apiKey, providerSpecificData }: any) => { try { const baseUrlRaw = providerSpecificData?.baseUrl || "https://integrate.api.nvidia.com/v1/chat/completions"; const normalized = normalizeBaseUrl(baseUrlRaw); const chatBase = normalized.replace(/\/models$/, ""); const chatUrl = normalized.endsWith("/chat/completions") ? normalized : `${chatBase}/chat/completions`; // #3116: probe a universally-available model rather than models[0] // (z-ai/glm-5.1), which requires the "Public API Endpoints" account permission // and can hang/be DEGRADED — making a *valid* key fail with "Upstream Error". const modelId = resolveNvidiaValidationModel(providerSpecificData); // #3226: use raw https (bypass the proxy/TLS-patched fetch) — the undici // dispatcher stalls against NVIDIA's endpoint, causing a 504 timeout. const res = await directHttpsRequest( chatUrl, { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ model: modelId, messages: [{ role: "user", content: "test" }], max_tokens: 1, }), }, 20000 ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; } // Any non-auth response (200, 400, 422, 429) means auth passed return { valid: true, error: null }; } catch (error: any) { return toValidationErrorResult(error); } }, // Z.AI (glm) — bypass the proxy/TLS-patched fetch for the same reason as nvidia // above (#3905): the undici dispatcher stalls against api.z.ai after the provider // returns 502 "job timed out" responses, because z.ai silently drops idle // keep-alive sockets without sending TCP RST. Using directHttpsRequest (native // Node.js HTTPS, no undici pool) avoids the zombie-socket hang on validation. // Z.AI uses the Anthropic wire format with x-api-key auth, not Bearer. zai: async ({ apiKey, providerSpecificData }: any) => { try { // providerSpecificData.baseUrl allows test overrides to point at a local // HTTP server; production always uses the fixed api.z.ai endpoint. const messagesUrl = providerSpecificData?.baseUrl ? `${normalizeBaseUrl(providerSpecificData.baseUrl).split("?")[0]}?beta=true` : "https://api.z.ai/api/anthropic/v1/messages?beta=true"; const res = await directHttpsRequest( messagesUrl, { method: "POST", headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json", }, body: JSON.stringify({ model: "glm-5.1", messages: [{ role: "user", content: "test" }], max_tokens: 1, }), }, 20000 ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; } if (res.status === 404 || res.status === 405) { return { valid: false, error: "Provider validation endpoint not supported" }; } if (res.status >= 500 && res.status !== 502) { return { valid: false, error: `Provider unavailable (${res.status})` }; } // Any non-auth response (200, 400, 422, 429, 502) means auth passed; // 502 "job timed out" is z.ai's own server-side queue limit, not an auth error. return { valid: true, error: null }; } catch (error: any) { return toValidationErrorResult(error); } }, // Xiaomi MiMo — Token Plan keys (tp-*) only work on regional endpoints // (e.g. token-plan-sgp, token-plan-ams), not api.xiaomimimo.com. // /v1/models works but validate via chat/completions for stronger auth check. "xiaomi-mimo": async ({ apiKey, providerSpecificData }: any) => { try { const baseUrl = normalizeBaseUrl( providerSpecificData?.baseUrl || "https://api.xiaomimimo.com/v1" ); const chatUrl = `${baseUrl.replace(/\/chat\/completions$/, "")}/chat/completions`; const res = await validationWrite( chatUrl, { method: "POST", headers: buildBearerHeaders(apiKey, providerSpecificData), body: JSON.stringify({ model: "mimo-v2.5-pro", messages: [{ role: "user", content: "test" }], max_tokens: 1, }), }, isLocal ); if (res.status === 401 || res.status === 403) { return { valid: false, error: "Invalid API key" }; } // Any non-auth response (200, 400, 422, 429) means auth passed return { valid: true, error: null }; } catch (error: any) { return toValidationErrorResult(error); } }, // 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"], ]), // 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 baseUrl = resolveBaseUrl(validationEntry, providerSpecificData); try { if (OPENAI_LIKE_FORMATS.has(entry.format)) { return await validateOpenAILikeProvider({ apiKey, baseUrl, headers: entry.headers || {}, providerSpecificData, modelId, modelsUrl: entry.modelsUrl, isLocal, }); } if (entry.format === "claude") { 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); } }