mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
refactor(api): extrai validators enterprise-cloud + probe compartilhado de validation.ts (#4923)
Integrado em release/v3.8.36 (validation.ts split fatia 3 — enterprise-cloud + probe)
This commit is contained in:
committed by
GitHub
parent
1cb246215a
commit
6035fd8ba1
@@ -25,41 +25,12 @@ import {
|
||||
} from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { resolveNvidiaValidationModel } from "@/lib/providers/nvidiaValidationModel";
|
||||
import { getGigachatAccessToken } from "@omniroute/open-sse/services/gigachatAuth.ts";
|
||||
import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts";
|
||||
import {
|
||||
AZURE_AI_DEFAULT_BASE_URL,
|
||||
buildAzureAiChatUrl,
|
||||
buildAzureAiModelsUrl,
|
||||
} from "@omniroute/open-sse/config/azureAi.ts";
|
||||
import {
|
||||
discoverBedrockNativeModels,
|
||||
isBedrockNativeApiError,
|
||||
isBedrockNativeAuthError,
|
||||
} from "@omniroute/open-sse/services/bedrock.ts";
|
||||
import {
|
||||
DATAROBOT_DEFAULT_BASE_URL,
|
||||
buildDataRobotCatalogUrl,
|
||||
buildDataRobotChatUrl,
|
||||
isDataRobotDeploymentUrl,
|
||||
} from "@omniroute/open-sse/config/datarobot.ts";
|
||||
import {
|
||||
OCI_DEFAULT_BASE_URL,
|
||||
buildOciChatUrl,
|
||||
buildOciModelsUrl,
|
||||
} from "@omniroute/open-sse/config/oci.ts";
|
||||
import {
|
||||
SAP_DEFAULT_BASE_URL,
|
||||
buildSapChatUrl,
|
||||
buildSapModelsUrl,
|
||||
getSapResourceGroup,
|
||||
isSapDeploymentUrl,
|
||||
} from "@omniroute/open-sse/config/sap.ts";
|
||||
import {
|
||||
WATSONX_DEFAULT_BASE_URL,
|
||||
buildWatsonxChatUrl,
|
||||
buildWatsonxModelsUrl,
|
||||
} from "@omniroute/open-sse/config/watsonx.ts";
|
||||
import {
|
||||
buildRunwayApiUrl,
|
||||
buildRunwayHeaders,
|
||||
@@ -76,16 +47,11 @@ import {
|
||||
OPENAI_LIKE_FORMATS,
|
||||
GEMINI_LIKE_FORMATS,
|
||||
normalizeBaseUrl,
|
||||
normalizeAzureOpenAIBaseUrl,
|
||||
normalizeAnthropicBaseUrl,
|
||||
normalizeClaudeCodeCompatibleBaseUrl,
|
||||
addModelsSuffix,
|
||||
resolveBaseUrl,
|
||||
resolveChatUrl,
|
||||
normalizeHerokuChatUrl,
|
||||
normalizeDatabricksChatUrl,
|
||||
normalizeSnowflakeChatUrl,
|
||||
normalizeGigachatChatUrl,
|
||||
} from "./validation/urlHelpers";
|
||||
import {
|
||||
STANDARD_USER_AGENT,
|
||||
@@ -121,6 +87,19 @@ import {
|
||||
validateJulesProvider,
|
||||
validateInnerAiProvider,
|
||||
} from "./validation/webProvidersB";
|
||||
import { validateDirectChatProvider } from "./validation/directChatProbe";
|
||||
import {
|
||||
validateHerokuProvider,
|
||||
validateDatabricksProvider,
|
||||
validateDataRobotProvider,
|
||||
validateSnowflakeProvider,
|
||||
validateGigachatProvider,
|
||||
validateAzureOpenAIProvider,
|
||||
validateAzureAiProvider,
|
||||
validateWatsonxProvider,
|
||||
validateOciProvider,
|
||||
validateSapProvider,
|
||||
} from "./validation/cloudProviders";
|
||||
|
||||
// isRetryableProxyTarget + isSecurityBlockError now live in ./validation/transport. Re-export them
|
||||
// here to preserve the historical public surface (tests + route handlers import them via this module).
|
||||
@@ -274,47 +253,6 @@ async function validateOpenAILikeProvider({
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDirectChatProvider({
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
providerSpecificData = {},
|
||||
isLocal = false,
|
||||
}: any) {
|
||||
try {
|
||||
const response = await validationWrite(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: applyCustomUserAgent(headers, providerSpecificData),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
isLocal
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} 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");
|
||||
@@ -1012,631 +950,6 @@ async function validateBailianCodingPlanProvider({ apiKey, providerSpecificData
|
||||
}
|
||||
}
|
||||
|
||||
async function validateHerokuProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeHerokuChatUrl(baseUrl),
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "claude-4-sonnet",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDatabricksProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeDatabricksChatUrl(baseUrl),
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "databricks-meta-llama-3-3-70b-instruct",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDataRobotProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const configuredBaseUrl =
|
||||
normalizeBaseUrl(providerSpecificData.baseUrl) || DATAROBOT_DEFAULT_BASE_URL;
|
||||
|
||||
if (isDataRobotDeploymentUrl(configuredBaseUrl)) {
|
||||
return validateDirectChatProvider({
|
||||
url: buildDataRobotChatUrl(configuredBaseUrl),
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "datarobot-deployed-llm",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
const catalogUrl = buildDataRobotCatalogUrl(configuredBaseUrl);
|
||||
if (!catalogUrl) {
|
||||
return { valid: false, error: "Invalid DataRobot base URL" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await validationRead(catalogUrl, {
|
||||
method: "GET",
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "gateway_catalog" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "gateway_catalog",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return { valid: true, error: null, method: "gateway_catalog" };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSnowflakeProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
const usesProgrammaticAccessToken = typeof apiKey === "string" && apiKey.startsWith("pat/");
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeSnowflakeChatUrl(baseUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${usesProgrammaticAccessToken ? apiKey.slice(4) : apiKey}`,
|
||||
"X-Snowflake-Authorization-Token-Type": usesProgrammaticAccessToken
|
||||
? "PROGRAMMATIC_ACCESS_TOKEN"
|
||||
: "KEYPAIR_JWT",
|
||||
},
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "llama3.3-70b",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
async function validateGigachatProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl =
|
||||
normalizeBaseUrl(providerSpecificData.baseUrl) || "https://gigachat.devices.sberbank.ru/api/v1";
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = await getGigachatAccessToken({ credentials: apiKey });
|
||||
} catch (error: any) {
|
||||
if (String(error?.message || "").match(/\b(401|403)\b/)) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeGigachatChatUrl(baseUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "GigaChat-2-Pro",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
async function validateAzureOpenAIProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!rawBaseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
const baseUrl = normalizeAzureOpenAIBaseUrl(rawBaseUrl);
|
||||
const apiVersion =
|
||||
typeof providerSpecificData.validationApiVersion === "string" &&
|
||||
providerSpecificData.validationApiVersion.trim()
|
||||
? providerSpecificData.validationApiVersion.trim()
|
||||
: "2024-12-01-preview";
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"api-key": apiKey,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
const encodedVersion = encodeURIComponent(apiVersion);
|
||||
|
||||
for (const probeUrl of [
|
||||
`${baseUrl}/openai/deployments?api-version=${encodedVersion}`,
|
||||
`${baseUrl}/openai/models?api-version=${encodedVersion}`,
|
||||
]) {
|
||||
try {
|
||||
const response = await validationRead(probeUrl, { method: "GET", headers });
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "azure_probe" };
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
if (response.status === 400 || response.status === 404 || response.status === 405) {
|
||||
continue;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "azure_probe",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentId =
|
||||
typeof providerSpecificData.validationModelId === "string"
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "";
|
||||
|
||||
if (!deploymentId) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
warning:
|
||||
"Azure key accepted, but no deployment name was provided for a chat probe. Set Model ID to validate a specific deployment.",
|
||||
};
|
||||
}
|
||||
|
||||
const chatUrl = `${baseUrl}/openai/deployments/${encodeURIComponent(deploymentId)}/chat/completions?api-version=${encodedVersion}`;
|
||||
const response = await validationWrite(chatUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: deploymentId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null, method: "chat_probe" };
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "chat_probe",
|
||||
warning: "Azure credentials are valid, but the requested deployment was not found.",
|
||||
};
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
}
|
||||
|
||||
async function validateAzureAiProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || AZURE_AI_DEFAULT_BASE_URL;
|
||||
const modelsUrl = buildAzureAiModelsUrl(rawBaseUrl);
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"api-key": apiKey,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(modelsUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "azure_ai_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "azure_ai_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string"
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "";
|
||||
|
||||
if (!validationModelId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Endpoint /models unavailable. Provide a Model ID to validate via /chat/completions.",
|
||||
};
|
||||
}
|
||||
|
||||
const chatUrl = buildAzureAiChatUrl(
|
||||
rawBaseUrl,
|
||||
providerSpecificData.apiType === "responses" ? "responses" : "chat"
|
||||
);
|
||||
const chatBody =
|
||||
providerSpecificData.apiType === "responses"
|
||||
? {
|
||||
model: validationModelId,
|
||||
input: "test",
|
||||
max_output_tokens: 1,
|
||||
}
|
||||
: {
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await validationWrite(chatUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(chatBody),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null, method: "azure_ai_chat_probe" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing Azure AI Foundry" };
|
||||
}
|
||||
|
||||
async function validateWatsonxProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || WATSONX_DEFAULT_BASE_URL;
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildWatsonxModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "watsonx_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "watsonx_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "ibm/granite-3-3-8b-instruct";
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildWatsonxChatUrl(rawBaseUrl), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "watsonx_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "watsonx credentials are valid, but the requested model is not enabled." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing watsonx.ai" };
|
||||
}
|
||||
|
||||
async function validateOciProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || OCI_DEFAULT_BASE_URL;
|
||||
const projectId =
|
||||
typeof providerSpecificData.projectId === "string" && providerSpecificData.projectId.trim()
|
||||
? providerSpecificData.projectId.trim()
|
||||
: typeof providerSpecificData.project === "string" && providerSpecificData.project.trim()
|
||||
? providerSpecificData.project.trim()
|
||||
: "";
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...(projectId ? { "OpenAI-Project": projectId } : {}),
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildOciModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "oci_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "oci_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat/responses probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "openai.gpt-oss-20b";
|
||||
const apiType = providerSpecificData.apiType === "responses" ? "responses" : "chat";
|
||||
const body =
|
||||
apiType === "responses"
|
||||
? {
|
||||
model: validationModelId,
|
||||
input: "test",
|
||||
max_output_tokens: 1,
|
||||
}
|
||||
: {
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildOciChatUrl(rawBaseUrl, apiType), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: apiType === "responses" ? "oci_responses_probe" : "oci_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "OCI credentials are valid, but the requested model was not found." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing OCI Generative AI" };
|
||||
}
|
||||
|
||||
async function validateSapProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || SAP_DEFAULT_BASE_URL;
|
||||
const resourceGroup = getSapResourceGroup(providerSpecificData);
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"AI-Resource-Group": resourceGroup,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildSapModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "sap_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "sap_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to deployment probe when the discovery API is unavailable.
|
||||
}
|
||||
|
||||
const canProbeChat =
|
||||
isSapDeploymentUrl(rawBaseUrl) || /\/chat\/completions$/i.test(normalizeBaseUrl(rawBaseUrl));
|
||||
if (!canProbeChat) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"SAP validation needs either a reachable AI_API_URL or a deployment URL in providerSpecificData.baseUrl",
|
||||
};
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "gpt-4o";
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildSapChatUrl(rawBaseUrl), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "sap_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "SAP credentials are valid, but the deployment URL or model was not found." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing SAP Generative AI Hub" };
|
||||
}
|
||||
|
||||
async function validateRekaProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || "https://api.reka.ai/v1";
|
||||
const headers = buildRekaHeaders(apiKey, providerSpecificData);
|
||||
|
||||
669
src/lib/providers/validation/cloudProviders.ts
Normal file
669
src/lib/providers/validation/cloudProviders.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
// Enterprise-cloud provider key validators: heroku, databricks, datarobot, snowflake, gigachat,
|
||||
// azure-openai, azure-ai, watsonx, oci, sap. Extracted from validation.ts (god-file decomposition) —
|
||||
// top-level functions with no dispatcher-state captures; behavior is byte-identical to the inline defs.
|
||||
import {
|
||||
normalizeBaseUrl,
|
||||
normalizeAzureOpenAIBaseUrl,
|
||||
normalizeHerokuChatUrl,
|
||||
normalizeDatabricksChatUrl,
|
||||
normalizeSnowflakeChatUrl,
|
||||
normalizeGigachatChatUrl,
|
||||
} from "./urlHelpers";
|
||||
import { applyCustomUserAgent, buildBearerHeaders } from "./headers";
|
||||
import { toValidationErrorResult, validationRead, validationWrite } from "./transport";
|
||||
import { validateDirectChatProvider } from "./directChatProbe";
|
||||
import { getGigachatAccessToken } from "@omniroute/open-sse/services/gigachatAuth.ts";
|
||||
import {
|
||||
AZURE_AI_DEFAULT_BASE_URL,
|
||||
buildAzureAiChatUrl,
|
||||
buildAzureAiModelsUrl,
|
||||
} from "@omniroute/open-sse/config/azureAi.ts";
|
||||
import {
|
||||
DATAROBOT_DEFAULT_BASE_URL,
|
||||
buildDataRobotCatalogUrl,
|
||||
buildDataRobotChatUrl,
|
||||
isDataRobotDeploymentUrl,
|
||||
} from "@omniroute/open-sse/config/datarobot.ts";
|
||||
import {
|
||||
OCI_DEFAULT_BASE_URL,
|
||||
buildOciChatUrl,
|
||||
buildOciModelsUrl,
|
||||
} from "@omniroute/open-sse/config/oci.ts";
|
||||
import {
|
||||
SAP_DEFAULT_BASE_URL,
|
||||
buildSapChatUrl,
|
||||
buildSapModelsUrl,
|
||||
getSapResourceGroup,
|
||||
isSapDeploymentUrl,
|
||||
} from "@omniroute/open-sse/config/sap.ts";
|
||||
import {
|
||||
WATSONX_DEFAULT_BASE_URL,
|
||||
buildWatsonxChatUrl,
|
||||
buildWatsonxModelsUrl,
|
||||
} from "@omniroute/open-sse/config/watsonx.ts";
|
||||
|
||||
export async function validateHerokuProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeHerokuChatUrl(baseUrl),
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "claude-4-sonnet",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateDatabricksProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeDatabricksChatUrl(baseUrl),
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "databricks-meta-llama-3-3-70b-instruct",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateDataRobotProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const configuredBaseUrl =
|
||||
normalizeBaseUrl(providerSpecificData.baseUrl) || DATAROBOT_DEFAULT_BASE_URL;
|
||||
|
||||
if (isDataRobotDeploymentUrl(configuredBaseUrl)) {
|
||||
return validateDirectChatProvider({
|
||||
url: buildDataRobotChatUrl(configuredBaseUrl),
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "datarobot-deployed-llm",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
const catalogUrl = buildDataRobotCatalogUrl(configuredBaseUrl);
|
||||
if (!catalogUrl) {
|
||||
return { valid: false, error: "Invalid DataRobot base URL" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await validationRead(catalogUrl, {
|
||||
method: "GET",
|
||||
headers: buildBearerHeaders(apiKey, providerSpecificData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "gateway_catalog" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "gateway_catalog",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
return { valid: true, error: null, method: "gateway_catalog" };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateSnowflakeProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
const usesProgrammaticAccessToken = typeof apiKey === "string" && apiKey.startsWith("pat/");
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeSnowflakeChatUrl(baseUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${usesProgrammaticAccessToken ? apiKey.slice(4) : apiKey}`,
|
||||
"X-Snowflake-Authorization-Token-Type": usesProgrammaticAccessToken
|
||||
? "PROGRAMMATIC_ACCESS_TOKEN"
|
||||
: "KEYPAIR_JWT",
|
||||
},
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "llama3.3-70b",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateGigachatProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const baseUrl =
|
||||
normalizeBaseUrl(providerSpecificData.baseUrl) || "https://gigachat.devices.sberbank.ru/api/v1";
|
||||
|
||||
let token;
|
||||
try {
|
||||
token = await getGigachatAccessToken({ credentials: apiKey });
|
||||
} catch (error: any) {
|
||||
if (String(error?.message || "").match(/\b(401|403)\b/)) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return validateDirectChatProvider({
|
||||
url: normalizeGigachatChatUrl(baseUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token.accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: {
|
||||
model: providerSpecificData.validationModelId || "GigaChat-2-Pro",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateAzureOpenAIProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl);
|
||||
if (!rawBaseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
|
||||
const baseUrl = normalizeAzureOpenAIBaseUrl(rawBaseUrl);
|
||||
const apiVersion =
|
||||
typeof providerSpecificData.validationApiVersion === "string" &&
|
||||
providerSpecificData.validationApiVersion.trim()
|
||||
? providerSpecificData.validationApiVersion.trim()
|
||||
: "2024-12-01-preview";
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"api-key": apiKey,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
const encodedVersion = encodeURIComponent(apiVersion);
|
||||
|
||||
for (const probeUrl of [
|
||||
`${baseUrl}/openai/deployments?api-version=${encodedVersion}`,
|
||||
`${baseUrl}/openai/models?api-version=${encodedVersion}`,
|
||||
]) {
|
||||
try {
|
||||
const response = await validationRead(probeUrl, { method: "GET", headers });
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "azure_probe" };
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
if (response.status === 400 || response.status === 404 || response.status === 405) {
|
||||
continue;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "azure_probe",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentId =
|
||||
typeof providerSpecificData.validationModelId === "string"
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "";
|
||||
|
||||
if (!deploymentId) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
warning:
|
||||
"Azure key accepted, but no deployment name was provided for a chat probe. Set Model ID to validate a specific deployment.",
|
||||
};
|
||||
}
|
||||
|
||||
const chatUrl = `${baseUrl}/openai/deployments/${encodeURIComponent(deploymentId)}/chat/completions?api-version=${encodedVersion}`;
|
||||
const response = await validationWrite(chatUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: deploymentId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null, method: "chat_probe" };
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "chat_probe",
|
||||
warning: "Azure credentials are valid, but the requested deployment was not found.",
|
||||
};
|
||||
}
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
}
|
||||
|
||||
export async function validateAzureAiProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || AZURE_AI_DEFAULT_BASE_URL;
|
||||
const modelsUrl = buildAzureAiModelsUrl(rawBaseUrl);
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"api-key": apiKey,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(modelsUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "azure_ai_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "azure_ai_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string"
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "";
|
||||
|
||||
if (!validationModelId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Endpoint /models unavailable. Provide a Model ID to validate via /chat/completions.",
|
||||
};
|
||||
}
|
||||
|
||||
const chatUrl = buildAzureAiChatUrl(
|
||||
rawBaseUrl,
|
||||
providerSpecificData.apiType === "responses" ? "responses" : "chat"
|
||||
);
|
||||
const chatBody =
|
||||
providerSpecificData.apiType === "responses"
|
||||
? {
|
||||
model: validationModelId,
|
||||
input: "test",
|
||||
max_output_tokens: 1,
|
||||
}
|
||||
: {
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await validationWrite(chatUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(chatBody),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null, method: "azure_ai_chat_probe" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing Azure AI Foundry" };
|
||||
}
|
||||
|
||||
export async function validateWatsonxProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || WATSONX_DEFAULT_BASE_URL;
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildWatsonxModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "watsonx_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "watsonx_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "ibm/granite-3-3-8b-instruct";
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildWatsonxChatUrl(rawBaseUrl), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "watsonx_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "watsonx credentials are valid, but the requested model is not enabled." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing watsonx.ai" };
|
||||
}
|
||||
|
||||
export async function validateOciProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || OCI_DEFAULT_BASE_URL;
|
||||
const projectId =
|
||||
typeof providerSpecificData.projectId === "string" && providerSpecificData.projectId.trim()
|
||||
? providerSpecificData.projectId.trim()
|
||||
: typeof providerSpecificData.project === "string" && providerSpecificData.project.trim()
|
||||
? providerSpecificData.project.trim()
|
||||
: "";
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
...(projectId ? { "OpenAI-Project": projectId } : {}),
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildOciModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "oci_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "oci_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat/responses probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "openai.gpt-oss-20b";
|
||||
const apiType = providerSpecificData.apiType === "responses" ? "responses" : "chat";
|
||||
const body =
|
||||
apiType === "responses"
|
||||
? {
|
||||
model: validationModelId,
|
||||
input: "test",
|
||||
max_output_tokens: 1,
|
||||
}
|
||||
: {
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildOciChatUrl(rawBaseUrl, apiType), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: apiType === "responses" ? "oci_responses_probe" : "oci_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "OCI credentials are valid, but the requested model was not found." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing OCI Generative AI" };
|
||||
}
|
||||
|
||||
export async function validateSapProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || SAP_DEFAULT_BASE_URL;
|
||||
const resourceGroup = getSapResourceGroup(providerSpecificData);
|
||||
const headers = applyCustomUserAgent(
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"AI-Resource-Group": resourceGroup,
|
||||
},
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationRead(buildSapModelsUrl(rawBaseUrl), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "sap_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "sap_models",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to deployment probe when the discovery API is unavailable.
|
||||
}
|
||||
|
||||
const canProbeChat =
|
||||
isSapDeploymentUrl(rawBaseUrl) || /\/chat\/completions$/i.test(normalizeBaseUrl(rawBaseUrl));
|
||||
if (!canProbeChat) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"SAP validation needs either a reachable AI_API_URL or a deployment URL in providerSpecificData.baseUrl",
|
||||
};
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string" &&
|
||||
providerSpecificData.validationModelId.trim()
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "gpt-4o";
|
||||
|
||||
try {
|
||||
const response = await validationWrite(buildSapChatUrl(rawBaseUrl), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 404 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "sap_chat_probe",
|
||||
...(response.status === 404
|
||||
? { warning: "SAP credentials are valid, but the deployment URL or model was not found." }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing SAP Generative AI Hub" };
|
||||
}
|
||||
|
||||
47
src/lib/providers/validation/directChatProbe.ts
Normal file
47
src/lib/providers/validation/directChatProbe.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// Generic "POST /chat/completions auth probe" used by several provider validators (command-code,
|
||||
// nlpcloud + the enterprise-cloud validators). Extracted from validation.ts (god-file decomposition)
|
||||
// into its own leaf so both the host dispatcher and validation/cloudProviders.ts can share it without
|
||||
// a cycle. Behavior is byte-identical to the original inline def.
|
||||
import { applyCustomUserAgent } from "./headers";
|
||||
import { toValidationErrorResult, validationWrite } from "./transport";
|
||||
|
||||
export async function validateDirectChatProvider({
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
providerSpecificData = {},
|
||||
isLocal = false,
|
||||
}: any) {
|
||||
try {
|
||||
const response = await validationWrite(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: applyCustomUserAgent(headers, providerSpecificData),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
isLocal
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (
|
||||
response.ok ||
|
||||
response.status === 400 ||
|
||||
response.status === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Provider unavailable (${response.status})` };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
38
tests/unit/validation-cloud-providers-split.test.ts
Normal file
38
tests/unit/validation-cloud-providers-split.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Characterization of the validation.ts enterprise-cloud split (god-file decomposition): the 10 cloud
|
||||
// validators (heroku/databricks/datarobot/snowflake/gigachat/azure-openai/azure-ai/watsonx/oci/sap)
|
||||
// moved into validation/cloudProviders.ts, and the shared "POST /chat/completions auth probe"
|
||||
// (validateDirectChatProvider) moved into validation/directChatProbe.ts so the host and the cloud
|
||||
// module share it without a cycle. Behavior-preserving move — the locks here are module surface +
|
||||
// the no-cycle wiring. Runtime behavior stays covered by provider-validation-azure-vertex/branches.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const cloud = await import("../../src/lib/providers/validation/cloudProviders.ts");
|
||||
const probe = await import("../../src/lib/providers/validation/directChatProbe.ts");
|
||||
const HOST = await import("../../src/lib/providers/validation.ts");
|
||||
|
||||
test("cloudProviders exposes the ten enterprise-cloud validators", () => {
|
||||
for (const name of [
|
||||
"validateHerokuProvider",
|
||||
"validateDatabricksProvider",
|
||||
"validateDataRobotProvider",
|
||||
"validateSnowflakeProvider",
|
||||
"validateGigachatProvider",
|
||||
"validateAzureOpenAIProvider",
|
||||
"validateAzureAiProvider",
|
||||
"validateWatsonxProvider",
|
||||
"validateOciProvider",
|
||||
"validateSapProvider",
|
||||
]) {
|
||||
assert.equal(typeof (cloud as Record<string, unknown>)[name], "function", `missing ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("directChatProbe exposes the shared validateDirectChatProvider helper", () => {
|
||||
assert.equal(typeof probe.validateDirectChatProvider, "function");
|
||||
});
|
||||
|
||||
test("host dispatcher surface stays intact after the move", () => {
|
||||
assert.equal(typeof (HOST as Record<string, unknown>).validateProviderApiKey, "function");
|
||||
assert.equal(typeof (HOST as Record<string, unknown>).validateCommandCodeProvider, "function");
|
||||
});
|
||||
Reference in New Issue
Block a user