mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
chore(validation): decompose providers/validation.ts into validation/* leaves (→ 442) (#8546)
* chore(validation): extract web-cookie, kiro, specialty-inline validators into validation/* leaves * docs(changelog): add fragment for this PR
This commit is contained in:
1
changelog.d/maintenance/decomp-provider-validation.md
Normal file
1
changelog.d/maintenance/decomp-provider-validation.md
Normal file
@@ -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
|
||||
@@ -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<string, unknown>;
|
||||
}) {
|
||||
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<string, ReturnType<typeof buildOpengatewayValidator>> {
|
||||
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]) => [
|
||||
|
||||
71
src/lib/providers/validation/kiro.ts
Normal file
71
src/lib/providers/validation/kiro.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
378
src/lib/providers/validation/specialtyInline.ts
Normal file
378
src/lib/providers/validation/specialtyInline.ts
Normal file
@@ -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<string, ReturnType<typeof buildOpengatewayValidator>> {
|
||||
return Object.fromEntries(
|
||||
configs.map(([id, baseUrl, model]) => [
|
||||
id,
|
||||
(({ apiKey, providerSpecificData }: any) =>
|
||||
buildOpengatewayValidator(
|
||||
baseUrl,
|
||||
model
|
||||
)({ apiKey, providerSpecificData, isLocal })) as ReturnType<
|
||||
typeof buildOpengatewayValidator
|
||||
>,
|
||||
])
|
||||
);
|
||||
}
|
||||
152
src/lib/providers/validation/webCookie.ts
Normal file
152
src/lib/providers/validation/webCookie.ts
Normal file
@@ -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<string, unknown>;
|
||||
}) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
100
tests/unit/validation-kiro-split.test.ts
Normal file
100
tests/unit/validation-kiro-split.test.ts
Normal file
@@ -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<string, unknown>).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;
|
||||
}
|
||||
});
|
||||
69
tests/unit/validation-specialty-inline-split.test.ts
Normal file
69
tests/unit/validation-specialty-inline-split.test.ts
Normal file
@@ -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<string, unknown>)[name], "function", `missing ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("host dispatcher surface stays intact after the move", () => {
|
||||
assert.equal(typeof (HOST as Record<string, unknown>).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);
|
||||
});
|
||||
52
tests/unit/validation-web-cookie-split.test.ts
Normal file
52
tests/unit/validation-web-cookie-split.test.ts
Normal file
@@ -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<string, unknown>)[name], "function", `missing ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("host re-exports validateWebCookieProvider + bytezValidationResultFromStatus (historical public surface)", () => {
|
||||
assert.equal(
|
||||
(HOST as Record<string, unknown>).validateWebCookieProvider,
|
||||
(M as Record<string, unknown>).validateWebCookieProvider
|
||||
);
|
||||
assert.equal(
|
||||
(HOST as Record<string, unknown>).bytezValidationResultFromStatus,
|
||||
(M as Record<string, unknown>).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<string, unknown>).validateProviderApiKey, "function");
|
||||
});
|
||||
Reference in New Issue
Block a user