mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
fix(providers): fix Azure AI Foundry multi-model discovery and per-deployment connection testing (#8174) (#8206)
Co-authored-by: not-knope <185121404+not-knope@users.noreply.github.com>
This commit is contained in:
@@ -36,11 +36,59 @@ export function normalizeAzureAiBaseUrl(value: string | null | undefined): strin
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export type AzureUrlFormat = "foundry" | "classic";
|
||||
|
||||
export function detectAzureUrlFormat(baseUrl: string | null | undefined): AzureUrlFormat {
|
||||
if (!baseUrl || typeof baseUrl !== "string") return "foundry";
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return "foundry";
|
||||
|
||||
try {
|
||||
const urlStr =
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://")
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
const parsed = new URL(urlStr);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
|
||||
if (host.endsWith(".services.ai.azure.com")) {
|
||||
return "foundry";
|
||||
}
|
||||
|
||||
if (
|
||||
(host.endsWith(".openai.azure.com") || host.endsWith(".cognitiveservices.azure.com")) &&
|
||||
!pathname.includes("/v1")
|
||||
) {
|
||||
return "classic";
|
||||
}
|
||||
|
||||
if (pathname.includes("/v1")) {
|
||||
return "foundry";
|
||||
}
|
||||
|
||||
return "foundry";
|
||||
} catch {
|
||||
return "foundry";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAzureAiChatUrl(
|
||||
value: string | null | undefined,
|
||||
apiType: "chat" | "responses" = "chat"
|
||||
apiType: "chat" | "responses" = "chat",
|
||||
model?: string,
|
||||
apiVersion = "2024-12-01-preview"
|
||||
): string {
|
||||
const format = detectAzureUrlFormat(value);
|
||||
const normalized = normalizeAzureAiBaseUrl(value);
|
||||
|
||||
if (format === "classic" && model) {
|
||||
const raw = stripTrailingSlashes((value || "").trim())
|
||||
.replace(/\/openai$/i, "")
|
||||
.replace(/\/openai\/deployments\/[^/]+\/chat\/completions[^/]*$/i, "");
|
||||
return `${raw}/openai/deployments/${encodeURIComponent(model)}/chat/completions?api-version=${encodeURIComponent(apiVersion)}`;
|
||||
}
|
||||
|
||||
return `${normalized}/${apiType === "responses" ? "responses" : "chat/completions"}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -197,7 +197,12 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
? "responses"
|
||||
: "chat";
|
||||
const baseUrl = this.resolveBaseUrl(credentials);
|
||||
return normalizeAzureAiChatUrl(baseUrl, apiType);
|
||||
const apiVersion =
|
||||
typeof credentials?.providerSpecificData?.apiVersion === "string" &&
|
||||
credentials.providerSpecificData.apiVersion.trim()
|
||||
? credentials.providerSpecificData.apiVersion.trim()
|
||||
: "2024-12-01-preview";
|
||||
return normalizeAzureAiChatUrl(baseUrl, apiType, model, apiVersion);
|
||||
}
|
||||
case "watsonx": {
|
||||
const baseUrl = this.resolveBaseUrl(credentials);
|
||||
|
||||
@@ -17,8 +17,13 @@ export function normalizeDataRobotChatUrl(baseUrl) {
|
||||
return buildDataRobotChatUrl(baseUrl);
|
||||
}
|
||||
|
||||
export function normalizeAzureAiChatUrl(baseUrl: string, apiType: "chat" | "responses" = "chat") {
|
||||
return buildAzureAiChatUrl(baseUrl, apiType);
|
||||
export function normalizeAzureAiChatUrl(
|
||||
baseUrl: string,
|
||||
apiType: "chat" | "responses" = "chat",
|
||||
model?: string,
|
||||
apiVersion?: string
|
||||
) {
|
||||
return buildAzureAiChatUrl(baseUrl, apiType, model, apiVersion);
|
||||
}
|
||||
|
||||
export function normalizeWatsonxChatUrl(baseUrl: string) {
|
||||
|
||||
@@ -232,3 +232,42 @@ export function normalizeSapModelsResponse(
|
||||
})
|
||||
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
|
||||
}
|
||||
|
||||
export function normalizeAzureModelsResponse(
|
||||
data: unknown,
|
||||
fallbackOwner = "azure-ai"
|
||||
): Array<{ id: string; name: string; owned_by: string }> {
|
||||
const payload = asRecord(data);
|
||||
const items = Array.isArray(data)
|
||||
? data
|
||||
: Array.isArray(payload.data)
|
||||
? (payload.data as unknown[])
|
||||
: Array.isArray(payload.models)
|
||||
? (payload.models as unknown[])
|
||||
: Array.isArray(payload.value)
|
||||
? (payload.value as unknown[])
|
||||
: Array.isArray(payload.deployments)
|
||||
? (payload.deployments as unknown[])
|
||||
: [];
|
||||
|
||||
return items
|
||||
.map((value) => {
|
||||
const item = asRecord(value);
|
||||
const id =
|
||||
toNonEmptyString(item.id) ||
|
||||
toNonEmptyString(item.deployment_name) ||
|
||||
toNonEmptyString(item.deploymentName) ||
|
||||
toNonEmptyString(item.name) ||
|
||||
toNonEmptyString(item.model);
|
||||
if (!id) return null;
|
||||
const name =
|
||||
toNonEmptyString(item.display_name) ||
|
||||
toNonEmptyString(item.displayName) ||
|
||||
toNonEmptyString(item.name) ||
|
||||
id;
|
||||
const ownedBy =
|
||||
toNonEmptyString(item.owned_by) || toNonEmptyString(item.provider) || fallbackOwner;
|
||||
return { id, name, owned_by: ownedBy };
|
||||
})
|
||||
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
normalizeDataRobotCatalogResponse,
|
||||
normalizeOpenAiLikeModelsResponse,
|
||||
normalizeSapModelsResponse,
|
||||
normalizeAzureModelsResponse,
|
||||
} from "./discovery/normalizers";
|
||||
import { isNamedOpenAIStyleProvider } from "./discovery/providerSets";
|
||||
import { buildStaleEncryptionKeyResponse } from "./staleEncryptionGuard";
|
||||
@@ -996,58 +997,63 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
const rawBaseUrl =
|
||||
getProviderBaseUrl(connection.providerSpecificData) || AZURE_AI_DEFAULT_BASE_URL;
|
||||
const modelsUrl = buildAzureAiModelsUrl(baseUrl);
|
||||
const baseUrl = normalizeAzureOpenAIBaseUrl(rawBaseUrl);
|
||||
const apiVersion = encodeURIComponent(
|
||||
getAzureOpenAIApiVersion(connection.providerSpecificData) || "2024-12-01-preview"
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(modelsUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": token,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "Azure AI models API unavailable — using cached catalog",
|
||||
localWarning: "Azure AI models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
const discoveryUrls = [
|
||||
buildAzureAiModelsUrl(rawBaseUrl),
|
||||
`${baseUrl}/deployments`,
|
||||
`${baseUrl}/openai/deployments?api-version=${apiVersion}`,
|
||||
`${baseUrl}/openai/models?api-version=${apiVersion}`,
|
||||
];
|
||||
|
||||
let lastStatus = 0;
|
||||
for (const modelsUrl of discoveryUrls) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeOutboundFetch(modelsUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"api-key": token,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "Azure AI models API unavailable — using cached catalog",
|
||||
localWarning: "Azure AI models API unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const normalized = normalizeAzureModelsResponse(await response.json(), "azure-ai");
|
||||
if (normalized.length > 0) {
|
||||
return buildApiDiscoveryResponse(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
lastStatus = response.status;
|
||||
if (response.status === 401 || response.status === 403) break;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Models probe failed (${response.status}) — using cached catalog`,
|
||||
localWarning: `Models probe failed (${response.status}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = (data.data || data.models || []).map((model: Record<string, unknown>) => ({
|
||||
id:
|
||||
(typeof model.id === "string" && model.id) ||
|
||||
(typeof model.name === "string" && model.name) ||
|
||||
"",
|
||||
name:
|
||||
(typeof model.display_name === "string" && model.display_name) ||
|
||||
(typeof model.name === "string" && model.name) ||
|
||||
(typeof model.id === "string" && model.id) ||
|
||||
"",
|
||||
owned_by: "azure-ai",
|
||||
}));
|
||||
|
||||
return buildApiDiscoveryResponse(models.filter((model) => model.id));
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: `Azure AI models probe failed (${lastStatus || "empty"}) — using cached catalog`,
|
||||
localWarning: `Azure AI models probe failed (${lastStatus || "empty"}) — using local catalog`,
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${lastStatus || "unknown"}` },
|
||||
{ status: lastStatus || 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "azure-openai") {
|
||||
|
||||
@@ -615,6 +615,9 @@ async function testApiKeyConnection(connection: any) {
|
||||
error,
|
||||
warning: result.warning || null,
|
||||
diagnosis,
|
||||
...(Array.isArray((result as any).deployments)
|
||||
? { deployments: (result as any).deployments }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,11 @@ async function findCustomModelMetadata(providerId: string, modelId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInternalChatRequest(testBody: Record<string, unknown>, signal: AbortSignal) {
|
||||
export function buildInternalChatRequest(
|
||||
testBody: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
connectionId?: string
|
||||
) {
|
||||
return new Request(`${INTERNAL_ORIGIN}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -136,13 +140,18 @@ export function buildInternalChatRequest(testBody: Record<string, unknown>, sign
|
||||
// Output Styles (e.g. "Ultra terse") leak a system prompt into a test-model call.
|
||||
"X-OmniRoute-Compression": "off",
|
||||
"X-Request-Id": `model-test-${randomUUID()}`,
|
||||
...(connectionId ? { "X-OmniRoute-Connection": connectionId } : {}),
|
||||
},
|
||||
body: JSON.stringify(testBody),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInternalRerankRequest(testBody: Record<string, unknown>, signal: AbortSignal) {
|
||||
export function buildInternalRerankRequest(
|
||||
testBody: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
connectionId?: string
|
||||
) {
|
||||
return new Request(`${INTERNAL_ORIGIN}/v1/rerank`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -151,6 +160,7 @@ export function buildInternalRerankRequest(testBody: Record<string, unknown>, si
|
||||
"X-OmniRoute-No-Cache": "true",
|
||||
"X-OmniRoute-Compression": "off",
|
||||
"X-Request-Id": `model-test-${randomUUID()}`,
|
||||
...(connectionId ? { "X-OmniRoute-Connection": connectionId } : {}),
|
||||
},
|
||||
body: JSON.stringify(testBody),
|
||||
signal,
|
||||
@@ -308,9 +318,9 @@ export async function runSingleModelTest(
|
||||
);
|
||||
}
|
||||
if (isRerank) {
|
||||
return postRerank(buildInternalRerankRequest(testBody, signal));
|
||||
return postRerank(buildInternalRerankRequest(testBody, signal, connectionId));
|
||||
}
|
||||
return postChatCompletion(buildInternalChatRequest(testBody, signal));
|
||||
return postChatCompletion(buildInternalChatRequest(testBody, signal, connectionId));
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
|
||||
@@ -290,6 +290,56 @@ export async function validateAzureOpenAIProvider({ apiKey, providerSpecificData
|
||||
return { valid: false, error: `Validation failed: ${response.status}` };
|
||||
}
|
||||
|
||||
function extractTargetDeployments(psd: Record<string, unknown>): string[] {
|
||||
const deployments: string[] = [];
|
||||
|
||||
if (Array.isArray(psd.deployments)) {
|
||||
for (const d of psd.deployments) {
|
||||
if (typeof d === "string" && d.trim()) deployments.push(d.trim());
|
||||
else if (
|
||||
d &&
|
||||
typeof d === "object" &&
|
||||
typeof (d as any).id === "string" &&
|
||||
(d as any).id.trim()
|
||||
) {
|
||||
deployments.push((d as any).id.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(psd.models)) {
|
||||
for (const m of psd.models) {
|
||||
if (typeof m === "string" && m.trim()) deployments.push(m.trim());
|
||||
else if (
|
||||
m &&
|
||||
typeof m === "object" &&
|
||||
typeof (m as any).id === "string" &&
|
||||
(m as any).id.trim()
|
||||
) {
|
||||
deployments.push((m as any).id.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(psd.validationModelIds)) {
|
||||
for (const id of psd.validationModelIds) {
|
||||
if (typeof id === "string" && id.trim()) deployments.push(id.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof psd.validationModelId === "string" && psd.validationModelId.trim()) {
|
||||
const split = psd.validationModelId
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const s of split) {
|
||||
if (!deployments.includes(s)) deployments.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(deployments));
|
||||
}
|
||||
|
||||
export async function validateAzureAiProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
const rawBaseUrl = normalizeBaseUrl(providerSpecificData.baseUrl) || AZURE_AI_DEFAULT_BASE_URL;
|
||||
const modelsUrl = buildAzureAiModelsUrl(rawBaseUrl);
|
||||
@@ -301,6 +351,7 @@ export async function validateAzureAiProvider({ apiKey, providerSpecificData = {
|
||||
providerSpecificData
|
||||
);
|
||||
|
||||
let modelsEndpointOk = false;
|
||||
try {
|
||||
const response = await validationRead(modelsUrl, {
|
||||
method: "GET",
|
||||
@@ -308,83 +359,131 @@ export async function validateAzureAiProvider({ apiKey, providerSpecificData = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { valid: true, error: null, method: "azure_ai_models" };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
modelsEndpointOk = true;
|
||||
} else 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",
|
||||
};
|
||||
} else if (response.status === 429) {
|
||||
modelsEndpointOk = true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to chat probe when /models is unavailable.
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData.validationModelId === "string"
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "";
|
||||
const targetDeployments = extractTargetDeployments(providerSpecificData);
|
||||
|
||||
if (!validationModelId) {
|
||||
if (targetDeployments.length === 0) {
|
||||
if (modelsEndpointOk) {
|
||||
return { valid: true, error: null, method: "azure_ai_models" };
|
||||
}
|
||||
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,
|
||||
}
|
||||
const apiType = providerSpecificData.apiType === "responses" ? "responses" : "chat";
|
||||
const apiVersion =
|
||||
typeof providerSpecificData.apiVersion === "string" && providerSpecificData.apiVersion.trim()
|
||||
? providerSpecificData.apiVersion.trim()
|
||||
: "2024-12-01-preview";
|
||||
|
||||
const probeOne = async (deploymentId: string) => {
|
||||
const chatUrl = buildAzureAiChatUrl(rawBaseUrl, apiType, deploymentId, apiVersion);
|
||||
const chatBody =
|
||||
apiType === "responses"
|
||||
? {
|
||||
model: deploymentId,
|
||||
input: "test",
|
||||
max_output_tokens: 1,
|
||||
}
|
||||
: {
|
||||
model: deploymentId,
|
||||
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 === 422 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { deploymentId, valid: true, status: "ok", error: null };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { deploymentId, valid: false, status: "auth_error", error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return { deploymentId, valid: false, status: "not_found", error: "Deployment not found" };
|
||||
}
|
||||
|
||||
return {
|
||||
deploymentId,
|
||||
valid: false,
|
||||
status: "error",
|
||||
error: `Provider unavailable (${response.status})`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const errRes = toValidationErrorResult(error);
|
||||
return {
|
||||
deploymentId,
|
||||
valid: false,
|
||||
status: "error",
|
||||
error: errRes.error || "Connection failed",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const settled = await Promise.allSettled(targetDeployments.map(probeOne));
|
||||
const deploymentResults = settled.map((res, i) =>
|
||||
res.status === "fulfilled"
|
||||
? res.value
|
||||
: {
|
||||
model: validationModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
deploymentId: targetDeployments[i],
|
||||
valid: false,
|
||||
status: "error",
|
||||
error: "Connection test failed",
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await validationWrite(chatUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(chatBody),
|
||||
});
|
||||
const validCount = deploymentResults.filter((r) => r.valid).length;
|
||||
const totalCount = deploymentResults.length;
|
||||
const hasAnyValid = validCount > 0 || modelsEndpointOk;
|
||||
|
||||
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);
|
||||
if (!hasAnyValid) {
|
||||
const firstErr = deploymentResults.find((r) => r.error)?.error || "Connection failed";
|
||||
return {
|
||||
valid: false,
|
||||
error: firstErr,
|
||||
method: "azure_ai_chat_probe",
|
||||
deployments: deploymentResults,
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: false, error: "Connection failed while testing Azure AI Foundry" };
|
||||
const warning =
|
||||
validCount < totalCount
|
||||
? `${totalCount - validCount} of ${totalCount} deployment(s) failed connection test.`
|
||||
: null;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
warning,
|
||||
method:
|
||||
modelsEndpointOk && targetDeployments.length === 0
|
||||
? "azure_ai_models"
|
||||
: "azure_ai_chat_probe",
|
||||
deployments: deploymentResults,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateWatsonxProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
@@ -666,4 +765,3 @@ export async function validateSapProvider({ apiKey, providerSpecificData = {} }:
|
||||
|
||||
return { valid: false, error: "Connection failed while testing SAP Generative AI Hub" };
|
||||
}
|
||||
|
||||
|
||||
189
tests/unit/azureAiMultiModelTest.test.ts
Normal file
189
tests/unit/azureAiMultiModelTest.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { detectAzureUrlFormat, buildAzureAiChatUrl } from "../../open-sse/config/azureAi.ts";
|
||||
import {
|
||||
normalizeOpenAiLikeModelsResponse,
|
||||
normalizeAzureModelsResponse,
|
||||
} from "../../src/app/api/providers/[id]/models/discovery/normalizers";
|
||||
import { validateAzureAiProvider } from "../../src/lib/providers/validation/cloudProviders.ts";
|
||||
import { buildInternalChatRequest } from "../../src/lib/api/modelTestRunner";
|
||||
|
||||
test("detectAzureUrlFormat correctly identifies foundry vs classic Azure URL formats", () => {
|
||||
// Foundry format (.services.ai.azure.com or explicit /v1 path)
|
||||
assert.equal(
|
||||
detectAzureUrlFormat("https://my-foundry.services.ai.azure.com/openai/v1"),
|
||||
"foundry"
|
||||
);
|
||||
assert.equal(detectAzureUrlFormat("https://my-foundry.services.ai.azure.com"), "foundry");
|
||||
assert.equal(detectAzureUrlFormat("https://custom-gateway.internal/openai/v1"), "foundry");
|
||||
|
||||
// Classic Azure OpenAI format (.openai.azure.com / .cognitiveservices.azure.com without /v1)
|
||||
assert.equal(detectAzureUrlFormat("https://my-resource.openai.azure.com"), "classic");
|
||||
assert.equal(detectAzureUrlFormat("https://my-resource.cognitiveservices.azure.com"), "classic");
|
||||
|
||||
// Classic domain with explicit /v1 path returns foundry
|
||||
assert.equal(detectAzureUrlFormat("https://my-resource.openai.azure.com/openai/v1"), "foundry");
|
||||
|
||||
// Fallbacks for empty / null / unknown domain
|
||||
assert.equal(detectAzureUrlFormat(""), "foundry");
|
||||
assert.equal(detectAzureUrlFormat(null), "foundry");
|
||||
assert.equal(detectAzureUrlFormat(undefined), "foundry");
|
||||
assert.equal(detectAzureUrlFormat("https://unknown-proxy.company.com"), "foundry");
|
||||
});
|
||||
|
||||
test("buildAzureAiChatUrl builds appropriate chat URLs based on detected format", () => {
|
||||
// Classic format with model
|
||||
const classicUrl = buildAzureAiChatUrl(
|
||||
"https://my-resource.openai.azure.com",
|
||||
"chat",
|
||||
"gpt-4o-mini",
|
||||
"2024-12-01-preview"
|
||||
);
|
||||
assert.equal(
|
||||
classicUrl,
|
||||
"https://my-resource.openai.azure.com/openai/deployments/gpt-4o-mini/chat/completions?api-version=2024-12-01-preview"
|
||||
);
|
||||
|
||||
// Foundry format
|
||||
const foundryUrl = buildAzureAiChatUrl(
|
||||
"https://my-resource.services.ai.azure.com/openai/v1",
|
||||
"chat",
|
||||
"gpt-4o-mini"
|
||||
);
|
||||
assert.equal(foundryUrl, "https://my-resource.services.ai.azure.com/openai/v1/chat/completions");
|
||||
});
|
||||
|
||||
test("normalizeOpenAiLikeModelsResponse regression test for standard OpenAI-compatible providers", () => {
|
||||
// Standard OpenAI response format (openai, together, openrouter, etc.)
|
||||
const openaiPayload = {
|
||||
object: "list",
|
||||
data: [
|
||||
{ id: "gpt-4o", object: "model", owned_by: "system" },
|
||||
{ id: "gpt-4o-mini", object: "model", owned_by: "system" },
|
||||
],
|
||||
};
|
||||
|
||||
const normalizedOpenAI = normalizeOpenAiLikeModelsResponse(openaiPayload, "openai");
|
||||
assert.equal(normalizedOpenAI.length, 2);
|
||||
assert.equal(normalizedOpenAI[0].id, "gpt-4o");
|
||||
assert.equal(normalizedOpenAI[0].owned_by, "system");
|
||||
assert.equal(normalizedOpenAI[1].id, "gpt-4o-mini");
|
||||
|
||||
// Together AI format
|
||||
const togetherPayload = {
|
||||
data: [{ id: "meta-llama/Llama-3-70b-chat-hf", name: "Llama-3-70b", provider: "together" }],
|
||||
};
|
||||
|
||||
const normalizedTogether = normalizeOpenAiLikeModelsResponse(togetherPayload, "together");
|
||||
assert.equal(normalizedTogether.length, 1);
|
||||
assert.equal(normalizedTogether[0].id, "meta-llama/Llama-3-70b-chat-hf");
|
||||
assert.equal(normalizedTogether[0].owned_by, "together");
|
||||
});
|
||||
|
||||
test("normalizeAzureModelsResponse handles Azure-specific deployment response formats", () => {
|
||||
// Azure REST value array
|
||||
const azureValuePayload = {
|
||||
value: [
|
||||
{ id: "dep-gpt4", name: "GPT 4 Deployment" },
|
||||
{ deployment_name: "dep-phi3", display_name: "Phi-3 Mini" },
|
||||
],
|
||||
};
|
||||
|
||||
const normalizedValue = normalizeAzureModelsResponse(azureValuePayload, "azure-ai");
|
||||
assert.equal(normalizedValue.length, 2);
|
||||
assert.equal(normalizedValue[0].id, "dep-gpt4");
|
||||
assert.equal(normalizedValue[0].name, "GPT 4 Deployment");
|
||||
assert.equal(normalizedValue[1].id, "dep-phi3");
|
||||
assert.equal(normalizedValue[1].name, "Phi-3 Mini");
|
||||
|
||||
// Azure deployments array
|
||||
const azureDeploymentsPayload = {
|
||||
deployments: [{ deploymentName: "dep-mistral", model: "mistral-large" }],
|
||||
};
|
||||
|
||||
const normalizedDeployments = normalizeAzureModelsResponse(azureDeploymentsPayload, "azure-ai");
|
||||
assert.equal(normalizedDeployments.length, 1);
|
||||
assert.equal(normalizedDeployments[0].id, "dep-mistral");
|
||||
});
|
||||
|
||||
test("buildInternalChatRequest sets X-OmniRoute-Connection header when connectionId is provided", () => {
|
||||
const reqWithConn = buildInternalChatRequest(
|
||||
{ model: "azure-ai/gpt-4o" },
|
||||
new AbortController().signal,
|
||||
"conn-azure-123"
|
||||
);
|
||||
assert.equal(reqWithConn.headers.get("X-OmniRoute-Connection"), "conn-azure-123");
|
||||
|
||||
const reqWithoutConn = buildInternalChatRequest(
|
||||
{ model: "azure-ai/gpt-4o" },
|
||||
new AbortController().signal
|
||||
);
|
||||
assert.equal(reqWithoutConn.headers.get("X-OmniRoute-Connection"), null);
|
||||
});
|
||||
|
||||
test("validateAzureAiProvider evaluates multi-deployment connections with granular per-deployment results", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string | URL | Request, options?: RequestInit) => {
|
||||
const urlStr = String(url);
|
||||
const bodyStr = String(options?.body || "");
|
||||
if (urlStr.includes("/models")) {
|
||||
return new Response(JSON.stringify({ error: "models unavailable" }), { status: 404 });
|
||||
}
|
||||
if (urlStr.includes("dep-ok") || bodyStr.includes("dep-ok")) {
|
||||
return new Response(JSON.stringify({ id: "dep-ok" }), { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("dep-auth-err") || bodyStr.includes("dep-auth-err")) {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
}
|
||||
if (urlStr.includes("dep-timeout") || bodyStr.includes("dep-timeout")) {
|
||||
throw new Error("Connection test timed out");
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const psd = {
|
||||
baseUrl: "https://example.com/openai/v1",
|
||||
deployments: ["dep-ok", "dep-auth-err", "dep-timeout"],
|
||||
};
|
||||
|
||||
const result = await validateAzureAiProvider({
|
||||
apiKey: "test-api-key",
|
||||
providerSpecificData: psd,
|
||||
});
|
||||
|
||||
assert.equal(typeof result, "object");
|
||||
assert.equal(result.valid, true);
|
||||
assert.ok(Array.isArray(result.deployments));
|
||||
assert.equal(result.deployments.length, 3);
|
||||
|
||||
type DeploymentResult = {
|
||||
deploymentId: string;
|
||||
valid: boolean;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
};
|
||||
const deployments = result.deployments as DeploymentResult[];
|
||||
const depOk = deployments.find((d) => d.deploymentId === "dep-ok");
|
||||
const depAuth = deployments.find((d) => d.deploymentId === "dep-auth-err");
|
||||
const depTimeout = deployments.find((d) => d.deploymentId === "dep-timeout");
|
||||
|
||||
assert.ok(depOk);
|
||||
assert.equal(depOk.valid, true);
|
||||
assert.equal(depOk.status, "ok");
|
||||
|
||||
assert.ok(depAuth);
|
||||
assert.equal(depAuth.valid, false);
|
||||
assert.equal(depAuth.status, "auth_error");
|
||||
|
||||
assert.ok(depTimeout);
|
||||
assert.equal(depTimeout.valid, false);
|
||||
assert.equal(depTimeout.status, "error");
|
||||
|
||||
// 1 failed/error deployment does not block the other deployments or invalidate the connection completely
|
||||
assert.equal(typeof result.warning, "string");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user