Compare commits

...

7 Commits

Author SHA1 Message Date
Mauricio Siu
ee1a48e0b0 Merge branch 'canary' into copilot/fix-azure-openai-endpoint 2026-01-19 14:37:15 +01:00
copilot-swe-agent[bot]
2c8eb54d7b Add test for edge case: verify regex doesn't match partial path segments
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 06:56:14 +00:00
copilot-swe-agent[bot]
f314c2f161 Security fix: Use proper hostname validation to prevent URL substring attacks
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 06:54:35 +00:00
copilot-swe-agent[bot]
136edfd4c5 Address code review feedback: Improve URL normalization and remove duplication
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 06:48:47 +00:00
copilot-swe-agent[bot]
2f9987970a Refactor: Extract Azure URL normalization to shared utility function
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 06:46:40 +00:00
copilot-swe-agent[bot]
25cfe915d2 Add tests for Azure OpenAI URL normalization
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 06:44:30 +00:00
copilot-swe-agent[bot]
0af6dc3fc2 Fix Azure OpenAI endpoint double /v1 issue
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2025-12-20 06:43:20 +00:00
3 changed files with 190 additions and 36 deletions

View File

@@ -0,0 +1,81 @@
import { normalizeAzureUrl } from "@dokploy/server/utils/ai/select-ai-provider";
import { describe, expect, it } from "vitest";
/**
* Test for Azure OpenAI endpoint URL normalization
* These tests verify that Azure OpenAI URLs are properly cleaned up
* to remove duplicate /v1 paths that would cause API errors
*/
describe("Azure OpenAI URL Normalization", () => {
it("should strip /openai/v1 from Azure URL", () => {
const input = "https://workspacename.openai.azure.com/openai/v1";
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";
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/";
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";
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/";
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";
const apiUrl = normalizeAzureUrl(input);
const deploymentsUrl = `${apiUrl}/openai/deployments?api-version=2023-05-15`;
expect(deploymentsUrl).toBe(
"https://workspacename.openai.azure.com/openai/deployments?api-version=2023-05-15",
);
});
it("should not strip /v1 from middle of path", () => {
const input = "https://workspacename.openai.azure.com/v1/something";
const result = normalizeAzureUrl(input);
// Should only strip trailing /v1, not /v1 in the middle
expect(result).toBe("https://workspacename.openai.azure.com/v1/something");
});
it("should handle edge case with multiple trailing /v1", () => {
const input = "https://workspacename.openai.azure.com/openai/v1/v1";
const result = normalizeAzureUrl(input);
// Should only strip the last /v1
expect(result).toBe("https://workspacename.openai.azure.com/openai/v1");
});
it("should not strip partial matches in path segments", () => {
const input = "https://workspacename.openai.azure.com/myopenai/v1service";
const result = normalizeAzureUrl(input);
// Should not modify paths that don't end with /openai/v1 or /v1
expect(result).toBe(
"https://workspacename.openai.azure.com/myopenai/v1service",
);
});
});

View File

@@ -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";
@@ -58,16 +59,41 @@ export const aiRouter = createTRPCRouter({
const providerName = getProviderName(input.apiUrl);
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
let response = null;
let apiUrl = input.apiUrl;
// Validate API key for providers that require it
if (
providerName !== "ollama" &&
providerName !== "gemini" &&
!input.apiKey
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
});
}
switch (providerName) {
case "ollama":
response = await fetch(`${input.apiUrl}/api/tags`, { headers });
response = await fetch(`${apiUrl}/api/tags`, { headers });
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
`${apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
{ headers: {} },
);
break;
case "azure":
// Azure OpenAI uses deployments endpoint
// Normalize the URL to remove trailing /openai/v1 or /v1
apiUrl = normalizeAzureUrl(apiUrl);
// Azure uses deployments endpoint to list models
response = await fetch(
`${apiUrl}/openai/deployments?api-version=2023-05-15`,
{ headers },
);
break;
case "perplexity":
// Perplexity doesn't have a /models endpoint, return hardcoded list
return [
@@ -103,12 +129,7 @@ export const aiRouter = createTRPCRouter({
},
] as Model[];
default:
if (!input.apiKey)
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
});
response = await fetch(`${input.apiUrl}/models`, { headers });
response = await fetch(`${apiUrl}/models`, { headers });
}
if (!response.ok) {

View File

@@ -7,17 +7,44 @@ 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 {
// Use a single regex to handle all variations in one pass
// This matches: /openai/v1 or /v1 at the end, with optional trailing slash
const normalized = url.replace(/\/(?:openai\/v1|v1)\/?$/, "");
// Remove any remaining trailing slash
return normalized.replace(/\/$/, "");
}
export function getProviderName(apiUrl: string) {
if (apiUrl.includes("api.openai.com")) return "openai";
if (apiUrl.includes("azure.com")) return "azure";
if (apiUrl.includes("api.anthropic.com")) return "anthropic";
if (apiUrl.includes("api.cohere.ai")) return "cohere";
if (apiUrl.includes("api.perplexity.ai")) return "perplexity";
if (apiUrl.includes("api.mistral.ai")) return "mistral";
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini";
return "custom";
try {
const url = new URL(apiUrl);
const hostname = url.hostname.toLowerCase();
if (hostname === "api.openai.com") return "openai";
// Azure OpenAI uses *.openai.azure.com subdomain
if (
hostname.endsWith(".openai.azure.com") ||
hostname === "openai.azure.com"
)
return "azure";
if (hostname === "api.anthropic.com") return "anthropic";
if (hostname === "api.cohere.ai") return "cohere";
if (hostname === "api.perplexity.ai") return "perplexity";
if (hostname === "api.mistral.ai") return "mistral";
if (url.port === "11434" || hostname.includes("ollama")) return "ollama";
if (hostname === "api.deepinfra.com") return "deepinfra";
if (hostname === "generativelanguage.googleapis.com") return "gemini";
return "custom";
} catch {
// If URL parsing fails, treat as custom provider
// This is safe because custom providers still require valid authentication
return "custom";
}
}
export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
@@ -29,11 +56,16 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
apiKey: config.apiKey,
baseURL: config.apiUrl,
});
case "azure":
case "azure": {
// Azure OpenAI endpoints should not include /openai/v1 or /v1 at the end
// The SDK handles the path construction internally
const azureBaseUrl = normalizeAzureUrl(config.apiUrl);
return createAzure({
apiKey: config.apiKey,
baseURL: config.apiUrl,
baseURL: azureBaseUrl,
});
}
case "anthropic":
return createAnthropic({
apiKey: config.apiKey,
@@ -92,25 +124,45 @@ export const getProviderHeaders = (
apiUrl: string,
apiKey: string,
): Record<string, string> => {
// Anthropic
if (apiUrl.includes("anthropic")) {
try {
const url = new URL(apiUrl);
const hostname = url.hostname.toLowerCase();
// Azure OpenAI uses *.openai.azure.com subdomain
if (
hostname.endsWith(".openai.azure.com") ||
hostname === "openai.azure.com"
) {
return {
"api-key": apiKey,
};
}
// Anthropic
if (hostname === "api.anthropic.com") {
return {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
};
}
// Mistral
if (hostname === "api.mistral.ai") {
return {
Authorization: apiKey,
};
}
// Default (OpenAI style)
return {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
Authorization: `Bearer ${apiKey}`,
};
} catch {
// Fallback to OpenAI-style headers if URL parsing fails
return {
Authorization: `Bearer ${apiKey}`,
};
}
// Mistral
if (apiUrl.includes("mistral")) {
return {
Authorization: apiKey,
};
}
// Default (OpenAI style)
return {
Authorization: `Bearer ${apiKey}`,
};
};
export interface Model {
id: string;