diff --git a/apps/dokploy/__test__/utils/azure-ai-provider.test.ts b/apps/dokploy/__test__/utils/azure-ai-provider.test.ts index 2f44cc34d..9051c1c24 100644 --- a/apps/dokploy/__test__/utils/azure-ai-provider.test.ts +++ b/apps/dokploy/__test__/utils/azure-ai-provider.test.ts @@ -1,3 +1,4 @@ +import { normalizeAzureUrl } from "@dokploy/server/utils/ai/select-ai-provider"; import { describe, expect, it } from "vitest"; /** @@ -8,60 +9,42 @@ import { describe, expect, it } from "vitest"; describe("Azure OpenAI URL Normalization", () => { it("should strip /openai/v1 from Azure URL", () => { const input = "https://workspacename.openai.azure.com/openai/v1"; - let result = input; - result = result.replace(/\/openai\/v1\/?$/, ""); - result = result.replace(/\/v1\/?$/, ""); - result = result.replace(/\/$/, ""); + const result = normalizeAzureUrl(input); expect(result).toBe("https://workspacename.openai.azure.com"); }); it("should strip /v1 from Azure URL", () => { const input = "https://workspacename.openai.azure.com/v1"; - let result = input; - result = result.replace(/\/openai\/v1\/?$/, ""); - result = result.replace(/\/v1\/?$/, ""); - result = result.replace(/\/$/, ""); + const result = normalizeAzureUrl(input); expect(result).toBe("https://workspacename.openai.azure.com"); }); it("should strip trailing slash from Azure URL", () => { const input = "https://workspacename.openai.azure.com/"; - let result = input; - result = result.replace(/\/openai\/v1\/?$/, ""); - result = result.replace(/\/v1\/?$/, ""); - result = result.replace(/\/$/, ""); + const result = normalizeAzureUrl(input); expect(result).toBe("https://workspacename.openai.azure.com"); }); it("should handle clean Azure URL without modification", () => { const input = "https://workspacename.openai.azure.com"; - let result = input; - result = result.replace(/\/openai\/v1\/?$/, ""); - result = result.replace(/\/v1\/?$/, ""); - result = result.replace(/\/$/, ""); + const result = normalizeAzureUrl(input); expect(result).toBe("https://workspacename.openai.azure.com"); }); it("should strip /openai/v1/ with trailing slash", () => { const input = "https://workspacename.openai.azure.com/openai/v1/"; - let result = input; - result = result.replace(/\/openai\/v1\/?$/, ""); - result = result.replace(/\/v1\/?$/, ""); - result = result.replace(/\/$/, ""); + const result = normalizeAzureUrl(input); expect(result).toBe("https://workspacename.openai.azure.com"); }); it("should build correct deployments endpoint for Azure", () => { const input = "https://workspacename.openai.azure.com/openai/v1"; - let apiUrl = input; - apiUrl = apiUrl.replace(/\/openai\/v1\/?$/, ""); - apiUrl = apiUrl.replace(/\/v1\/?$/, ""); - apiUrl = apiUrl.replace(/\/$/, ""); + const apiUrl = normalizeAzureUrl(input); const deploymentsUrl = `${apiUrl}/openai/deployments?api-version=2023-05-15`; @@ -72,10 +55,7 @@ describe("Azure OpenAI URL Normalization", () => { it("should not strip /v1 from middle of path", () => { const input = "https://workspacename.openai.azure.com/v1/something"; - let result = input; - result = result.replace(/\/openai\/v1\/?$/, ""); - result = result.replace(/\/v1\/?$/, ""); - result = result.replace(/\/$/, ""); + const result = normalizeAzureUrl(input); // Should only strip trailing /v1, not /v1 in the middle expect(result).toBe("https://workspacename.openai.azure.com/v1/something"); diff --git a/apps/dokploy/server/api/routers/ai.ts b/apps/dokploy/server/api/routers/ai.ts index 96ce5e721..13d3226c2 100644 --- a/apps/dokploy/server/api/routers/ai.ts +++ b/apps/dokploy/server/api/routers/ai.ts @@ -26,6 +26,7 @@ import { getProviderHeaders, getProviderName, type Model, + normalizeAzureUrl, } from "@dokploy/server/utils/ai/select-ai-provider"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -72,10 +73,8 @@ export const aiRouter = createTRPCRouter({ break; case "azure": // Azure OpenAI uses deployments endpoint - // Remove trailing /openai/v1 or /v1 if present - apiUrl = apiUrl.replace(/\/openai\/v1\/?$/, ""); - apiUrl = apiUrl.replace(/\/v1\/?$/, ""); - apiUrl = apiUrl.replace(/\/$/, ""); + // Normalize the URL to remove trailing /openai/v1 or /v1 + apiUrl = normalizeAzureUrl(apiUrl); if (!input.apiKey) throw new TRPCError({ diff --git a/packages/server/src/utils/ai/select-ai-provider.ts b/packages/server/src/utils/ai/select-ai-provider.ts index b204564f7..a7f2fe117 100644 --- a/packages/server/src/utils/ai/select-ai-provider.ts +++ b/packages/server/src/utils/ai/select-ai-provider.ts @@ -7,6 +7,22 @@ import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createOllama } from "ai-sdk-ollama"; +/** + * Normalize Azure OpenAI base URL by removing trailing /openai/v1 or /v1 paths + * Azure OpenAI SDK handles path construction internally, so these need to be stripped + * to avoid duplicate paths in the final URL (e.g., /v1/v1/chat/completions) + */ +export function normalizeAzureUrl(url: string): string { + let normalized = url; + // Remove trailing /openai/v1 if present + normalized = normalized.replace(/\/openai\/v1\/?$/, ""); + // Remove trailing /v1 if present + normalized = normalized.replace(/\/v1\/?$/, ""); + // Remove trailing slash + normalized = normalized.replace(/\/$/, ""); + return normalized; +} + export function getProviderName(apiUrl: string) { if (apiUrl.includes("api.openai.com")) return "openai"; if (apiUrl.includes("azure.com")) return "azure"; @@ -32,13 +48,7 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) { case "azure": { // Azure OpenAI endpoints should not include /openai/v1 or /v1 at the end // The SDK handles the path construction internally - let azureBaseUrl = config.apiUrl; - // Remove trailing /openai/v1 if present - azureBaseUrl = azureBaseUrl.replace(/\/openai\/v1\/?$/, ""); - // Remove trailing /v1 if present - azureBaseUrl = azureBaseUrl.replace(/\/v1\/?$/, ""); - // Remove trailing slash - azureBaseUrl = azureBaseUrl.replace(/\/$/, ""); + const azureBaseUrl = normalizeAzureUrl(config.apiUrl); return createAzure({ apiKey: config.apiKey,