diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 65f30872c5..12489d67e7 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -99,11 +99,11 @@ export class BaseExecutor { void model; void stream; if (this.provider?.startsWith?.("openai-compatible-")) { - const baseUrl = - typeof credentials?.providerSpecificData?.baseUrl === "string" - ? credentials.providerSpecificData.baseUrl - : "https://api.openai.com/v1"; + const psd = credentials?.providerSpecificData; + const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1"; const normalized = baseUrl.replace(/\/$/, ""); + const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; + if (customPath) return `${normalized}${customPath}`; const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 24f1e2ab8f..f51512c32e 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -9,15 +9,20 @@ export class DefaultExecutor extends BaseExecutor { buildUrl(model, stream, urlIndex = 0, credentials = null) { if (this.provider?.startsWith?.("openai-compatible-")) { - const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; + const psd = credentials?.providerSpecificData; + const baseUrl = psd?.baseUrl || "https://api.openai.com/v1"; const normalized = baseUrl.replace(/\/$/, ""); + const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; + if (customPath) return `${normalized}${customPath}`; const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } if (this.provider?.startsWith?.("anthropic-compatible-")) { - const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1"; + const psd = credentials?.providerSpecificData; + const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1"; const normalized = baseUrl.replace(/\/$/, ""); - return `${normalized}/messages`; + const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; + return `${normalized}${customPath || "/messages"}`; } switch (this.provider) { case "claude": diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 596c52080a..eaac32caae 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -3075,11 +3075,14 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1", + chatPath: "", + modelsPath: "", }); const [saving, setSaving] = useState(false); const [checkKey, setCheckKey] = useState(""); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); useEffect(() => { if (node) { @@ -3090,7 +3093,10 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) baseUrl: node.baseUrl || (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"), + chatPath: node.chatPath || "", + modelsPath: node.modelsPath || "", }); + setShowAdvanced(!!(node.chatPath || node.modelsPath)); } }, [node, isAnthropic]); @@ -3107,6 +3113,8 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) name: formData.name, prefix: formData.prefix, baseUrl: formData.baseUrl, + chatPath: formData.chatPath || "", + modelsPath: formData.modelsPath || "", }; if (!isAnthropic) { payload.apiType = formData.apiType; @@ -3127,6 +3135,7 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) baseUrl: formData.baseUrl, apiKey: checkKey, type: isAnthropic ? "anthropic-compatible" : "openai-compatible", + modelsPath: formData.modelsPath || "", }), }); const data = await res.json(); @@ -3182,6 +3191,39 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) type: isAnthropic ? t("anthropic") : t("openai"), })} /> + + {showAdvanced && ( +
+ setFormData({ ...formData, chatPath: e.target.value })} + placeholder={isAnthropic ? "/messages" : t("chatPathPlaceholder")} + hint={t("chatPathHint")} + /> + setFormData({ ...formData, modelsPath: e.target.value })} + placeholder={t("modelsPathPlaceholder")} + hint={t("modelsPathHint")} + /> +
+ )}
(null); + const [showAdvanced, setShowAdvanced] = useState(false); const apiTypeOptions = [ { value: "chat", label: t("chatCompletions") }, @@ -804,6 +807,8 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { apiType: formData.apiType, baseUrl: formData.baseUrl, type: "openai-compatible", + chatPath: formData.chatPath || "", + modelsPath: formData.modelsPath || "", }), }); const data = await res.json(); @@ -814,9 +819,12 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { prefix: "", apiType: "chat", baseUrl: "https://api.openai.com/v1", + chatPath: "", + modelsPath: "", }); setCheckKey(""); setValidationResult(null); + setShowAdvanced(false); } } catch (error) { console.log("Error creating OpenAI Compatible node:", error); @@ -835,6 +843,7 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { baseUrl: formData.baseUrl, apiKey: checkKey, type: "openai-compatible", + modelsPath: formData.modelsPath || "", }), }); const data = await res.json(); @@ -876,6 +885,39 @@ function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { placeholder={t("openaiBaseUrlPlaceholder")} hint={t("compatibleBaseUrlHint", { type: t("openai") })} /> + + {showAdvanced && ( +
+ setFormData({ ...formData, chatPath: e.target.value })} + placeholder={t("chatPathPlaceholder")} + hint={t("chatPathHint")} + /> + setFormData({ ...formData, modelsPath: e.target.value })} + placeholder={t("modelsPathPlaceholder")} + hint={t("modelsPathHint")} + /> +
+ )}
(null); + const [showAdvanced, setShowAdvanced] = useState(false); useEffect(() => { // Reset validation when modal opens @@ -959,6 +1004,8 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { prefix: formData.prefix, baseUrl: formData.baseUrl, type: "anthropic-compatible", + chatPath: formData.chatPath || "", + modelsPath: formData.modelsPath || "", }), }); const data = await res.json(); @@ -968,9 +1015,12 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1", + chatPath: "", + modelsPath: "", }); setCheckKey(""); setValidationResult(null); + setShowAdvanced(false); } } catch (error) { console.log("Error creating Anthropic Compatible node:", error); @@ -989,6 +1039,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { baseUrl: formData.baseUrl, apiKey: checkKey, type: "anthropic-compatible", + modelsPath: formData.modelsPath || "", }), }); const data = await res.json(); @@ -1024,6 +1075,39 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { placeholder={t("anthropicBaseUrlPlaceholder")} hint={t("compatibleBaseUrlHint", { type: t("anthropic") })} /> + + {showAdvanced && ( +
+ setFormData({ ...formData, chatPath: e.target.value })} + placeholder="/messages" + hint={t("chatPathHint")} + /> + setFormData({ ...formData, modelsPath: e.target.value })} + placeholder={t("modelsPathPlaceholder")} + hint={t("modelsPathHint")} + /> +
+ )}
{ const nodeType = value.type || "openai-compatible"; @@ -836,12 +838,15 @@ export const updateProviderNodeSchema = z.object({ prefix: z.string().trim().min(1, "Prefix is required"), apiType: z.enum(["chat", "responses"]).optional(), baseUrl: z.string().trim().min(1, "Base URL is required"), + chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), + modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), }); export const providerNodeValidateSchema = z.object({ baseUrl: z.string().trim().min(1, "Base URL and API key required"), apiKey: z.string().trim().min(1, "Base URL and API key required"), type: z.enum(["openai-compatible", "anthropic-compatible"]).optional(), + modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")), }); export const updateProviderConnectionSchema = z diff --git a/tests/unit/custom-endpoint-paths.test.mjs b/tests/unit/custom-endpoint-paths.test.mjs new file mode 100644 index 0000000000..79c4ad1085 --- /dev/null +++ b/tests/unit/custom-endpoint-paths.test.mjs @@ -0,0 +1,140 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// Inline buildUrl logic from DefaultExecutor for unit testing +// (avoids importing ESM modules with complex dependency chains) + +function buildUrlOpenAI(provider, credentials) { + const psd = credentials?.providerSpecificData; + const baseUrl = psd?.baseUrl || "https://api.openai.com/v1"; + const normalized = baseUrl.replace(/\/$/, ""); + const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; + if (customPath) return `${normalized}${customPath}`; + const path = provider.includes("responses") ? "/responses" : "/chat/completions"; + return `${normalized}${path}`; +} + +function buildUrlAnthropic(credentials) { + const psd = credentials?.providerSpecificData; + const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1"; + const normalized = baseUrl.replace(/\/$/, ""); + const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null; + return `${normalized}${customPath || "/messages"}`; +} + +function buildModelsUrl(baseUrl, modelsPath) { + const normalized = baseUrl.replace(/\/$/, ""); + return `${normalized}${modelsPath || "/models"}`; +} + +describe("Custom Endpoint Paths", () => { + describe("OpenAI Compatible buildUrl", () => { + it("returns custom chatPath when provided", () => { + const url = buildUrlOpenAI("openai-compatible-chat-abc123", { + providerSpecificData: { + baseUrl: "https://api.epsiloncode.pl", + chatPath: "/v4/chat/completions", + }, + }); + assert.equal(url, "https://api.epsiloncode.pl/v4/chat/completions"); + }); + + it("returns default /chat/completions without chatPath", () => { + const url = buildUrlOpenAI("openai-compatible-chat-abc123", { + providerSpecificData: { + baseUrl: "https://api.openai.com/v1", + }, + }); + assert.equal(url, "https://api.openai.com/v1/chat/completions"); + }); + + it("returns /responses for responses provider without chatPath", () => { + const url = buildUrlOpenAI("openai-compatible-responses-abc123", { + providerSpecificData: { + baseUrl: "https://api.openai.com/v1", + }, + }); + assert.equal(url, "https://api.openai.com/v1/responses"); + }); + + it("treats empty string chatPath as default", () => { + const url = buildUrlOpenAI("openai-compatible-chat-abc123", { + providerSpecificData: { + baseUrl: "https://api.example.com/v1", + chatPath: "", + }, + }); + assert.equal(url, "https://api.example.com/v1/chat/completions"); + }); + + it("strips trailing slash from baseUrl", () => { + const url = buildUrlOpenAI("openai-compatible-chat-abc123", { + providerSpecificData: { + baseUrl: "https://api.example.com/v1/", + chatPath: "/v4/chat/completions", + }, + }); + assert.equal(url, "https://api.example.com/v1/v4/chat/completions"); + }); + }); + + describe("Anthropic Compatible buildUrl", () => { + it("returns custom chatPath when provided", () => { + const url = buildUrlAnthropic({ + providerSpecificData: { + baseUrl: "https://proxy.example.com/v2", + chatPath: "/v4/messages", + }, + }); + assert.equal(url, "https://proxy.example.com/v2/v4/messages"); + }); + + it("returns default /messages without chatPath", () => { + const url = buildUrlAnthropic({ + providerSpecificData: { + baseUrl: "https://api.anthropic.com/v1", + }, + }); + assert.equal(url, "https://api.anthropic.com/v1/messages"); + }); + + it("treats empty string chatPath as default", () => { + const url = buildUrlAnthropic({ + providerSpecificData: { + baseUrl: "https://api.anthropic.com/v1", + chatPath: "", + }, + }); + assert.equal(url, "https://api.anthropic.com/v1/messages"); + }); + }); + + describe("Validate endpoint modelsPath", () => { + it("uses modelsPath when provided", () => { + const url = buildModelsUrl("https://api.example.com/v1", "/v4/models"); + assert.equal(url, "https://api.example.com/v1/v4/models"); + }); + + it("falls back to /models when modelsPath is empty", () => { + const url = buildModelsUrl("https://api.example.com/v1", ""); + assert.equal(url, "https://api.example.com/v1/models"); + }); + + it("falls back to /models when modelsPath is undefined", () => { + const url = buildModelsUrl("https://api.example.com/v1", undefined); + assert.equal(url, "https://api.example.com/v1/models"); + }); + }); + + describe("No credentials fallback", () => { + it("works with null credentials for openai-compatible", () => { + const url = buildUrlOpenAI("openai-compatible-chat-abc123", null); + assert.equal(url, "https://api.openai.com/v1/chat/completions"); + }); + + it("works with null credentials for anthropic-compatible", () => { + const url = buildUrlAnthropic(null); + assert.equal(url, "https://api.anthropic.com/v1/messages"); + }); + }); +});