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:
1 parent
2880980709
commit
b3cf3fdef0
7 files changed
+305
-2
No files matched your search
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in new issue
Block a user