From e25029939d006f7267630f7f11eed06cadb5da56 Mon Sep 17 00:00:00 2001 From: Regis <92858615+Regis-RCR@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:21:35 +0100 Subject: [PATCH 1/2] feat(api): add custom endpoint paths for compatible provider nodes Allow provider_nodes to configure custom chat and models endpoint paths via chatPath/modelsPath fields. This enables providers with non-standard versioned APIs (e.g. /v4/chat/completions) to work without embedding the version prefix in base_url. - Add migration 003: chat_path and models_path columns - Update Zod schemas (create, update, validate) - Update CRUD in providers.ts (INSERT/UPDATE) - Wire chatPath/modelsPath through API routes and providerSpecificData cascade - Read chatPath in DefaultExecutor and BaseExecutor buildUrl() - Use modelsPath in validate endpoint - Add Advanced Settings UI section (collapsible) in create/edit modals - Update base URL hint to reference Advanced Settings - Add i18n keys across all 30 locales - Add unit tests for buildUrl with custom paths Backward compatible: NULL chatPath/modelsPath = default behavior. --- open-sse/executors/base.ts | 8 +- open-sse/executors/default.ts | 11 +- .../dashboard/providers/[id]/page.tsx | 37 +++++ .../(dashboard)/dashboard/providers/page.tsx | 70 +++++++++ src/app/api/provider-nodes/[id]/route.ts | 11 +- src/app/api/provider-nodes/route.ts | 6 +- src/app/api/provider-nodes/validate/route.ts | 6 +- src/app/api/providers/route.ts | 4 + src/i18n/messages/ar.json | 11 +- src/i18n/messages/bg.json | 11 +- src/i18n/messages/da.json | 11 +- src/i18n/messages/de.json | 11 +- src/i18n/messages/en.json | 11 +- src/i18n/messages/es.json | 11 +- src/i18n/messages/fi.json | 11 +- src/i18n/messages/fr.json | 11 +- src/i18n/messages/he.json | 11 +- src/i18n/messages/hu.json | 11 +- src/i18n/messages/id.json | 11 +- src/i18n/messages/in.json | 11 +- src/i18n/messages/it.json | 11 +- src/i18n/messages/ja.json | 11 +- src/i18n/messages/ko.json | 11 +- src/i18n/messages/ms.json | 11 +- src/i18n/messages/nl.json | 11 +- src/i18n/messages/no.json | 11 +- src/i18n/messages/phi.json | 11 +- src/i18n/messages/pl.json | 11 +- src/i18n/messages/pt-BR.json | 11 +- src/i18n/messages/pt.json | 11 +- src/i18n/messages/ro.json | 11 +- src/i18n/messages/ru.json | 11 +- src/i18n/messages/sk.json | 11 +- src/i18n/messages/sv.json | 11 +- src/i18n/messages/th.json | 11 +- src/i18n/messages/uk-UA.json | 11 +- src/i18n/messages/vi.json | 11 +- src/i18n/messages/zh-CN.json | 11 +- .../003_provider_node_custom_paths.sql | 5 + src/lib/db/providers.ts | 11 +- src/shared/validation/schemas.ts | 5 + tests/unit/custom-endpoint-paths.test.mjs | 140 ++++++++++++++++++ 42 files changed, 569 insertions(+), 75 deletions(-) create mode 100644 src/lib/db/migrations/003_provider_node_custom_paths.sql create mode 100644 tests/unit/custom-endpoint-paths.test.mjs 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..b50be5bcdb 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,32 @@ 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,32 @@ 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 +997,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 +1008,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 +1032,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { baseUrl: formData.baseUrl, apiKey: checkKey, type: "anthropic-compatible", + modelsPath: formData.modelsPath || "", }), }); const data = await res.json(); @@ -1024,6 +1068,32 @@ 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"); + }); + }); +}); From cd05e03d638f17f912500b6619168022733a9082 Mon Sep 17 00:00:00 2001 From: Regis <92858615+Regis-RCR@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:29:06 +0100 Subject: [PATCH 2/2] fix(review): simplify cascade logic and add ARIA attributes Address review feedback: - Simplify providerSpecificData cascade for chatPath/modelsPath using `|| undefined` instead of conditional spreads (Gemini) - Add aria-expanded, aria-controls, aria-hidden to Advanced Settings toggle buttons for accessibility (Copilot) --- .../dashboard/providers/[id]/page.tsx | 11 ++++++++-- .../(dashboard)/dashboard/providers/page.tsx | 22 +++++++++++++++---- src/app/api/provider-nodes/[id]/route.ts | 9 ++------ 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index b50be5bcdb..eaac32caae 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -3195,12 +3195,19 @@ function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) type="button" className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1" onClick={() => setShowAdvanced(!showAdvanced)} + aria-expanded={showAdvanced} + aria-controls="advanced-settings" > - + {t("advancedSettings")} {showAdvanced && ( -
+
setShowAdvanced(!showAdvanced)} + aria-expanded={showAdvanced} + aria-controls="advanced-settings" > - + {t("advancedSettings")} {showAdvanced && ( -
+
setShowAdvanced(!showAdvanced)} + aria-expanded={showAdvanced} + aria-controls="advanced-settings" > - + {t("advancedSettings")} {showAdvanced && ( -
+