Spaces:
Runtime error
Runtime error
| // OpenAI/Gemini-format + Bedrock provider key validators (bedrock, openai-like, command-code, gemini-like, openai-compatible). | |
| // Extracted from validation.ts (god-file decomposition) — top-level functions; behavior is | |
| // byte-identical to the original inline defs. | |
| import { randomUUID } from "node:crypto"; | |
| import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; | |
| import { | |
| discoverBedrockNativeModels, | |
| isBedrockNativeApiError, | |
| isBedrockNativeAuthError, | |
| } from "@omniroute/open-sse/services/bedrock.ts"; | |
| import { | |
| addModelsSuffix, | |
| normalizeBaseUrl, | |
| resolveChatUrl, | |
| } from "./urlHelpers"; | |
| import { | |
| applyCustomUserAgent, | |
| buildBearerHeaders, | |
| } from "./headers"; | |
| import { | |
| toValidationErrorResult, | |
| validationRead, | |
| validationWrite, | |
| } from "./transport"; | |
| import { validateDirectChatProvider } from "./directChatProbe"; | |
| export async function validateBedrockProvider({ apiKey, providerSpecificData = {} }: any) { | |
| if (!apiKey) { | |
| return { valid: false, error: "Provider and API key required" }; | |
| } | |
| try { | |
| const discovery = await discoverBedrockNativeModels({ | |
| apiKey, | |
| providerSpecificData, | |
| fetcher: (url, init) => validationRead(url, init), | |
| }); | |
| return { | |
| valid: true, | |
| error: null, | |
| method: "bedrock_native_models", | |
| warning: discovery.warnings[0] || null, | |
| }; | |
| } catch (error: any) { | |
| if (isBedrockNativeAuthError(error)) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| if (isBedrockNativeApiError(error)) { | |
| if (error.status === 429) { | |
| return { | |
| valid: true, | |
| error: null, | |
| warning: "Bedrock accepted the key but model discovery is rate limited", | |
| method: "bedrock_native_models", | |
| }; | |
| } | |
| if (typeof error.status === "number" && error.status >= 500) { | |
| return { valid: false, error: `Provider unavailable (${error.status})` }; | |
| } | |
| if (typeof error.status === "number") { | |
| return { valid: false, error: `Bedrock validation failed: ${error.status}` }; | |
| } | |
| } | |
| return toValidationErrorResult(error); | |
| } | |
| } | |
| export async function validateOpenAILikeProvider({ | |
| provider = "openai", | |
| apiKey, | |
| baseUrl, | |
| headers = {}, | |
| modelId = "gpt-3.5-turbo", | |
| providerSpecificData, | |
| modelsUrl = "", | |
| isLocal = false, | |
| }: any) { | |
| try { | |
| // Guard against a non-string modelsUrl reaching .trim()/.startsWith() — a malformed | |
| // providerSpecificData / registry value would otherwise throw a TypeError mid-validation | |
| // ("trim is not a function" / "startsWith is not a function"). See #2463 class. | |
| const customModelsUrl = (typeof modelsUrl === "string" ? modelsUrl.trim() : "") || ""; | |
| const endpointUrl = customModelsUrl | |
| ? customModelsUrl.startsWith("http") | |
| ? customModelsUrl | |
| : `${baseUrl.replace(/\/+$/, "")}/${customModelsUrl.replace(/^\/+/, "")}` | |
| : // addModelsSuffix strips a trailing /chat/completions before appending /models, | |
| // so an OpenAI-style baseUrl validates against /v1/models, not /v1/chat/completions/models. | |
| addModelsSuffix(baseUrl); | |
| const requestUrl = | |
| typeof providerSpecificData?.modelsUrl === "string" && | |
| providerSpecificData.modelsUrl.trim() !== "" | |
| ? providerSpecificData.modelsUrl.trim() | |
| : endpointUrl; | |
| const response = await validationRead( | |
| requestUrl, | |
| { | |
| headers: { | |
| ...headers, | |
| ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), | |
| }, | |
| }, | |
| isLocal | |
| ); | |
| if (response.ok) { | |
| return { valid: true, error: null }; | |
| } | |
| if (response.status === 401) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| // #2929: A 403 on the models endpoint is not always a bad key. Some providers | |
| // (e.g. Fireworks Fire Pass `fpk_*` keys) return "...not authorized for this | |
| // route." on /models while still serving chat. Fall through to the chat probe | |
| // for such route-restriction 403s instead of declaring the key invalid. | |
| if (response.status === 403) { | |
| const forbiddenBody = await response.text().catch(() => ""); | |
| if (!/not authorized for this route/i.test(forbiddenBody)) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| } | |
| const chatUrl = resolveChatUrl(provider, baseUrl, providerSpecificData); | |
| if (!chatUrl) { | |
| return { valid: false, error: `Validation failed: ${response.status}` }; | |
| } | |
| const testModelId = (providerSpecificData as any)?.validationModelId || modelId; | |
| const testBody = { | |
| model: testModelId, | |
| messages: [{ role: "user", content: "test" }], | |
| max_tokens: 1, | |
| }; | |
| const chatRes = await validationWrite( | |
| chatUrl, | |
| { | |
| method: "POST", | |
| headers: { | |
| ...headers, | |
| ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), | |
| }, | |
| body: JSON.stringify(testBody), | |
| }, | |
| isLocal | |
| ); | |
| if (chatRes.ok) { | |
| return { valid: true, error: null }; | |
| } | |
| if (chatRes.status === 401 || chatRes.status === 403) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| if (chatRes.status === 404 || chatRes.status === 405) { | |
| return { valid: false, error: "Provider validation endpoint not supported" }; | |
| } | |
| if (chatRes.status >= 500) { | |
| return { valid: false, error: `Provider unavailable (${chatRes.status})` }; | |
| } | |
| return { valid: true, error: null }; | |
| } catch (error: any) { | |
| return toValidationErrorResult(error); | |
| } | |
| } | |
| export async function validateCommandCodeProvider({ apiKey, providerSpecificData = {} }: any) { | |
| const entry = getRegistryEntry("command-code"); | |
| const baseUrl = normalizeBaseUrl(entry?.baseUrl || "https://api.commandcode.ai"); | |
| const chatPath = entry?.chatPath || "/alpha/generate"; | |
| const url = `${baseUrl}${chatPath.startsWith("/") ? chatPath : `/${chatPath}`}`; | |
| const validationModelId = | |
| providerSpecificData?.validationModelId || | |
| entry?.models?.find((model) => model.id === "deepseek/deepseek-v4-flash")?.id || | |
| "deepseek/deepseek-v4-flash"; | |
| const { COMMAND_CODE_VERSION } = await import("@omniroute/open-sse/executors/commandCode.ts"); | |
| return validateDirectChatProvider({ | |
| url, | |
| providerSpecificData, | |
| headers: { | |
| "Content-Type": "application/json", | |
| Authorization: `Bearer ${apiKey}`, | |
| "x-command-code-version": COMMAND_CODE_VERSION, | |
| "x-cli-environment": "external", | |
| "x-project-slug": "pi-cc", | |
| "x-taste-learning": "false", | |
| "x-co-flag": "false", | |
| "x-session-id": randomUUID(), | |
| }, | |
| body: { | |
| config: { | |
| workingDir: "/workspace", | |
| date: new Date().toISOString().slice(0, 10), | |
| environment: "external", | |
| structure: [], | |
| isGitRepo: false, | |
| currentBranch: "", | |
| mainBranch: "", | |
| gitStatus: "", | |
| recentCommits: [], | |
| }, | |
| memory: "", | |
| taste: "", | |
| skills: "", | |
| permissionMode: "standard", | |
| params: { | |
| model: validationModelId, | |
| messages: [{ role: "user", content: "test" }], | |
| tools: [], | |
| system: "", | |
| max_tokens: 1, | |
| stream: true, | |
| }, | |
| }, | |
| }); | |
| } | |
| // HuggingFace fine-grained Inference-Provider tokens are valid even when | |
| // model/task endpoints reject them, so the generic OpenAI-like probe against | |
| // router.huggingface.co/v1/models falsely marks them invalid. Validate the | |
| // token strictly as an auth check via the whoami-v2 endpoint instead: only | |
| // 401/403 means the token is invalid; any other non-OK status is a transient | |
| // upstream failure, NOT an invalid key. | |
| export async function validateHuggingFaceProvider({ apiKey }: any) { | |
| try { | |
| const response = await validationRead("https://huggingface.co/api/whoami-v2", { | |
| method: "GET", | |
| headers: { Authorization: `Bearer ${apiKey}` }, | |
| }); | |
| if (response.ok) { | |
| return { valid: true, error: null, method: "huggingface_whoami" }; | |
| } | |
| if (response.status === 401 || response.status === 403) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| // Non-auth, non-OK status — surface as a transient upstream failure rather | |
| // than declaring the (potentially valid) fine-grained token invalid. | |
| return { valid: false, error: `HuggingFace token check returned ${response.status}` }; | |
| } catch (error: unknown) { | |
| return toValidationErrorResult(error); | |
| } | |
| } | |
| export async function validateGeminiLikeProvider({ | |
| apiKey, | |
| baseUrl, | |
| providerSpecificData = {}, | |
| authType = "query", | |
| isLocal = false, | |
| }: any) { | |
| try { | |
| if (!baseUrl) { | |
| return { valid: false, error: "Missing base URL" }; | |
| } | |
| const normalizedAuthType = String(authType || "query").toLowerCase(); | |
| // Strip a trailing /models before appending — the default Gemini registry baseUrl is | |
| // `.../v1beta/models` (for the chat urlBuilder), so naively appending /models produced | |
| // `.../v1beta/models/models` → upstream 404 on connection validation (#2545). | |
| const baseForModels = String(baseUrl) | |
| .replace(/\/models\/?$/, "") | |
| .replace(/\/$/, ""); | |
| const requestUrl = | |
| typeof providerSpecificData?.modelsUrl === "string" && | |
| providerSpecificData.modelsUrl.trim() !== "" | |
| ? providerSpecificData.modelsUrl.trim() | |
| : `${baseForModels}/models`; | |
| // Use the correct auth header based on provider config: | |
| // - gemini / gemini-cli (API key): x-goog-api-key | |
| // - gemini-cli (OAuth): Bearer token | |
| const headers: Record<string, string> = {}; | |
| let urlWithKey = requestUrl; | |
| if (typeof apiKey === "string" && apiKey.startsWith("ya29.")) { | |
| // A Google OAuth access token (ya29.*) must use Bearer auth even when the | |
| // connection is configured as an API-key provider — gemini-cli OAuth stores the | |
| // access token in the apiKey field. Checked first so authType "apikey"/"header" | |
| // doesn't shadow it with x-goog-api-key. | |
| headers["Authorization"] = `Bearer ${apiKey}`; | |
| } else if (normalizedAuthType === "header" || normalizedAuthType === "apikey") { | |
| headers["x-goog-api-key"] = apiKey; | |
| } else if (normalizedAuthType === "oauth" || normalizedAuthType === "bearer") { | |
| headers["Authorization"] = `Bearer ${apiKey}`; | |
| } else if (normalizedAuthType === "query") { | |
| urlWithKey = `${requestUrl}?key=${encodeURIComponent(apiKey)}`; | |
| } | |
| applyCustomUserAgent(headers, providerSpecificData); | |
| const response = await validationRead( | |
| urlWithKey, | |
| { | |
| headers, | |
| }, | |
| isLocal | |
| ); | |
| if (response.ok) { | |
| return { valid: true, error: null }; | |
| } | |
| if (response.status === 429) { | |
| return { valid: true, error: null }; | |
| } | |
| if (response.status === 400 || response.status === 401 || response.status === 403) { | |
| const isAuthError = (body: any) => { | |
| const message = (body?.error?.message || "").toLowerCase(); | |
| const reason = body?.error?.details?.[0]?.reason || ""; | |
| const status = body?.error?.status || ""; | |
| const authPatterns = [ | |
| "api key not valid", | |
| "api key expired", | |
| "api key invalid", | |
| "API_KEY_INVALID", | |
| "API_KEY_EXPIRED", | |
| "PERMISSION_DENIED", | |
| "UNAUTHENTICATED", | |
| ]; | |
| return authPatterns.some( | |
| (p) => message.includes(p.toLowerCase()) || reason === p || status === p | |
| ); | |
| }; | |
| try { | |
| const body = await response.json(); | |
| if (isAuthError(body)) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| if (response.status === 401 || response.status === 403) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| } catch { | |
| if (response.status === 401 || response.status === 403) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| } | |
| return { valid: false, error: `Validation failed: ${response.status}` }; | |
| } catch (error: any) { | |
| return toValidationErrorResult(error); | |
| } | |
| } | |
| // ── Specialty providers (non-standard APIs) ── | |
| export async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }: any) { | |
| const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl); | |
| if (!baseUrl) { | |
| return { valid: false, error: "No base URL configured for OpenAI compatible provider" }; | |
| } | |
| const validationModelId = | |
| typeof providerSpecificData?.validationModelId === "string" | |
| ? providerSpecificData.validationModelId.trim() | |
| : ""; | |
| // Step 1: Try GET /models | |
| let modelsReachable = false; | |
| try { | |
| const modelsRes = await validationRead(`${baseUrl}/models`, { | |
| method: "GET", | |
| headers: buildBearerHeaders(apiKey, providerSpecificData), | |
| }); | |
| modelsReachable = true; | |
| if (modelsRes.ok) { | |
| return { valid: true, error: null, method: "models_endpoint" }; | |
| } | |
| if (modelsRes.status === 401 || modelsRes.status === 403) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| // Endpoint responded and auth seems valid, but quota is exhausted/rate-limited. | |
| if (modelsRes.status === 429) { | |
| return { | |
| valid: true, | |
| error: null, | |
| method: "models_endpoint", | |
| warning: "Rate limited, but credentials are valid", | |
| }; | |
| } | |
| } catch { | |
| // /models fetch failed (network error, etc.) — fall through to chat test | |
| } | |
| // T25: if /models cannot be used and no custom model was provided, return a | |
| // clear actionable message instead of a generic connection error. | |
| if (!validationModelId) { | |
| return { | |
| valid: false, | |
| error: "Endpoint /models unavailable. Provide a Model ID to validate via /chat/completions.", | |
| }; | |
| } | |
| // Step 2: Fallback — try a minimal chat completion request | |
| // Many providers don't expose /models but accept chat completions fine | |
| const apiType = providerSpecificData.apiType || "chat"; | |
| const chatSuffix = apiType === "responses" ? "/responses" : "/chat/completions"; | |
| const chatUrl = `${baseUrl}${chatSuffix}`; | |
| const testModelId = validationModelId; | |
| try { | |
| const chatRes = await validationWrite(chatUrl, { | |
| method: "POST", | |
| headers: buildBearerHeaders(apiKey, providerSpecificData), | |
| body: JSON.stringify({ | |
| model: testModelId, | |
| messages: [{ role: "user", content: "test" }], | |
| max_tokens: 1, | |
| }), | |
| }); | |
| if (chatRes.ok) { | |
| return { valid: true, error: null, method: "chat_completions" }; | |
| } | |
| if (chatRes.status === 401 || chatRes.status === 403) { | |
| return { valid: false, error: "Invalid API key" }; | |
| } | |
| if (chatRes.status === 429) { | |
| return { | |
| valid: true, | |
| error: null, | |
| method: "chat_completions", | |
| warning: "Rate limited, but credentials are valid", | |
| }; | |
| } | |
| // If /models was reachable but returned non-auth error, and chat succeeds | |
| // auth-wise, this still confirms credentials are valid. | |
| if (chatRes.status === 400) { | |
| return { | |
| valid: true, | |
| error: null, | |
| method: "inference_available", | |
| warning: "Model ID may be invalid, but credentials are valid", | |
| }; | |
| } | |
| // 4xx other than auth (e.g. 400 bad model, 422) usually means auth passed | |
| if (chatRes.status >= 400 && chatRes.status < 500) { | |
| return { | |
| valid: true, | |
| error: null, | |
| method: "inference_available", | |
| }; | |
| } | |
| if (chatRes.status >= 500) { | |
| return { valid: false, error: `Provider unavailable (${chatRes.status})` }; | |
| } | |
| } catch { | |
| // Chat test also failed — fall through to simple connectivity check | |
| } | |
| // Step 3: Final fallback — simple connectivity check | |
| // For local providers (Ollama, LM Studio, etc.) that may not respond to | |
| // standard OpenAI endpoints but are still reachable | |
| if (!modelsReachable) { | |
| return { valid: false, error: "Connection failed while testing /chat/completions" }; | |
| } | |
| try { | |
| const pingRes = await validationRead(baseUrl, { | |
| method: "GET", | |
| headers: buildBearerHeaders(apiKey, providerSpecificData), | |
| }); | |
| // If the server responds at all (even with an error page), it's reachable | |
| if (pingRes.status < 500) { | |
| return { valid: true, error: null }; | |
| } | |
| return { valid: false, error: `Provider unavailable (${pingRes.status})` }; | |
| } catch (error: any) { | |
| return toValidationErrorResult(error); | |
| } | |
| } | |