From b3cf3fdef0d66772fb4099e6f15fe83979a8b06f Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 1 Oct 2026 09:56:40 +0700 Subject: [PATCH] feat(providers): per-provider custom header overrides from the registry Custom Headers card on the provider detail page pre-fills the registry transport headers; only rows the user changes are stored (settings. providerOverrides) and merged last at dispatch, so code-side registry bumps keep winning for untouched rows. Header name/value validation and a blocked list (Host, Authorization, Cookie, ...) gate the API route. Co-Authored-By: Claude Code --- open-sse/executors/base.js | 4 +- open-sse/handlers/chatCore.js | 4 +- .../providers/[id]/CustomConfigCard.js | 181 ++++++++++++++++++ .../dashboard/providers/[id]/page.js | 4 + src/app/api/providers/[id]/overrides/route.js | 110 +++++++++++ src/lib/db/repos/settingsRepo.js | 2 + src/sse/handlers/chat.js | 2 + 7 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/CustomConfigCard.js create mode 100644 src/app/api/providers/[id]/overrides/route.js diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 18a62229..71e26deb 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -97,7 +97,7 @@ export class BaseExecutor { return { status: response.status, message: bodyText || `HTTP ${response.status}` }; } - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null, providerOverrides = null }) { const fallbackCount = this.getFallbackCount(); let lastError = null; let lastStatus = 0; @@ -128,6 +128,8 @@ export class BaseExecutor { const url = this.buildUrl(model, stream, urlIndex, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); const headers = this.buildHeaders(credentials, stream, url, model, transformedBody); + // User per-provider override wins over registry headers (blocked names filtered at the API) + if (providerOverrides?.headers) Object.assign(headers, providerOverrides.headers); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 8755659d..60d3d815 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -60,7 +60,7 @@ export function stripContinuityFields(body) { return body; } -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, providerOverrides }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); // Stable per-session color so all lines of one CLI conversation share a tag @@ -383,6 +383,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred signal: streamController.signal, log, proxyOptions, + providerOverrides, }); providerResponse = result.response; providerUrl = result.url; @@ -451,6 +452,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred signal: streamController.signal, log, proxyOptions, + providerOverrides, }); if (retryResult.response.ok) { providerResponse = retryResult.response; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CustomConfigCard.js b/src/app/(dashboard)/dashboard/providers/[id]/CustomConfigCard.js new file mode 100644 index 00000000..119dd1be --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/CustomConfigCard.js @@ -0,0 +1,181 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { Card, Badge } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; + +// Mirrors the server-side gate in /api/providers/[id]/overrides — client check is UX only +const BLOCKED_HEADERS = ["host", "content-length", "content-type", "connection", "transfer-encoding", "authorization", "cookie"]; +const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/; + +export default function CustomConfigCard({ providerId }) { + const notify = useNotificationStore(); + const [expanded, setExpanded] = useState(false); + const [rows, setRows] = useState([{ name: "", value: "" }]); + const [builtin, setBuiltin] = useState({}); + const [hasOverride, setHasOverride] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + fetch(`/api/providers/${providerId}/overrides`, { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled || !data) return; + // Effective set = registry built-ins with user overrides layered on top + const builtinHeaders = data.builtinHeaders || {}; + const effective = { ...builtinHeaders, ...(data.headers || {}) }; + const headerRows = Object.entries(effective).map(([name, value]) => ({ name, value })); + setBuiltin(builtinHeaders); + setRows(headerRows.length ? headerRows : [{ name: "", value: "" }]); + setHasOverride(Object.keys(data.headers || {}).length > 0); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [providerId]); + + const setRow = (i, field, value) => { + setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, [field]: value } : r))); + }; + + const save = useCallback(async () => { + // Diff-on-save: only rows differing from the registry default become overrides, + // so a code-side registry bump still wins for everything the user left alone. + const headers = {}; + for (const r of rows.filter((r) => r.name.trim())) { + const name = r.name.trim(); + if (!HEADER_NAME_RE.test(name)) { + notify.error(`Invalid header name: ${name}`); + return; + } + if (BLOCKED_HEADERS.includes(name.toLowerCase())) { + notify.error(`Header ${name} cannot be overridden`); + return; + } + if (name in headers) { + notify.error(`Duplicate header name: ${name}`); + return; + } + if (r.value !== builtin[name]) headers[name] = r.value; + } + + setSaving(true); + try { + const res = await fetch(`/api/providers/${providerId}/overrides`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ headers }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + notify.error(err.error || "Failed to save"); + return; + } + setHasOverride(Object.keys(headers).length > 0); + notify.success("Custom headers saved"); + } finally { + setSaving(false); + } + }, [rows, builtin, providerId, notify]); + + const resetToBuiltin = () => { + setRows(Object.entries(builtin).map(([name, value]) => ({ name, value }))); + }; + + // Only render when there is something to customize: registry headers or existing overrides + if (Object.keys(builtin).length === 0 && !hasOverride) return null; + + return ( + + + + {expanded && ( +
+
+ {rows.map((row, i) => { + const overridden = row.name.trim() in builtin && row.value !== builtin[row.name.trim()]; + return ( +
+ setRow(i, "name", e.target.value)} + placeholder="Header-Name" + spellCheck={false} + className="w-44 rounded-md border border-border bg-background px-2 py-1.5 text-sm focus:border-primary focus:outline-none" + /> + setRow(i, "value", e.target.value)} + placeholder="Value" + spellCheck={false} + title={overridden ? "Overridden" : row.name.trim() in builtin ? "Registry default" : ""} + className={`min-w-0 flex-1 rounded-md border bg-background px-2 py-1.5 text-sm focus:border-primary focus:outline-none ${ + overridden ? "border-amber-400/60" : "border-border" + }`} + /> + +
+ ); + })} +
+ +
+ +
+ + +
+
+
+ )} +
+ ); +} + +CustomConfigCard.propTypes = { + providerId: PropTypes.string.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 61b416ef..c861e900 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -23,6 +23,7 @@ import EditCompatibleNodeModal from "./EditCompatibleNodeModal"; import AddCustomModelModal from "./AddCustomModelModal"; import BulkImportCodexModal from "./BulkImportCodexModal"; import BulkImportGrokCliModal from "./BulkImportGrokCliModal"; +import CustomConfigCard from "./CustomConfigCard"; const ONE_BY_ONE_DELAY_MS = 1000; @@ -1743,6 +1744,9 @@ export default function ProviderDetailPage() { )} + {/* Per-provider user overrides (custom headers / connect timeout) */} + + {/* Models */}
diff --git a/src/app/api/providers/[id]/overrides/route.js b/src/app/api/providers/[id]/overrides/route.js new file mode 100644 index 00000000..f767da85 --- /dev/null +++ b/src/app/api/providers/[id]/overrides/route.js @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server"; +import { getSettings, updateSettings } from "@/lib/localDb"; +import { PROVIDERS } from "open-sse/config/providers.js"; +import { resolveProviderAlias } from "open-sse/services/model.js"; + +export const dynamic = "force-dynamic"; + +// Validation at the trust boundary — the UI also validates, but this is the gate. +const MAX_HEADERS = 20; +const MAX_HEADER_VALUE_LENGTH = 8192; +// RFC 7230 token subset: letters, digits, hyphen (no spaces, no unicode) +const HEADER_NAME_RE = /^[A-Za-z0-9-]+$/; +// Request-structure / auth headers a user override must never touch +const BLOCKED_HEADERS = new Set([ + "host", + "content-length", + "content-type", + "connection", + "transfer-encoding", + "authorization", + "cookie", +]); + +/** + * Validate + normalize an override payload. Returns { override } or { error }. + * An override with no headers is normalized to null (= delete). + */ +function normalizeOverride({ headers }) { + const out = {}; + + if (headers !== undefined && headers !== null) { + if (typeof headers !== "object" || Array.isArray(headers)) { + return { error: "headers must be an object" }; + } + const entries = Object.entries(headers).filter(([, v]) => v !== "" && v != null); + if (entries.length > MAX_HEADERS) { + return { error: `Too many headers (max ${MAX_HEADERS})` }; + } + const clean = {}; + for (const [name, value] of entries) { + if (!HEADER_NAME_RE.test(name)) { + return { error: `Invalid header name: ${name}` }; + } + if (typeof value !== "string" || /[\r\n]/.test(value)) { + return { error: `Invalid value for header ${name}` }; + } + if (value.length > MAX_HEADER_VALUE_LENGTH) { + return { error: `Header ${name} value too long (max ${MAX_HEADER_VALUE_LENGTH})` }; + } + if (BLOCKED_HEADERS.has(name.toLowerCase())) { + return { error: `Header ${name} cannot be overridden` }; + } + clean[name] = value; + } + if (Object.keys(clean).length) out.headers = clean; + } + + return { override: Object.keys(out).length ? out : null }; +} + +async function readOverrides() { + const settings = await getSettings(); + return settings.providerOverrides || {}; +} + +/** + * GET /api/providers/[id]/overrides — user override for this provider + */ +export async function GET(request, { params }) { + try { + const { id } = await params; + // URL may use an alias (gcli, cc…) — key everything by canonical registry id + const canonical = resolveProviderAlias(id); + const override = (await readOverrides())[canonical] || {}; + // Built-in headers come straight from the registry transport — single source of + // truth, so the UI pre-fills exactly what this provider sends upstream. + return NextResponse.json({ + headers: override.headers || {}, + builtinHeaders: PROVIDERS[canonical]?.headers || {}, + }); + } catch (error) { + console.log("Error getting provider overrides:", error); + return NextResponse.json({ error: "Failed to get overrides" }, { status: 500 }); + } +} + +/** + * PUT /api/providers/[id]/overrides — body: { headers: {name: value} } + * Empty payload clears the override. + */ +export async function PUT(request, { params }) { + try { + const { id } = await params; + const canonical = resolveProviderAlias(id); + const body = await request.json().catch(() => ({})); + const { override, error } = normalizeOverride(body); + if (error) { + return NextResponse.json({ error }, { status: 400 }); + } + const current = await readOverrides(); + const next = { ...current }; + if (override) next[canonical] = override; + else delete next[canonical]; + await updateSettings({ providerOverrides: next }); + return NextResponse.json({ headers: override?.headers || {} }); + } catch (error) { + console.log("Error saving provider overrides:", error); + return NextResponse.json({ error: "Failed to save overrides" }, { status: 500 }); + } +} diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 8b8023f0..60afee27 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -62,6 +62,8 @@ const DEFAULT_SETTINGS = { pxpipeAutoInstall: true, pxpipeMinChars: 25000, pxpipeTimeoutMs: 15000, + // Per-provider user header overrides applied at dispatch: { [providerId]: { headers: {..} } } + providerOverrides: {}, }; async function readRaw() { diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index f7070f9a..bbd89242 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -293,6 +293,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null, onPxpipeEvent: appendPxpipeEvent, providerThinking, + // Per-provider user overrides (custom headers / connect timeout) from settings + providerOverrides: (chatSettings.providerOverrides || {})[provider] || null, // Detect source format by endpoint + body sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null, onCredentialsRefreshed: async (newCreds) => {