mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
The connection test posted chat-completions fields (`messages`, `max_tokens`) to `/responses`. Send `input` and `max_output_tokens` instead. Fixes #11453
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
// OpenAI/Gemini-format + Bedrock provider key validators (bedrock, openai-like, command-code, gemini-like, openai-compatible).
|
|
// Extracted from validation.ts (god-file decomposition) — top-level functions; behavior is
|
|
// byte-identical to the original inline defs.
|
|
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
|
|
import {
|
|
discoverBedrockNativeModels,
|
|
isBedrockNativeApiError,
|
|
isBedrockNativeAuthError,
|
|
} from "@omniroute/open-sse/services/bedrock.ts";
|
|
import { addModelsSuffix, normalizeBaseUrl, resolveChatUrl } from "./urlHelpers";
|
|
import { applyCustomUserAgent, buildBearerHeaders } from "./headers";
|
|
import { toValidationErrorResult, validationRead, validationWrite } from "./transport";
|
|
import { validateDirectChatProvider } from "./directChatProbe";
|
|
import { extractCozeValidationError } from "./cozeError";
|
|
|
|
export async function validateBedrockProvider({ apiKey, providerSpecificData = {} }: any) {
|
|
if (!apiKey) {
|
|
return { valid: false, error: "Provider and API key required" };
|
|
}
|
|
|
|
try {
|
|
const discovery = await discoverBedrockNativeModels({
|
|
apiKey,
|
|
providerSpecificData,
|
|
fetcher: (url, init) => validationRead(url, init),
|
|
});
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
method: "bedrock_native_models",
|
|
warning: discovery.warnings[0] || null,
|
|
};
|
|
} catch (error: any) {
|
|
if (isBedrockNativeAuthError(error)) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
if (isBedrockNativeApiError(error)) {
|
|
if (error.status === 429) {
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
warning: "Bedrock accepted the key but model discovery is rate limited",
|
|
method: "bedrock_native_models",
|
|
};
|
|
}
|
|
if (typeof error.status === "number" && error.status >= 500) {
|
|
return { valid: false, error: `Provider unavailable (${error.status})` };
|
|
}
|
|
if (typeof error.status === "number") {
|
|
return { valid: false, error: `Bedrock validation failed: ${error.status}` };
|
|
}
|
|
}
|
|
return toValidationErrorResult(error);
|
|
}
|
|
}
|
|
|
|
export async function validateOpenAILikeProvider({
|
|
provider = "openai",
|
|
apiKey,
|
|
baseUrl,
|
|
headers = {},
|
|
modelId = "gpt-3.5-turbo",
|
|
providerSpecificData,
|
|
modelsUrl = "",
|
|
isLocal = false,
|
|
}: any) {
|
|
try {
|
|
// Guard against a non-string modelsUrl reaching .trim()/.startsWith() — a malformed
|
|
// providerSpecificData / registry value would otherwise throw a TypeError mid-validation
|
|
// ("trim is not a function" / "startsWith is not a function"). See #2463 class.
|
|
const customModelsUrl = (typeof modelsUrl === "string" ? modelsUrl.trim() : "") || "";
|
|
const endpointUrl = customModelsUrl
|
|
? customModelsUrl.startsWith("http")
|
|
? customModelsUrl
|
|
: `${baseUrl.replace(/\/+$/, "")}/${customModelsUrl.replace(/^\/+/, "")}`
|
|
: // addModelsSuffix strips a trailing /chat/completions before appending /models,
|
|
// so an OpenAI-style baseUrl validates against /v1/models, not /v1/chat/completions/models.
|
|
addModelsSuffix(baseUrl);
|
|
|
|
const requestUrl =
|
|
typeof providerSpecificData?.modelsUrl === "string" &&
|
|
providerSpecificData.modelsUrl.trim() !== ""
|
|
? providerSpecificData.modelsUrl.trim()
|
|
: endpointUrl;
|
|
|
|
const response = await validationRead(
|
|
requestUrl,
|
|
{
|
|
headers: {
|
|
...headers,
|
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
},
|
|
},
|
|
isLocal
|
|
);
|
|
|
|
if (response.ok) {
|
|
return { valid: true, error: null };
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
|
|
// #2929: A 403 on the models endpoint is not always a bad key. Some providers
|
|
// (e.g. Fireworks Fire Pass `fpk_*` keys) return "...not authorized for this
|
|
// route." on /models while still serving chat. Fall through to the chat probe
|
|
// for such route-restriction 403s instead of declaring the key invalid.
|
|
if (response.status === 403) {
|
|
const forbiddenBody = await response.text().catch(() => "");
|
|
if (!/not authorized for this route/i.test(forbiddenBody)) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
}
|
|
|
|
const chatUrl = resolveChatUrl(provider, baseUrl, providerSpecificData);
|
|
if (!chatUrl) {
|
|
return { valid: false, error: `Validation failed: ${response.status}` };
|
|
}
|
|
|
|
const testModelId = (providerSpecificData as any)?.validationModelId || modelId;
|
|
|
|
const testBody = {
|
|
model: testModelId,
|
|
messages: [{ role: "user", content: "test" }],
|
|
max_tokens: 1,
|
|
};
|
|
|
|
const chatRes = await validationWrite(
|
|
chatUrl,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
...headers,
|
|
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
},
|
|
body: JSON.stringify(testBody),
|
|
},
|
|
isLocal
|
|
);
|
|
|
|
if (chatRes.ok) {
|
|
return { valid: true, error: null };
|
|
}
|
|
|
|
// #5426: Coze answers the chat probe with a JSON envelope ({ code, msg,
|
|
// logId, from }) on a bad key. Translate it into a friendly message so the
|
|
// raw envelope (logId included) never leaks into the connection UI. Scoped
|
|
// to provider === "coze" so a non-Coze error body that happens to carry a
|
|
// `msg` field is never mislabeled, and other providers' response bodies are
|
|
// never consumed here — they fall through to the canned handling below.
|
|
if (provider === "coze") {
|
|
const chatErrorBody = await chatRes.text().catch(() => "");
|
|
const cozeError = extractCozeValidationError(chatErrorBody);
|
|
if (cozeError) {
|
|
return { valid: false, error: cozeError };
|
|
}
|
|
}
|
|
|
|
if (chatRes.status === 401 || chatRes.status === 403) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
|
|
if (chatRes.status === 404 || chatRes.status === 405) {
|
|
return {
|
|
valid: false,
|
|
error: "Provider validation endpoint not supported",
|
|
unsupported: true,
|
|
};
|
|
}
|
|
|
|
if (chatRes.status >= 500) {
|
|
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
|
|
}
|
|
|
|
// #7284: A 429 on the chat probe means the key is accepted but this connection
|
|
// is rate/concurrency limited (e.g. always-throttled free tiers like opencode-zen).
|
|
// Keep valid:true (the key works) but surface a warning so the connection Test
|
|
// does not read as an unqualified green when real traffic will hit 429s.
|
|
// Mirrors validateBedrockProvider's existing 429 precedent above.
|
|
if (chatRes.status === 429) {
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
warning: "Provider accepted the key but is rate limited (429)",
|
|
};
|
|
}
|
|
|
|
return { valid: true, error: null };
|
|
} catch (error: any) {
|
|
return toValidationErrorResult(error);
|
|
}
|
|
}
|
|
|
|
export async function validateCommandCodeProvider({ apiKey, providerSpecificData = {} }: any) {
|
|
const entry = getRegistryEntry("command-code");
|
|
const baseUrl = normalizeBaseUrl(entry?.baseUrl || "https://api.commandcode.ai");
|
|
const chatPath = entry?.chatPath || "/provider/v1/chat/completions";
|
|
const url = `${baseUrl}${chatPath.startsWith("/") ? chatPath : `/${chatPath}`}`;
|
|
const validationModelId =
|
|
providerSpecificData?.validationModelId ||
|
|
entry?.models?.find((model) => model.id === "deepseek/deepseek-v4-flash")?.id ||
|
|
"deepseek/deepseek-v4-flash";
|
|
|
|
return validateDirectChatProvider({
|
|
url,
|
|
providerSpecificData,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${apiKey}`,
|
|
Accept: "text/event-stream",
|
|
},
|
|
body: {
|
|
model: validationModelId,
|
|
messages: [{ role: "user", content: "test" }],
|
|
stream: true,
|
|
max_tokens: 1,
|
|
},
|
|
});
|
|
}
|
|
|
|
// HuggingFace fine-grained Inference-Provider tokens are valid even when
|
|
// model/task endpoints reject them, so the generic OpenAI-like probe against
|
|
// router.huggingface.co/v1/models falsely marks them invalid. Validate the
|
|
// token strictly as an auth check via the whoami-v2 endpoint instead: only
|
|
// 401/403 means the token is invalid; any other non-OK status is a transient
|
|
// upstream failure, NOT an invalid key.
|
|
export async function validateHuggingFaceProvider({ apiKey }: any) {
|
|
try {
|
|
const response = await validationRead("https://huggingface.co/api/whoami-v2", {
|
|
method: "GET",
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
});
|
|
|
|
if (response.ok) {
|
|
return { valid: true, error: null, method: "huggingface_whoami" };
|
|
}
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
|
|
// Non-auth, non-OK status — surface as a transient upstream failure rather
|
|
// than declaring the (potentially valid) fine-grained token invalid.
|
|
return { valid: false, error: `HuggingFace token check returned ${response.status}` };
|
|
} catch (error: unknown) {
|
|
return toValidationErrorResult(error);
|
|
}
|
|
}
|
|
|
|
export async function validateGeminiLikeProvider({
|
|
apiKey,
|
|
baseUrl,
|
|
providerSpecificData = {},
|
|
authType = "query",
|
|
isLocal = false,
|
|
}: any) {
|
|
try {
|
|
if (!baseUrl) {
|
|
return { valid: false, error: "Missing base URL" };
|
|
}
|
|
|
|
const normalizedAuthType = String(authType || "query").toLowerCase();
|
|
// Strip a trailing /models before appending — the default Gemini registry baseUrl is
|
|
// `.../v1beta/models` (for the chat urlBuilder), so naively appending /models produced
|
|
// `.../v1beta/models/models` → upstream 404 on connection validation (#2545).
|
|
const baseForModels = String(baseUrl)
|
|
.replace(/\/models\/?$/, "")
|
|
.replace(/\/$/, "");
|
|
const requestUrl =
|
|
typeof providerSpecificData?.modelsUrl === "string" &&
|
|
providerSpecificData.modelsUrl.trim() !== ""
|
|
? providerSpecificData.modelsUrl.trim()
|
|
: `${baseForModels}/models`;
|
|
|
|
// Use the correct auth header based on provider config:
|
|
// - gemini (API key): x-goog-api-key
|
|
// - Google OAuth access tokens (ya29.*): Bearer token
|
|
const headers: Record<string, string> = {};
|
|
let urlWithKey = requestUrl;
|
|
|
|
if (typeof apiKey === "string" && apiKey.startsWith("ya29.")) {
|
|
// A Google OAuth access token (ya29.*) must use Bearer auth even when the
|
|
// connection is configured as an API-key provider. Checked first so authType
|
|
// "apikey"/"header" doesn't shadow it with x-goog-api-key.
|
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
} else if (normalizedAuthType === "header" || normalizedAuthType === "apikey") {
|
|
headers["x-goog-api-key"] = apiKey;
|
|
} else if (normalizedAuthType === "oauth" || normalizedAuthType === "bearer") {
|
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
} else if (normalizedAuthType === "query") {
|
|
urlWithKey = `${requestUrl}?key=${encodeURIComponent(apiKey)}`;
|
|
}
|
|
|
|
applyCustomUserAgent(headers, providerSpecificData);
|
|
|
|
const response = await validationRead(
|
|
urlWithKey,
|
|
{
|
|
headers,
|
|
},
|
|
isLocal
|
|
);
|
|
|
|
if (response.ok) {
|
|
return { valid: true, error: null };
|
|
}
|
|
|
|
if (response.status === 429) {
|
|
return { valid: true, error: null };
|
|
}
|
|
|
|
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
const isAuthError = (body: any) => {
|
|
const message = (body?.error?.message || "").toLowerCase();
|
|
const reason = body?.error?.details?.[0]?.reason || "";
|
|
const status = body?.error?.status || "";
|
|
const authPatterns = [
|
|
"api key not valid",
|
|
"api key expired",
|
|
"api key invalid",
|
|
"API_KEY_INVALID",
|
|
"API_KEY_EXPIRED",
|
|
"PERMISSION_DENIED",
|
|
"UNAUTHENTICATED",
|
|
];
|
|
return authPatterns.some(
|
|
(p) => message.includes(p.toLowerCase()) || reason === p || status === p
|
|
);
|
|
};
|
|
|
|
try {
|
|
const body = await response.json();
|
|
if (isAuthError(body)) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
if (response.status === 401 || response.status === 403) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
} catch {
|
|
if (response.status === 401 || response.status === 403) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
}
|
|
|
|
return { valid: false, error: `Validation failed: ${response.status}` };
|
|
} catch (error: any) {
|
|
return toValidationErrorResult(error);
|
|
}
|
|
}
|
|
|
|
// ── Specialty providers (non-standard APIs) ──
|
|
|
|
export async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData = {} }: any) {
|
|
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
|
if (!baseUrl) {
|
|
return { valid: false, error: "No base URL configured for OpenAI compatible provider" };
|
|
}
|
|
|
|
const validationModelId =
|
|
typeof providerSpecificData?.validationModelId === "string"
|
|
? providerSpecificData.validationModelId.trim()
|
|
: "";
|
|
|
|
// Step 1: Try GET /models
|
|
let modelsReachable = false;
|
|
try {
|
|
const modelsRes = await validationRead(`${baseUrl}/models`, {
|
|
method: "GET",
|
|
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
|
});
|
|
|
|
modelsReachable = true;
|
|
|
|
if (modelsRes.ok) {
|
|
return { valid: true, error: null, method: "models_endpoint" };
|
|
}
|
|
|
|
if (modelsRes.status === 401 || modelsRes.status === 403) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
|
|
// Endpoint responded and auth seems valid, but quota is exhausted/rate-limited.
|
|
if (modelsRes.status === 429) {
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
method: "models_endpoint",
|
|
warning: "Rate limited, but credentials are valid",
|
|
};
|
|
}
|
|
} catch {
|
|
// /models fetch failed (network error, etc.) — fall through to chat test
|
|
}
|
|
|
|
// T25: if /models cannot be used and no custom model was provided, return a
|
|
// clear actionable message instead of a generic connection error.
|
|
if (!validationModelId) {
|
|
return {
|
|
valid: false,
|
|
error: "Endpoint /models unavailable. Provide a Model ID to validate via /chat/completions.",
|
|
};
|
|
}
|
|
|
|
// Step 2: Fallback — try a minimal chat completion request
|
|
// Many providers don't expose /models but accept chat completions fine
|
|
const apiType = providerSpecificData.apiType || "chat";
|
|
const chatSuffix = apiType === "responses" ? "/responses" : "/chat/completions";
|
|
const chatUrl = `${baseUrl}${chatSuffix}`;
|
|
const testModelId = validationModelId;
|
|
const testBody =
|
|
apiType === "responses"
|
|
? {
|
|
model: testModelId,
|
|
input: [{ role: "user", content: "test" }],
|
|
max_output_tokens: 1,
|
|
}
|
|
: {
|
|
model: testModelId,
|
|
messages: [{ role: "user", content: "test" }],
|
|
max_tokens: 1,
|
|
};
|
|
|
|
try {
|
|
const chatRes = await validationWrite(chatUrl, {
|
|
method: "POST",
|
|
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
|
body: JSON.stringify(testBody),
|
|
});
|
|
|
|
if (chatRes.ok) {
|
|
return { valid: true, error: null, method: "chat_completions" };
|
|
}
|
|
|
|
if (chatRes.status === 401 || chatRes.status === 403) {
|
|
return { valid: false, error: "Invalid API key" };
|
|
}
|
|
|
|
if (chatRes.status === 429) {
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
method: "chat_completions",
|
|
warning: "Rate limited, but credentials are valid",
|
|
};
|
|
}
|
|
|
|
// If /models was reachable but returned non-auth error, and chat succeeds
|
|
// auth-wise, this still confirms credentials are valid.
|
|
if (chatRes.status === 400) {
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
method: "inference_available",
|
|
warning: "Model ID may be invalid, but credentials are valid",
|
|
};
|
|
}
|
|
|
|
// #2032: a 404 on the chat probe commonly means the requested model id
|
|
// does not exist at this provider (OpenAI-compatible `model_not_found`,
|
|
// e.g. Featherless/OpenRouter-style `vendor/model` typos). Credentials
|
|
// are still valid (the endpoint responded), but silently passing this
|
|
// hides the bad model id from the user until a real request later trips
|
|
// the per-model lockout — surface it as a warning at Check time instead.
|
|
if (chatRes.status === 404) {
|
|
let modelNotFoundDetail = "";
|
|
try {
|
|
const body: any = await chatRes.json();
|
|
const err = body?.error;
|
|
if (typeof err?.message === "string" && err.message.trim()) {
|
|
modelNotFoundDetail = `: ${err.message.trim()}`;
|
|
}
|
|
} catch {
|
|
// Non-JSON or unreadable body — fall through with the generic warning.
|
|
}
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
method: "inference_available",
|
|
warning: `Model ID may not exist at this provider (404)${modelNotFoundDetail}`,
|
|
};
|
|
}
|
|
|
|
// 4xx other than auth (e.g. 400 bad model, 422) usually means auth passed
|
|
if (chatRes.status >= 400 && chatRes.status < 500) {
|
|
return {
|
|
valid: true,
|
|
error: null,
|
|
method: "inference_available",
|
|
};
|
|
}
|
|
|
|
if (chatRes.status >= 500) {
|
|
return { valid: false, error: `Provider unavailable (${chatRes.status})` };
|
|
}
|
|
} catch {
|
|
// Chat test also failed — fall through to simple connectivity check
|
|
}
|
|
|
|
// Step 3: Final fallback — simple connectivity check
|
|
// For local providers (Ollama, LM Studio, etc.) that may not respond to
|
|
// standard OpenAI endpoints but are still reachable
|
|
if (!modelsReachable) {
|
|
return { valid: false, error: "Connection failed while testing /chat/completions" };
|
|
}
|
|
|
|
try {
|
|
const pingRes = await validationRead(baseUrl, {
|
|
method: "GET",
|
|
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
|
});
|
|
|
|
// If the server responds at all (even with an error page), it's reachable
|
|
if (pingRes.status < 500) {
|
|
return { valid: true, error: null };
|
|
}
|
|
|
|
return { valid: false, error: `Provider unavailable (${pingRes.status})` };
|
|
} catch (error: any) {
|
|
return toValidationErrorResult(error);
|
|
}
|
|
}
|