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 && (
-