diff --git a/open-sse/config/azureAi.ts b/open-sse/config/azureAi.ts index fedbb99054..3daea87a2e 100644 --- a/open-sse/config/azureAi.ts +++ b/open-sse/config/azureAi.ts @@ -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"}`; } diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index f7f76005f1..690bdea190 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -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); diff --git a/open-sse/executors/default/urlNormalizers.ts b/open-sse/executors/default/urlNormalizers.ts index a4a5ed4ae5..f2cf1a7294 100644 --- a/open-sse/executors/default/urlNormalizers.ts +++ b/open-sse/executors/default/urlNormalizers.ts @@ -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) { diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index 1b6e1c20df..ab6c06e477 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -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)); +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index f40cb6e5f3..b24c4b7cf5 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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) => ({ - 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") { diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 8db4086671..9cae225cf5 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -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 } + : {}), }; } diff --git a/src/lib/api/modelTestRunner.ts b/src/lib/api/modelTestRunner.ts index edac46a909..be5411e64a 100644 --- a/src/lib/api/modelTestRunner.ts +++ b/src/lib/api/modelTestRunner.ts @@ -124,7 +124,11 @@ async function findCustomModelMetadata(providerId: string, modelId: string) { } } -export function buildInternalChatRequest(testBody: Record, signal: AbortSignal) { +export function buildInternalChatRequest( + testBody: Record, + signal: AbortSignal, + connectionId?: string +) { return new Request(`${INTERNAL_ORIGIN}/v1/chat/completions`, { method: "POST", headers: { @@ -136,13 +140,18 @@ export function buildInternalChatRequest(testBody: Record, 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, signal: AbortSignal) { +export function buildInternalRerankRequest( + testBody: Record, + signal: AbortSignal, + connectionId?: string +) { return new Request(`${INTERNAL_ORIGIN}/v1/rerank`, { method: "POST", headers: { @@ -151,6 +160,7 @@ export function buildInternalRerankRequest(testBody: Record, 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; diff --git a/src/lib/providers/validation/cloudProviders.ts b/src/lib/providers/validation/cloudProviders.ts index 0000d8cfba..776aeb883e 100644 --- a/src/lib/providers/validation/cloudProviders.ts +++ b/src/lib/providers/validation/cloudProviders.ts @@ -290,6 +290,56 @@ export async function validateAzureOpenAIProvider({ apiKey, providerSpecificData return { valid: false, error: `Validation failed: ${response.status}` }; } +function extractTargetDeployments(psd: Record): 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" }; } - diff --git a/tests/unit/azureAiMultiModelTest.test.ts b/tests/unit/azureAiMultiModelTest.test.ts new file mode 100644 index 0000000000..de8be64ee3 --- /dev/null +++ b/tests/unit/azureAiMultiModelTest.test.ts @@ -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; + } +});