mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 04:35:24 +02:00
Compare commits
7 Commits
v0.26.6
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee1a48e0b0 | ||
|
|
2c8eb54d7b | ||
|
|
f314c2f161 | ||
|
|
136edfd4c5 | ||
|
|
2f9987970a | ||
|
|
25cfe915d2 | ||
|
|
0af6dc3fc2 |
81
apps/dokploy/__test__/utils/azure-ai-provider.test.ts
Normal file
81
apps/dokploy/__test__/utils/azure-ai-provider.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user