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 <noreply@anthropic.com>
This commit is contained in:
decoluaandClaude Code committed 2026-10-01 09:56:40 +07:00
1 parent 2880980709
commit b3cf3fdef0
7 files changed
+305 -2

No files matched your search

+3 -1
View File
@@ -97,7 +97,7 @@ export class BaseExecutor {
return { status: response.status, message: bodyText || `HTTP ${response.status}` }; 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(); const fallbackCount = this.getFallbackCount();
let lastError = null; let lastError = null;
let lastStatus = 0; let lastStatus = 0;
@@ -128,6 +128,8 @@ export class BaseExecutor {
const url = this.buildUrl(model, stream, urlIndex, credentials); const url = this.buildUrl(model, stream, urlIndex, credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials);
const headers = this.buildHeaders(credentials, stream, url, model, transformedBody); 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; if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
+3 -1
View File
@@ -60,7 +60,7 @@ export function stripContinuityFields(body) {
return 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 { provider, model } = modelInfo;
const requestStartTime = Date.now(); const requestStartTime = Date.now();
// Stable per-session color so all lines of one CLI conversation share a tag // 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, signal: streamController.signal,
log, log,
proxyOptions, proxyOptions,
providerOverrides,
}); });
providerResponse = result.response; providerResponse = result.response;
providerUrl = result.url; providerUrl = result.url;
@@ -451,6 +452,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
signal: streamController.signal, signal: streamController.signal,
log, log,
proxyOptions, proxyOptions,
providerOverrides,
}); });
if (retryResult.response.ok) { if (retryResult.response.ok) {
providerResponse = retryResult.response; providerResponse = retryResult.response;
@@ -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 (
<Card padding="xs">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center justify-between text-left"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">tune</span>
<span className="text-sm font-semibold">Custom Headers</span>
{hasOverride && (
<Badge variant="success" size="sm">Active</Badge>
)}
</div>
<span className="material-symbols-outlined text-text-muted">
{expanded ? "expand_less" : "expand_more"}
</span>
</button>
{expanded && (
<div className="mt-3 border-t border-border pt-3">
<div className="flex flex-col gap-2">
{rows.map((row, i) => {
const overridden = row.name.trim() in builtin && row.value !== builtin[row.name.trim()];
return (
<div key={i} className="flex items-center gap-2">
<input
value={row.name}
onChange={(e) => 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"
/>
<input
value={row.value}
onChange={(e) => 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"
}`}
/>
<button
type="button"
title="Remove header"
onClick={() => setRows((prev) => (prev.length > 1 ? prev.filter((_, idx) => idx !== i) : [{ name: "", value: "" }]))}
className="shrink-0 text-text-muted hover:text-red-500"
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
);
})}
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setRows((prev) => [...prev, { name: "", value: "" }])}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">add</span>
Add header
</button>
<div className="flex gap-2">
<button
type="button"
disabled={saving}
onClick={resetToBuiltin}
className="rounded-md border border-border px-3 py-1.5 text-xs hover:bg-black/[0.03] dark:hover:bg-white/[0.03] disabled:opacity-50"
>
Reset
</button>
<button
type="button"
disabled={saving}
onClick={save}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
)}
</Card>
);
}
CustomConfigCard.propTypes = {
providerId: PropTypes.string.isRequired,
};
@@ -23,6 +23,7 @@ import EditCompatibleNodeModal from "./EditCompatibleNodeModal";
import AddCustomModelModal from "./AddCustomModelModal"; import AddCustomModelModal from "./AddCustomModelModal";
import BulkImportCodexModal from "./BulkImportCodexModal"; import BulkImportCodexModal from "./BulkImportCodexModal";
import BulkImportGrokCliModal from "./BulkImportGrokCliModal"; import BulkImportGrokCliModal from "./BulkImportGrokCliModal";
import CustomConfigCard from "./CustomConfigCard";
const ONE_BY_ONE_DELAY_MS = 1000; const ONE_BY_ONE_DELAY_MS = 1000;
@@ -1743,6 +1744,9 @@ export default function ProviderDetailPage() {
</Card> </Card>
)} )}
{/* Per-provider user overrides (custom headers / connect timeout) */}
<CustomConfigCard providerId={providerId} />
{/* Models */} {/* Models */}
<Card> <Card>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -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 });
}
}
+2
View File
@@ -62,6 +62,8 @@ const DEFAULT_SETTINGS = {
pxpipeAutoInstall: true, pxpipeAutoInstall: true,
pxpipeMinChars: 25000, pxpipeMinChars: 25000,
pxpipeTimeoutMs: 15000, pxpipeTimeoutMs: 15000,
// Per-provider user header overrides applied at dispatch: { [providerId]: { headers: {..} } }
providerOverrides: {},
}; };
async function readRaw() { async function readRaw() {
+2
View File
@@ -293,6 +293,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null, pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null,
onPxpipeEvent: appendPxpipeEvent, onPxpipeEvent: appendPxpipeEvent,
providerThinking, providerThinking,
// Per-provider user overrides (custom headers / connect timeout) from settings
providerOverrides: (chatSettings.providerOverrides || {})[provider] || null,
// Detect source format by endpoint + body // Detect source format by endpoint + body
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null, sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
onCredentialsRefreshed: async (newCreds) => { onCredentialsRefreshed: async (newCreds) => {