diff --git a/changelog.d/maintenance/decomp-provider-validation.md b/changelog.d/maintenance/decomp-provider-validation.md new file mode 100644 index 0000000000..0514896b83 --- /dev/null +++ b/changelog.d/maintenance/decomp-provider-validation.md @@ -0,0 +1 @@ +- chore(validation): decompose `src/lib/providers/validation.ts` (→ 442 lines) by extracting the web-cookie, kiro and specialty inline validators into `validation/*` leaves — behavior-preserving move; the specialty validators that captured `isLocal` from the enclosing closure now take it as an explicit parameter, with the host dispatcher passing it at each call site diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index f1d645b4ba..e9b2cef768 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -10,13 +10,8 @@ import { providerAllowsOptionalApiKey, WEB_COOKIE_PROVIDERS, } from "@/shared/constants/providers"; -import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; -import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy"; -import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel"; import { MODAL_DEFAULT_VALIDATION_MODEL_ID } from "@/shared/constants/modal"; -import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; import { validateImageProviderApiKey } from "@/lib/providers/imageValidation"; -import { KiroService } from "@/lib/oauth/services/kiro"; import { usesCcWireImage } from "@omniroute/open-sse/services/ccWireImageBuiltins.ts"; import { isAlibabaRegionalProvider, @@ -31,14 +26,7 @@ import { addModelsSuffix, resolveBaseUrl, } from "./validation/urlHelpers"; -import { STANDARD_USER_AGENT, directHttpsRequest, buildBearerHeaders } from "./validation/headers"; -import { - validationRead, - validationWrite, - toValidationErrorResult, - toWebCookieValidationErrorResult, - WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API, -} from "./validation/transport"; +import { toValidationErrorResult } from "./validation/transport"; import { validateDeepSeekWebProvider, validateQwenWebProvider, @@ -109,6 +97,25 @@ import { 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 }; @@ -117,212 +124,13 @@ export { validateCommandCodeProvider, validateClaudeCodeCompatibleProvider }; // 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: _providerSpecificData = {}, -}: { - provider: string; - apiKey?: string; - providerSpecificData?: Record; -}) { - try { - const entry = getRegistryEntry(provider); - const cookieProvider = WEB_COOKIE_PROVIDERS[provider as keyof typeof WEB_COOKIE_PROVIDERS]; - if (!entry && !cookieProvider) { - return { valid: false, error: "Provider not found in registry", unsupported: true }; - } +// validateWebCookieProvider + bytezValidationResultFromStatus have external importers (tests + +// the web-cookie fallback suites) — re-export to preserve the historical public surface. +export { validateWebCookieProvider, bytezValidationResultFromStatus }; - // 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 }; - } - - if (!entry) { - // Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g. - // gemini-business, poe-web, venice-web, v0-vercel-web) only expose a - // marketing website URL, not a real API host. Probing `${website}/models` - // does not reliably signal session validity for these — - // live verification showed most return redirects or SPA 200s regardless of - // cookie validity, which would silently report an expired/garbage cookie as - // "OK" (worse than an honest "not supported"). Until each of these providers - // has a verified, side-effect-free auth probe against its real API host, report - // unsupported instead of a false positive. - return { - valid: false, - error: "Provider validation not supported", - unsupported: true, - }; - } - - // 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 = normalizeBaseUrl(entry.baseUrl || ""); - - // Defense-in-depth: only an http(s) baseUrl without a query string is safe to - // probe by blindly appending `/models`. A ws(s):// baseUrl (e.g. copilot-web) is - // already rejected by the outbound URL guard downstream, but reject it explicitly - // here for the honest "unsupported" result instead of a confusing security-block - // message — this also covers a future http(s) baseUrl carrying a query string, - // which the guard does not currently block (#7857 acceptance criteria). - if (!/^https?:\/\//i.test(baseUrl) || baseUrl.includes("?")) { - return { - valid: false, - error: "Provider validation not supported", - unsupported: true, - }; - } - - const testUrl = `${baseUrl}/models`; - - const res = await validationRead( - testUrl, - { - method: "GET", - headers: { - "User-Agent": STANDARD_USER_AGENT, - Cookie: cookie, - }, - }, - isLocalProvider(provider) - ); - - if (res.status === 401 || res.status === 403) { - return { - valid: false, - error: "SESSION_EXPIRED", - errorCode: "AUTH_007", - unsupported: false, - }; - } - - // #7857: for providers whose baseUrl is a conversation/completion endpoint rather - // than a real API root, the /models path never existed upstream — a redirect, - // login-HTML 200, 404, 405, or 429 from it is not a meaningful auth signal and is - // indistinguishable from a genuinely valid session. Report the same honest - // "unsupported" result the !entry branch above already gives its no-registry - // siblings, instead of a false `valid: true`. - if (WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API.has(provider)) { - return { - valid: false, - error: "Provider validation not supported", - unsupported: true, - }; - } - - // 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: unknown) { - return toWebCookieValidationErrorResult(provider, error); - } -} - -// #5422: Bytez key validation cannot use a chat probe. A Bytez account only serves models -// that have been added to its catalog, so even Bytez's own documented model ids return 404 -// ("Model does not exist or has yet to be added to the Bytez catalog") for a fresh/free key — -// the generic OpenAI-like chat probe misreads that 404 as "endpoint not supported". Validate -// against the model-independent, auth-only tasks endpoint instead (verified live): -// GET …/models/v2/list/tasks → 200 (valid key) | 401 { error: "Unauthorized" } (invalid). -// The pure status→result mapping is factored out so it is unit-testable without network. -export function bytezValidationResultFromStatus(status: number): { - valid: boolean; - error: string | null; -} { - if (status === 200) { - return { valid: true, error: null }; - } - if (status === 401 || status === 403) { - return { valid: false, error: "Invalid API key" }; - } - return { valid: false, error: `Validation failed: ${status}` }; -} - -export async function validateBytezProvider({ apiKey, providerSpecificData = {} }: any) { - try { - const res = await validationRead("https://api.bytez.com/models/v2/list/tasks", { - method: "GET", - headers: buildBearerHeaders(apiKey, providerSpecificData), - }); - return bytezValidationResultFromStatus(res.status); - } catch (error: unknown) { - return toValidationErrorResult(error); - } -} - -async function validateKiroApiKeyRuntimeProbe({ - apiKey, - region, - profileArn, -}: { - apiKey: string; - region: string; - profileArn?: string | null; -}) { - const endpoint = - region === "us-east-1" - ? "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" - : `https://q.${region}.amazonaws.com/generateAssistantResponse`; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - try { - const body = { - ...(profileArn ? { profileArn } : {}), - conversationState: { - chatTriggerType: "MANUAL", - conversationId: crypto.randomUUID(), - currentMessage: { - userInputMessage: { - content: "ping", - modelId: "auto", - origin: "AI_EDITOR", - }, - }, - history: [], - }, - inferenceConfig: { - maxTokens: 1, - }, - }; - - const res = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - tokentype: "API_KEY", - "Content-Type": "application/x-amz-json-1.0", - "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", - Accept: "application/vnd.amazon.eventstream", - "Amz-Sdk-Request": "attempt=1; max=3", - "Amz-Sdk-Invocation-Id": crypto.randomUUID(), - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - - await res.body?.cancel().catch(() => undefined); - - if (res.ok) { - return { valid: true, error: null, method: "kiro_generate_assistant_response" }; - } - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid Kiro API key or AWS region" }; - } - if (res.status === 400 || res.status === 422 || res.status === 429) { - return { valid: true, error: null, method: `kiro_generate_assistant_response_${res.status}` }; - } - return { valid: false, error: `Kiro validation failed: ${res.status}` }; - } finally { - clearTimeout(timeout); - } -} +// 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); @@ -355,161 +163,23 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi } } - /** - * 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)]) - ); - } + // 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": async ({ apiKey, providerSpecificData }: any) => { - try { - const configuredBaseUrl = - typeof providerSpecificData?.baseUrl === "string" && providerSpecificData.baseUrl.trim() - ? providerSpecificData.baseUrl.trim() - : "https://api.v0.dev"; - - const root = normalizeBaseUrl(configuredBaseUrl) - .replace(/\/v1\/chat\/completions$/, "") - .replace(/\/v1$/, ""); - - const res = await validationRead( - `${root}/v1/chats?limit=1`, - { - method: "GET", - headers: buildBearerHeaders(apiKey, providerSpecificData), - }, - isLocal - ); - - if (res.ok) { - return { valid: true, error: null, method: "v0_platform_chats_list" }; - } - - if (res.status === 401 || res.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - - return { valid: false, error: `v0 validation failed: ${res.status}` }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, + "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 is a fully local, credential-less CLI passthrough — there is no API - // key to check upstream. The only meaningful validation is confirming the - // `auggie` binary is installed and runnable on this machine. - auggie: async () => { - const { checkAuggieCliVersion } = await import("@omniroute/open-sse/executors/auggie.ts"); - const result = await checkAuggieCliVersion(); - if (!result.ok) { - return { - valid: false, - error: result.error || "Auggie CLI not found. Install it and run `auggie login`.", - unsupported: false, - }; - } - return { valid: true, error: null, unsupported: false, method: result.version }; - }, - 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); - } - }, - kiro: async ({ apiKey, providerSpecificData }: any) => { - try { - const region = providerSpecificData?.region || "us-east-1"; - const credential = await new KiroService().validateApiKey(apiKey, region); - if (!credential.profileArn) { - return await validateKiroApiKeyRuntimeProbe({ - apiKey: credential.accessToken, - region: credential.region, - profileArn: providerSpecificData?.profileArn, - }); - } - return { - valid: true, - error: null, - method: "kiro_list_available_profiles", - }; - } catch (error: any) { - return toValidationErrorResult(error); - } - }, + 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 @@ -595,212 +265,26 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi 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-2.0", - 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); - } - }, + 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"], - ]), + ...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]) => [ diff --git a/src/lib/providers/validation/kiro.ts b/src/lib/providers/validation/kiro.ts new file mode 100644 index 0000000000..c248052575 --- /dev/null +++ b/src/lib/providers/validation/kiro.ts @@ -0,0 +1,71 @@ +// Kiro runtime probe — falls back to a generateAssistantResponse call when an API key +// cannot list profiles. Extracted from validation.ts (god-file decomposition) — top-level +// function with no dispatcher-state captures; behavior is byte-identical to the original +// inline def. +export async function validateKiroApiKeyRuntimeProbe({ + apiKey, + region, + profileArn, +}: { + apiKey: string; + region: string; + profileArn?: string | null; +}) { + const endpoint = + region === "us-east-1" + ? "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + : `https://q.${region}.amazonaws.com/generateAssistantResponse`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const body = { + ...(profileArn ? { profileArn } : {}), + conversationState: { + chatTriggerType: "MANUAL", + conversationId: crypto.randomUUID(), + currentMessage: { + userInputMessage: { + content: "ping", + modelId: "auto", + origin: "AI_EDITOR", + }, + }, + history: [], + }, + inferenceConfig: { + maxTokens: 1, + }, + }; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + tokentype: "API_KEY", + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", + Accept: "application/vnd.amazon.eventstream", + "Amz-Sdk-Request": "attempt=1; max=3", + "Amz-Sdk-Invocation-Id": crypto.randomUUID(), + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + await res.body?.cancel().catch(() => undefined); + + if (res.ok) { + return { valid: true, error: null, method: "kiro_generate_assistant_response" }; + } + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid Kiro API key or AWS region" }; + } + if (res.status === 400 || res.status === 422 || res.status === 429) { + return { valid: true, error: null, method: `kiro_generate_assistant_response_${res.status}` }; + } + return { valid: false, error: `Kiro validation failed: ${res.status}` }; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/lib/providers/validation/specialtyInline.ts b/src/lib/providers/validation/specialtyInline.ts new file mode 100644 index 0000000000..191902d290 --- /dev/null +++ b/src/lib/providers/validation/specialtyInline.ts @@ -0,0 +1,378 @@ +// Inline specialty provider validators that previously lived as closures inside the +// SPECIALTY_VALIDATORS map in validation.ts. Extracted (god-file decomposition) as top-level +// functions taking `isLocal` where the original closure captured it; behavior is byte-identical +// to the original inline defs. The host dispatcher still owns the SPECIALTY_VALIDATORS map +// construction + dispatch — these are just the leaf validator bodies. +import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel"; +import { normalizeBaseUrl } from "./urlHelpers"; +import { buildBearerHeaders, directHttpsRequest } from "./headers"; +import { toValidationErrorResult, validationRead, validationWrite } from "./transport"; +import { validateKiroApiKeyRuntimeProbe } from "./kiro"; + +export async function validateV0VercelProvider({ apiKey, providerSpecificData, isLocal }: any) { + try { + const configuredBaseUrl = + typeof providerSpecificData?.baseUrl === "string" && providerSpecificData.baseUrl.trim() + ? providerSpecificData.baseUrl.trim() + : "https://api.v0.dev"; + + const root = normalizeBaseUrl(configuredBaseUrl) + .replace(/\/v1\/chat\/completions$/, "") + .replace(/\/v1$/, ""); + + const res = await validationRead( + `${root}/v1/chats?limit=1`, + { + method: "GET", + headers: buildBearerHeaders(apiKey, providerSpecificData), + }, + isLocal + ); + + if (res.ok) { + return { valid: true, error: null, method: "v0_platform_chats_list" }; + } + + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid API key" }; + } + + return { valid: false, error: `v0 validation failed: ${res.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +// auggie is a fully local, credential-less CLI passthrough — there is no API +// key to check upstream. The only meaningful validation is confirming the +// `auggie` binary is installed and runnable on this machine. +export async function validateAuggieProvider() { + const { checkAuggieCliVersion } = await import("@omniroute/open-sse/executors/auggie.ts"); + const result = await checkAuggieCliVersion(); + if (!result.ok) { + return { + valid: false, + error: result.error || "Auggie CLI not found. Install it and run `auggie login`.", + unsupported: false, + }; + } + return { valid: true, error: null, unsupported: false, method: result.version }; +} + +export async function validateQoderProvider({ 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); + } +} + +export async function validateKiroProvider({ apiKey, providerSpecificData }: any) { + try { + const region = providerSpecificData?.region || "us-east-1"; + const credential = await new KiroService().validateApiKey(apiKey, region); + if (!credential.profileArn) { + return await validateKiroApiKeyRuntimeProbe({ + apiKey: credential.accessToken, + region: credential.region, + profileArn: providerSpecificData?.profileArn, + }); + } + return { + valid: true, + error: null, + method: "kiro_list_available_profiles", + }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +export async function validateGitlabProvider({ apiKey, providerSpecificData, isLocal }: 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); + } +} + +export async function validateVertexProvider({ 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 }; + } +} + +export async function validateVertexPartnerProvider({ 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) +export async function validateLongcatProvider({ apiKey, providerSpecificData, isLocal }: 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-2.0", + 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. +export async function validateNvidiaProvider({ 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. +export async function validateZaiProvider({ 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. +export async function validateXiaomiMimoProvider({ apiKey, providerSpecificData, isLocal }: 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); + } +} + +/** + * 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. + */ +export function buildOpengatewayValidator(defaultBaseUrl: string, model: string) { + return async ({ apiKey, providerSpecificData, isLocal }: 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. +export function buildGitlawbValidators( + configs: [string, string, string][], + isLocal: boolean +): Record> { + return Object.fromEntries( + configs.map(([id, baseUrl, model]) => [ + id, + (({ apiKey, providerSpecificData }: any) => + buildOpengatewayValidator( + baseUrl, + model + )({ apiKey, providerSpecificData, isLocal })) as ReturnType< + typeof buildOpengatewayValidator + >, + ]) + ); +} diff --git a/src/lib/providers/validation/webCookie.ts b/src/lib/providers/validation/webCookie.ts new file mode 100644 index 0000000000..3e71cece93 --- /dev/null +++ b/src/lib/providers/validation/webCookie.ts @@ -0,0 +1,152 @@ +// Web-cookie session-ping validator + Bytez auth-only probe. Extracted from validation.ts +// (god-file decomposition) — top-level functions with no dispatcher-state captures; behavior is +// byte-identical to the original inline defs. +import { WEB_COOKIE_PROVIDERS, isLocalProvider } from "@/shared/constants/providers"; +import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { normalizeBaseUrl } from "./urlHelpers"; +import { STANDARD_USER_AGENT, buildBearerHeaders } from "./headers"; +import { + validationRead, + toValidationErrorResult, + toWebCookieValidationErrorResult, + WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API, +} from "./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: _providerSpecificData = {}, +}: { + provider: string; + apiKey?: string; + providerSpecificData?: Record; +}) { + try { + const entry = getRegistryEntry(provider); + const cookieProvider = WEB_COOKIE_PROVIDERS[provider as keyof typeof WEB_COOKIE_PROVIDERS]; + if (!entry && !cookieProvider) { + 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 }; + } + + if (!entry) { + // Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g. + // gemini-business, poe-web, venice-web, v0-vercel-web) only expose a + // marketing website URL, not a real API host. Probing `${website}/models` + // does not reliably signal session validity for these — + // live verification showed most return redirects or SPA 200s regardless of + // cookie validity, which would silently report an expired/garbage cookie as + // "OK" (worse than an honest "not supported"). Until each of these providers + // has a verified, side-effect-free auth probe against its real API host, report + // unsupported instead of a false positive. + return { + valid: false, + error: "Provider validation not supported", + unsupported: true, + }; + } + + // 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 = normalizeBaseUrl(entry.baseUrl || ""); + + // Defense-in-depth: only an http(s) baseUrl without a query string is safe to + // probe by blindly appending `/models`. A ws(s):// baseUrl (e.g. copilot-web) is + // already rejected by the outbound URL guard downstream, but reject it explicitly + // here for the honest "unsupported" result instead of a confusing security-block + // message — this also covers a future http(s) baseUrl carrying a query string, + // which the guard does not currently block (#7857 acceptance criteria). + if (!/^https?:\/\//i.test(baseUrl) || baseUrl.includes("?")) { + return { + valid: false, + error: "Provider validation not supported", + unsupported: true, + }; + } + + const testUrl = `${baseUrl}/models`; + + const res = await validationRead( + testUrl, + { + method: "GET", + headers: { + "User-Agent": STANDARD_USER_AGENT, + Cookie: cookie, + }, + }, + isLocalProvider(provider) + ); + + if (res.status === 401 || res.status === 403) { + return { + valid: false, + error: "SESSION_EXPIRED", + errorCode: "AUTH_007", + unsupported: false, + }; + } + + // #7857: for providers whose baseUrl is a conversation/completion endpoint rather + // than a real API root, the /models path never existed upstream — a redirect, + // login-HTML 200, 404, 405, or 429 from it is not a meaningful auth signal and is + // indistinguishable from a genuinely valid session. Report the same honest + // "unsupported" result the !entry branch above already gives its no-registry + // siblings, instead of a false `valid: true`. + if (WEB_COOKIE_PROVIDERS_WITHOUT_MODELS_API.has(provider)) { + return { + valid: false, + error: "Provider validation not supported", + unsupported: true, + }; + } + + // 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: unknown) { + return toWebCookieValidationErrorResult(provider, error); + } +} + +// #5422: Bytez key validation cannot use a chat probe. A Bytez account only serves models +// that have been added to its catalog, so even Bytez's own documented model ids return 404 +// ("Model does not exist or has yet to be added to the Bytez catalog") for a fresh/free key — +// the generic OpenAI-like chat probe misreads that 404 as "endpoint not supported". Validate +// against the model-independent, auth-only tasks endpoint instead (verified live): +// GET …/models/v2/list/tasks → 200 (valid key) | 401 { error: "Unauthorized" } (invalid). +// The pure status→result mapping is factored out so it is unit-testable without network. +export function bytezValidationResultFromStatus(status: number): { + valid: boolean; + error: string | null; +} { + if (status === 200) { + return { valid: true, error: null }; + } + if (status === 401 || status === 403) { + return { valid: false, error: "Invalid API key" }; + } + return { valid: false, error: `Validation failed: ${status}` }; +} + +export async function validateBytezProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const res = await validationRead("https://api.bytez.com/models/v2/list/tasks", { + method: "GET", + headers: buildBearerHeaders(apiKey, providerSpecificData), + }); + return bytezValidationResultFromStatus(res.status); + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} diff --git a/tests/unit/validation-kiro-split.test.ts b/tests/unit/validation-kiro-split.test.ts new file mode 100644 index 0000000000..e0777964cb --- /dev/null +++ b/tests/unit/validation-kiro-split.test.ts @@ -0,0 +1,100 @@ +// Characterization of the validation.ts kiro runtime-probe split (god-file decomposition): +// validateKiroApiKeyRuntimeProbe moved into validation/kiro.ts as a top-level function (it was +// previously a module-private helper inside validation.ts). Behavior-preserving move — the lock +// here is module surface; the runtime behavior stays covered by the provider-validation-specialty +// kiro suites. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const M = await import("../../src/lib/providers/validation/kiro.ts"); +const HOST = await import("../../src/lib/providers/validation.ts"); + +test("kiro leaf exposes validateKiroApiKeyRuntimeProbe", () => { + assert.equal(typeof M.validateKiroApiKeyRuntimeProbe, "function"); +}); + +test("host dispatcher surface stays intact after the move", () => { + assert.equal(typeof (HOST as Record).validateProviderApiKey, "function"); +}); + +test("validateKiroApiKeyRuntimeProbe: 200 returns valid with method tag", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(new ReadableStream(), { status: 200 })) as typeof fetch; + try { + const result = await M.validateKiroApiKeyRuntimeProbe({ + apiKey: "ksk-valid", + region: "us-east-1", + }); + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(result.method, "kiro_generate_assistant_response"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: 401/403 → invalid Kiro key/region", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ message: "denied" }), { status: 403 })) as typeof fetch; + try { + const result = await M.validateKiroApiKeyRuntimeProbe({ + apiKey: "ksk-bad", + region: "eu-west-1", + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid Kiro API key or AWS region"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: 400/422/429 treated as valid (auth passed)", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("{}", { status: 429 })) as typeof fetch; + try { + const result = await M.validateKiroApiKeyRuntimeProbe({ + apiKey: "ksk-rate-limited", + region: "us-east-1", + }); + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.match(result.method || "", /kiro_generate_assistant_response_429/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: us-east-1 targets the codewhisperer endpoint", async () => { + const originalFetch = globalThis.fetch; + let calledUrl = ""; + globalThis.fetch = (async (url: URL | string) => { + calledUrl = String(url); + return new Response(new ReadableStream(), { status: 200 }); + }) as typeof fetch; + try { + await M.validateKiroApiKeyRuntimeProbe({ apiKey: "k", region: "us-east-1" }); + assert.equal( + calledUrl, + "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("validateKiroApiKeyRuntimeProbe: non-us-east-1 targets the regional q endpoint", async () => { + const originalFetch = globalThis.fetch; + let calledUrl = ""; + globalThis.fetch = (async (url: URL | string) => { + calledUrl = String(url); + return new Response(new ReadableStream(), { status: 200 }); + }) as typeof fetch; + try { + await M.validateKiroApiKeyRuntimeProbe({ apiKey: "k", region: "eu-west-1" }); + assert.equal(calledUrl, "https://q.eu-west-1.amazonaws.com/generateAssistantResponse"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/validation-specialty-inline-split.test.ts b/tests/unit/validation-specialty-inline-split.test.ts new file mode 100644 index 0000000000..39c734c961 --- /dev/null +++ b/tests/unit/validation-specialty-inline-split.test.ts @@ -0,0 +1,69 @@ +// Characterization of the validation.ts specialty-inline split (god-file decomposition): the +// inline SPECIALTY_VALIDATORS closures (v0-vercel, auggie, qoder, kiro wrapper, gitlab, vertex, +// vertex-partner, longcat, nvidia, zai, xiaomi-mimo, gitlawb factory) moved into +// validation/specialtyInline.ts as top-level functions taking `isLocal` where the original +// closure captured it. Behavior-preserving move — the locks here are module surface; the runtime +// behavior stays covered by provider-validation-specialty / provider-validation-azure-vertex / +// nvidia-validation-* / zai-validator / xiaomi-mimo-provider suites. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const M = await import("../../src/lib/providers/validation/specialtyInline.ts"); +const HOST = await import("../../src/lib/providers/validation.ts"); + +test("specialtyInline exposes the extracted leaf validators", () => { + for (const name of [ + "validateV0VercelProvider", + "validateAuggieProvider", + "validateQoderProvider", + "validateKiroProvider", + "validateGitlabProvider", + "validateVertexProvider", + "validateVertexPartnerProvider", + "validateLongcatProvider", + "validateNvidiaProvider", + "validateZaiProvider", + "validateXiaomiMimoProvider", + "buildOpengatewayValidator", + "buildGitlawbValidators", + ]) { + assert.equal(typeof (M as Record)[name], "function", `missing ${name}`); + } +}); + +test("host dispatcher surface stays intact after the move", () => { + assert.equal(typeof (HOST as Record).validateProviderApiKey, "function"); +}); + +test("buildGitlawbValidators returns one entry per config id", () => { + const map = M.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"], + ], + false + ); + assert.deepEqual(Object.keys(map).sort(), ["gitlawb", "gitlawb-gmi"]); + assert.equal(typeof map.gitlawb, "function"); + assert.equal(typeof map["gitlawb-gmi"], "function"); +}); + +test("validateVertexProvider: Express-mode key is accepted without JWT mint", async () => { + const result = await M.validateVertexProvider({ apiKey: "opaque-express-key" }); + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("validateVertexPartnerProvider: Express-mode key is accepted without JWT mint", async () => { + const result = await M.validateVertexPartnerProvider({ apiKey: "opaque-express-key" }); + assert.equal(result.valid, true); + assert.equal(result.error, null); +}); + +test("validateVertexProvider: malformed Service Account JSON is rejected", async () => { + const result = await M.validateVertexProvider({ + apiKey: '{"type":"service_account","project_id":"p"}', + }); + assert.equal(result.valid, false); + assert.match(result.error || "", /Invalid Service Account JSON/i); +}); diff --git a/tests/unit/validation-web-cookie-split.test.ts b/tests/unit/validation-web-cookie-split.test.ts new file mode 100644 index 0000000000..483ce74af1 --- /dev/null +++ b/tests/unit/validation-web-cookie-split.test.ts @@ -0,0 +1,52 @@ +// Characterization of the validation.ts web-cookie + bytez split (god-file decomposition): +// validateWebCookieProvider, bytezValidationResultFromStatus, and validateBytezProvider moved +// into validation/webCookie.ts. Behavior-preserving move — the locks here are module surface + +// the pure status→result mapping; the runtime behavior stays covered by the +// provider-validation-web-cookie-auth007 / web-cookie-validation-fallback / bytez-validation-5422 +// suites. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const M = await import("../../src/lib/providers/validation/webCookie.ts"); +const HOST = await import("../../src/lib/providers/validation.ts"); + +test("webCookie exposes validateWebCookieProvider, bytezValidationResultFromStatus, validateBytezProvider", () => { + for (const name of [ + "validateWebCookieProvider", + "bytezValidationResultFromStatus", + "validateBytezProvider", + ]) { + assert.equal(typeof (M as Record)[name], "function", `missing ${name}`); + } +}); + +test("host re-exports validateWebCookieProvider + bytezValidationResultFromStatus (historical public surface)", () => { + assert.equal( + (HOST as Record).validateWebCookieProvider, + (M as Record).validateWebCookieProvider + ); + assert.equal( + (HOST as Record).bytezValidationResultFromStatus, + (M as Record).bytezValidationResultFromStatus + ); +}); + +test("bytezValidationResultFromStatus: 200 valid, 401/403 invalid key, other generic failure", () => { + assert.deepEqual(M.bytezValidationResultFromStatus(200), { valid: true, error: null }); + assert.deepEqual(M.bytezValidationResultFromStatus(401), { + valid: false, + error: "Invalid API key", + }); + assert.deepEqual(M.bytezValidationResultFromStatus(403), { + valid: false, + error: "Invalid API key", + }); + assert.deepEqual(M.bytezValidationResultFromStatus(500), { + valid: false, + error: "Validation failed: 500", + }); +}); + +test("host dispatcher surface stays intact after the move", () => { + assert.equal(typeof (HOST as Record).validateProviderApiKey, "function"); +});