feat(dashboard,sse,api): per-model upstream headers, compat PATCH, chat alignment

- Store/sanitize upstreamHeaders; shared forbidden header names (upstreamHeaders.ts)

- chatCore: buildUpstreamHeadersForExecute; T5 recomputes; 401 retry uses translatedBody.model

- Dashboard compat popover + i18n; Zod partialRecord + header value newline guard

- Executors merge upstreamExtraHeaders; sanitize unit tests

- Dev: bootstrap env in run-next, instrumentation-node import, credentialLoader dedupe

Made-with: Cursor
This commit is contained in:
zhang-qiang
2026-03-24 17:24:11 +08:00
parent 71540b5dc0
commit a8ca88797a
20 changed files with 856 additions and 217 deletions
+22 -18
View File
@@ -49,19 +49,22 @@ but the real logic lives in `src/lib/db/`.
Translation between provider formats: `open-sse/translator/`
**Upstream model extra headers** (`compatByProtocol` / custom models): merged in executors after default auth; **same header name replaces** the executor value (e.g. custom `Authorization` overrides Bearer). In `open-sse/handlers/chatCore.ts`, the primary request merges headers for **both** the client model id and `resolveModelAlias(clientModel)` (resolved id wins on key conflicts). **T5 intra-family fallback** recomputes headers using only the fallback model id and `resolveModelAlias(fallback)` so sibling models do not inherit another models headers. Forbidden header names live in `src/shared/constants/upstreamHeaders.ts` — keep sanitize (`models.ts`), Zod (`schemas.ts`), and unit tests aligned when editing that list.
### MCP Server (`open-sse/mcp-server/`)
16 tools for AI agent control via **3 transport modes**:
- **stdio** — Local IDE integration (Claude Desktop, Cursor, VS Code)
- **SSE** — Remote Server-Sent Events at `/api/mcp/sse`
- **Streamable HTTP** — Modern bidirectional HTTP at `/api/mcp/stream`
HTTP transports run in-process via `httpTransport.ts` singleton using `WebStandardStreamableHTTPServerTransport`.
| Category | Tools |
| ---------- | ------------------------------------------------------------------------------------------------------------------------- |
| Essential | `get_health`, `list_combos`, `get_combo_metrics`, `switch_combo`, `check_quota`, `route_request`, `cost_report`, `list_models_catalog` |
| Advanced | `simulate_route`, `set_budget_guard`, `set_resilience_profile`, `test_combo`, `get_provider_metrics`, `best_combo_for_task`, `explain_route`, `get_session_snapshot` |
| Category | Tools |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Essential | `get_health`, `list_combos`, `get_combo_metrics`, `switch_combo`, `check_quota`, `route_request`, `cost_report`, `list_models_catalog` |
| Advanced | `simulate_route`, `set_budget_guard`, `set_resilience_profile`, `test_combo`, `get_provider_metrics`, `best_combo_for_task`, `explain_route`, `get_session_snapshot` |
- Scoped authorization (9 scopes), audit logging, Zod schemas
- IDE configs for Claude Desktop, Cursor, VS Code Copilot
@@ -79,25 +82,26 @@ Agent-to-Agent v0.3 protocol:
### Auto-Combo Engine (`open-sse/services/autoCombo/`)
Self-healing routing optimization:
- 6-factor scoring, 4 mode packs, bandit exploration
- Progressive cooldown, probe-based re-admission
### Dashboard (`src/app/(dashboard)/`)
| Page | Description |
| ---------------------------- | -------------------------------------------------------------- |
| `/dashboard` | Home with quick start, provider overview |
| `/dashboard/endpoint` | **Endpoints** (tabbed): Endpoint Proxy, MCP, A2A, API Endpoints |
| `/dashboard/providers` | Provider management and connections |
| `/dashboard/combos` | Combo configurations with routing strategies |
| `/dashboard/logs` | Request, Proxy, Audit, Console logs (tabbed) |
| `/dashboard/analytics` | Usage analytics and evaluations |
| `/dashboard/costs` | Cost tracking and breakdown |
| `/dashboard/health` | Uptime, circuit breakers, latency |
| `/dashboard/cli-tools` | CLI tool integrations (Claude, Codex, Antigravity, etc.) |
| `/dashboard/media` | Image, Video, Music generation playground |
| `/dashboard/settings` | System settings with multiple tabs |
| `/dashboard/api-manager` | API key management with model permissions |
| Page | Description |
| ------------------------ | --------------------------------------------------------------- |
| `/dashboard` | Home with quick start, provider overview |
| `/dashboard/endpoint` | **Endpoints** (tabbed): Endpoint Proxy, MCP, A2A, API Endpoints |
| `/dashboard/providers` | Provider management and connections |
| `/dashboard/combos` | Combo configurations with routing strategies |
| `/dashboard/logs` | Request, Proxy, Audit, Console logs (tabbed) |
| `/dashboard/analytics` | Usage analytics and evaluations |
| `/dashboard/costs` | Cost tracking and breakdown |
| `/dashboard/health` | Uptime, circuit breakers, latency |
| `/dashboard/cli-tools` | CLI tool integrations (Claude, Codex, Antigravity, etc.) |
| `/dashboard/media` | Image, Video, Music generation playground |
| `/dashboard/settings` | System settings with multiple tabs |
| `/dashboard/api-manager` | API key management with model permissions |
### OAuth & Tokens (`src/lib/oauth/`)
+8 -1
View File
@@ -26,6 +26,12 @@ const CONFIG_TTL_MS = 60_000;
let lastLoadTime = 0;
let cachedProviders = null;
// Survives Next.js dev HMR: module-level cache resets but process is the same (V4 pattern).
type CredGlobals = typeof globalThis & { __omnirouteCredNoFileLogged?: boolean };
function credGlobals(): CredGlobals {
return globalThis as CredGlobals;
}
/**
* Resolve the path to provider-credentials.json
* Priority: DATA_DIR env → ./data (project root)
@@ -51,8 +57,9 @@ export function loadProviderCredentials(providers) {
const credPath = resolveCredentialsPath();
if (!existsSync(credPath)) {
if (!cachedProviders) {
if (!credGlobals().__omnirouteCredNoFileLogged) {
console.log("[CREDENTIALS] No external credentials file found, using defaults.");
credGlobals().__omnirouteCredNoFileLogged = true;
}
cachedProviders = providers;
lastLoadTime = Date.now();
+3 -2
View File
@@ -1,5 +1,5 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.ts";
import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts";
const MAX_RETRY_AFTER_MS = 10000;
@@ -198,7 +198,7 @@ export class AntigravityExecutor extends BaseExecutor {
return totalMs > 0 ? totalMs : null;
}
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) {
const fallbackCount = this.getFallbackCount();
let lastError = null;
let lastStatus = 0;
@@ -208,6 +208,7 @@ export class AntigravityExecutor extends BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex);
const headers = this.buildHeaders(credentials, stream);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const transformedBody = this.transformRequest(model, body, stream, credentials);
// Initialize retry counter for this URL
+28 -2
View File
@@ -58,8 +58,23 @@ export type ExecuteInput = {
signal?: AbortSignal | null;
log?: ExecutorLog | null;
extendedContext?: boolean;
/** Merged after auth + CLI fingerprint headers (values override same-named defaults). */
upstreamExtraHeaders?: Record<string, string> | null;
};
/** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */
export function mergeUpstreamExtraHeaders(
headers: Record<string, string>,
extra?: Record<string, string> | null
): void {
if (!extra) return;
for (const [k, v] of Object.entries(extra)) {
if (typeof k === "string" && k.length > 0 && typeof v === "string") {
headers[k] = v;
}
}
}
function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal {
const controller = new AbortController();
@@ -204,7 +219,16 @@ export class BaseExecutor {
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
}
async execute({ model, body, stream, credentials, signal, log, extendedContext }: ExecuteInput) {
async execute({
model,
body,
stream,
credentials,
signal,
log,
extendedContext,
upstreamExtraHeaders,
}: ExecuteInput) {
const fallbackCount = this.getFallbackCount();
let lastError: unknown = null;
let lastStatus = 0;
@@ -258,6 +282,8 @@ export class BaseExecutor {
bodyString = fingerprinted.bodyString;
}
mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders);
const fetchOptions: RequestInit = {
method: "POST",
headers: finalHeaders,
@@ -289,7 +315,7 @@ export class BaseExecutor {
continue;
}
return { response, url, headers, transformedBody };
return { response, url, headers: finalHeaders, transformedBody };
} catch (error) {
// Distinguish timeout errors from other abort errors
const err = error instanceof Error ? error : new Error(String(error));
+3 -2
View File
@@ -20,7 +20,7 @@ declare const EdgeRuntime: string | undefined;
* @see cursorProtobuf.js for Protobuf encoding/decoding utilities
*/
import { BaseExecutor } from "./base.ts";
import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts";
import {
generateCursorBody,
@@ -363,9 +363,10 @@ export class CursorExecutor extends BaseExecutor {
});
}
async execute({ model, body, stream, credentials, signal, log }) {
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) {
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const transformedBody = this.transformRequest(model, body, stream, credentials);
try {
+11 -1
View File
@@ -1,5 +1,6 @@
import {
BaseExecutor,
mergeUpstreamExtraHeaders,
type ExecuteInput,
type ExecutorLog,
type ProviderCredentials,
@@ -89,9 +90,18 @@ export class KiroExecutor extends BaseExecutor {
/**
* Custom execute for Kiro - handles AWS EventStream binary response
*/
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
async execute({
model,
body,
stream,
credentials,
signal,
log,
upstreamExtraHeaders,
}: ExecuteInput) {
const url = this.buildUrl(model, stream, 0);
const headers = this.buildHeaders(credentials, stream);
mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);
const transformedBody = this.transformRequest(model, body, stream, credentials);
const response = await fetch(url, {
+28 -3
View File
@@ -26,7 +26,11 @@ import {
appendRequestLog,
saveCallLog,
} from "@/lib/usageDb";
import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb";
import {
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
getModelUpstreamExtraHeaders,
} from "@/lib/localDb";
import { getExecutor } from "../executors/index.ts";
import {
parseCodexQuotaHeaders,
@@ -280,6 +284,23 @@ export async function handleChatCore({
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Primary path: merge client model id + alias target so config on either key applies; resolved
// id wins on same header name. T5 family fallback uses only (nextModel, resolveModelAlias(next))
// so A-model headers are not sent to B — see buildUpstreamHeadersForExecute.
const buildUpstreamHeadersForExecute = (modelToCall: string): Record<string, string> => {
if (modelToCall === effectiveModel) {
return {
...getModelUpstreamExtraHeaders(provider || "", model || "", sourceFormat),
...getModelUpstreamExtraHeaders(provider || "", resolvedModel || "", sourceFormat),
};
}
const r = resolveModelAlias(modelToCall);
return {
...getModelUpstreamExtraHeaders(provider || "", modelToCall || "", sourceFormat),
...getModelUpstreamExtraHeaders(provider || "", r || "", sourceFormat),
};
};
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
const acceptHeader =
clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
@@ -562,6 +583,7 @@ export async function handleChatCore({
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
})
);
@@ -693,16 +715,19 @@ export async function handleChatCore({
await onCredentialsRefreshed(newCredentials);
}
// Retry with new credentials
// Retry with new credentials — model + extra headers follow translatedBody.model so they
// stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback).
try {
const retryModelId = String(translatedBody.model || effectiveModel);
const retryResult = await executor.execute({
model,
model: retryModelId,
body: translatedBody,
stream,
credentials: getExecutionCredentials(),
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
});
if (retryResult.response.ok) {
+6 -6
View File
@@ -9,15 +9,15 @@ import { bootstrapEnv } from "./bootstrap-env.mjs";
const mode = process.argv[2] === "start" ? "start" : "dev";
const runtimePorts = resolveRuntimePorts();
// Load .env / server.env first so PORT / DASHBOARD_PORT from files affect --port below.
const env = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(env);
const { dashboardPort } = runtimePorts;
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)];
// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 to use Turbopack (faster dev).
if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") {
// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 in .env for Turbopack (faster dev).
// Must read merged `env` from bootstrap — .env is not applied to process.env in the launcher.
if (mode === "dev" && env.OMNIROUTE_USE_TURBOPACK !== "1") {
args.splice(2, 0, "--webpack");
}
+1 -3
View File
@@ -7,10 +7,8 @@ import {
} from "./runtime-env.mjs";
import { bootstrapEnv } from "./bootstrap-env.mjs";
const runtimePorts = resolveRuntimePorts();
// Auto-generate secrets on first run, merge .env + process.env
const env = bootstrapEnv();
const runtimePorts = resolveRuntimePorts(env);
spawnWithForwardedSignals("node", ["server.js"], {
stdio: "inherit",
+8 -4
View File
@@ -5,10 +5,14 @@ export function parsePort(value, fallback) {
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
export function resolveRuntimePorts() {
const basePort = parsePort(process.env.PORT || "20128", 20128);
const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort);
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort);
/**
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.
*/
export function resolveRuntimePorts(fromEnv = process.env) {
const basePort = parsePort(fromEnv.PORT || "20128", 20128);
const apiPort = parsePort(fromEnv.API_PORT || String(basePort), basePort);
const dashboardPort = parsePort(fromEnv.DASHBOARD_PORT || String(basePort), basePort);
return { basePort, apiPort, dashboardPort };
}
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { useNotificationStore } from "@/store/notificationStore";
import PropTypes from "prop-types";
import { useParams, useRouter } from "next/navigation";
@@ -39,9 +40,22 @@ import {
type CompatByProtocolMap = Partial<
Record<
ModelCompatProtocolKey,
{ normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean }
{
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
}
>
>;
/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */
type ModelCompatSavePatch = {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
compatByProtocol?: CompatByProtocolMap;
};
type CompatModelRow = {
id?: string;
name?: string;
@@ -50,6 +64,7 @@ type CompatModelRow = {
supportedEndpoints?: string[];
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
compatByProtocol?: CompatByProtocolMap;
};
@@ -155,24 +170,115 @@ function anyNoPreserveCompatBadge(
return false;
}
function upstreamHeadersRecordsEqual(
a: Record<string, string>,
b: Record<string, string>
): boolean {
const ka = Object.keys(a).sort();
const kb = Object.keys(b).sort();
if (ka.length !== kb.length) return false;
return ka.every((k, i) => k === kb[i] && a[k] === b[k]);
}
type HeaderDraftRow = { id: string; name: string; value: string };
const UPSTREAM_HEADERS_UI_MAX = 16;
function recordToHeaderRows(rec: Record<string, string>, genId: () => string): HeaderDraftRow[] {
const entries = Object.entries(rec).filter(([k]) => k.trim());
if (entries.length === 0) return [{ id: genId(), name: "", value: "" }];
return entries.map(([name, value]) => ({ id: genId(), name, value }));
}
function headerRowsToRecord(rows: HeaderDraftRow[]): Record<string, string> {
const out: Record<string, string> = {};
for (const r of rows) {
const k = r.name.trim();
if (!k) continue;
out[k] = r.value;
}
return out;
}
type ProviderModelsApiErrorBody = {
error?: {
message?: string;
details?: Array<{ field?: string; message?: string }>;
};
};
async function formatProviderModelsErrorResponse(res: Response): Promise<string> {
try {
const data = (await res.json()) as ProviderModelsApiErrorBody;
const err = data?.error;
if (Array.isArray(err?.details) && err.details.length > 0) {
return err.details
.map((d) => {
const f = typeof d.field === "string" && d.field ? d.field : "?";
const m = typeof d.message === "string" ? d.message : "";
return m ? `${f}: ${m}` : f;
})
.join("; ");
}
if (typeof err?.message === "string" && err.message.trim()) {
return err.message.trim();
}
} catch {
/* ignore */
}
const st = res.statusText?.trim();
return st || `HTTP ${res.status}`;
}
function effectiveUpstreamHeadersForProtocol(
modelId: string,
protocol: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): Record<string, string> {
const c = customMap.get(modelId);
const o = overrideMap.get(modelId);
const base: Record<string, string> = {};
if (c?.upstreamHeaders && typeof c.upstreamHeaders === "object") {
Object.assign(base, c.upstreamHeaders);
} else if (o?.upstreamHeaders && typeof o.upstreamHeaders === "object") {
Object.assign(base, o.upstreamHeaders);
}
const pc = getProtoSlice(c, o, protocol);
if (pc?.upstreamHeaders && typeof pc.upstreamHeaders === "object") {
Object.assign(base, pc.upstreamHeaders);
}
return base;
}
function anyUpstreamHeadersBadge(
modelId: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(modelId);
const nonempty = (u: unknown) =>
u && typeof u === "object" && !Array.isArray(u) && Object.keys(u as object).length > 0;
if (nonempty(c?.upstreamHeaders) || nonempty(o?.upstreamHeaders)) return true;
for (const p of MODEL_COMPAT_PROTOCOL_KEYS) {
const pc = getProtoSlice(c, o, p);
if (nonempty(pc?.upstreamHeaders)) return true;
}
return false;
}
interface ModelRowProps {
model: { id: string };
fullModel: string;
alias?: string;
copied?: string;
onCopy: (text: string, key: string) => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
saveModelCompatFlags: (
modelId: string,
patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
}
) => void;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void;
getUpstreamHeadersRecord: (protocol: string) => Record<string, string>;
compatDisabled?: boolean;
}
@@ -186,14 +292,8 @@ interface PassthroughModelRowProps {
showDeveloperToggle?: boolean;
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
saveModelCompatFlags: (
modelId: string,
patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
}
) => void;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void;
getUpstreamHeadersRecord: (protocol: string) => Record<string, string>;
compatDisabled?: boolean;
}
@@ -207,6 +307,7 @@ interface PassthroughModelsSectionProps {
t: (key: string, values?: Record<string, unknown>) => string;
effectiveModelNormalize: (alias: string) => boolean;
effectiveModelPreserveDeveloper: (alias: string) => boolean;
getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record<string, string>;
saveModelCompatFlags: (
modelId: string,
flags: {
@@ -243,6 +344,7 @@ interface CompatibleModelsSectionProps {
t: (key: string, values?: Record<string, unknown>) => string;
effectiveModelNormalize: (alias: string) => boolean;
effectiveModelPreserveDeveloper: (alias: string) => boolean;
getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record<string, string>;
saveModelCompatFlags: (
modelId: string,
flags: {
@@ -376,6 +478,7 @@ function ModelCompatPopover({
t,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
onCompatPatch,
showDeveloperToggle = true,
disabled,
@@ -383,11 +486,13 @@ function ModelCompatPopover({
t: (key: string) => string;
effectiveModelNormalize: (protocol: string) => boolean;
effectiveModelPreserveDeveloper: (protocol: string) => boolean;
getUpstreamHeadersRecord: (protocol: string) => Record<string, string>;
onCompatPatch: (
protocol: string,
payload: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
}
) => void;
showDeveloperToggle?: boolean;
@@ -395,15 +500,85 @@ function ModelCompatPopover({
}) {
const [open, setOpen] = useState(false);
const [protocol, setProtocol] = useState<string>(MODEL_COMPAT_PROTOCOL_KEYS[0]);
const [headerRows, setHeaderRows] = useState<HeaderDraftRow[]>([]);
const [valuePeekRowId, setValuePeekRowId] = useState<string | null>(null);
const [valueFocusRowId, setValueFocusRowId] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [portalPanelRect, setPortalPanelRect] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const headerRowIdRef = useRef(0);
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
headerRowsRef.current = headerRows;
const genHeaderRowId = () => {
headerRowIdRef.current += 1;
return `uh-${headerRowIdRef.current}`;
};
const normalizeToolCallId = effectiveModelNormalize(protocol);
const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol);
const devToggle = showDeveloperToggle && protocol !== "claude";
// Click-outside: check both trigger and panel so that if the panel is ever rendered
// in a portal (outside this subtree), clicks inside the panel still do not close it.
const tryCommitHeaderRows = useCallback(
(rows: HeaderDraftRow[]) => {
const parsed = headerRowsToRecord(rows);
const current = getUpstreamHeadersRecord(protocol);
if (upstreamHeadersRecordsEqual(parsed, current)) return;
onCompatPatch(protocol, { upstreamHeaders: parsed });
},
[getUpstreamHeadersRecord, onCompatPatch, protocol]
);
const onHeaderFieldBlur = useCallback(() => {
queueMicrotask(() => tryCommitHeaderRows(headerRowsRef.current));
}, [tryCommitHeaderRows]);
useEffect(() => {
if (!open) return;
return () => {
tryCommitHeaderRows(headerRowsRef.current);
};
}, [open, tryCommitHeaderRows]);
useEffect(() => {
if (!open) return;
const rec = getUpstreamHeadersRecord(protocol);
setHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
// Only re-load rows when opening or switching protocol — not when the parent passes a new
// inline callback every render (would wipe in-progress edits).
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
}, [open, protocol]);
useEffect(() => {
setValuePeekRowId(null);
setValueFocusRowId(null);
}, [open, protocol]);
const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length;
const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX;
const updateHeaderRow = (id: string, patch: Partial<Pick<HeaderDraftRow, "name" | "value">>) => {
setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const addHeaderRow = () => {
if (!canAddHeaderRow) return;
setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]);
};
const removeHeaderRow = (id: string) => {
setHeaderRows((prev) => {
const next = prev.filter((r) => r.id !== id);
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
queueMicrotask(() => tryCommitHeaderRows(normalized));
return normalized;
});
};
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
@@ -416,66 +591,189 @@ function ModelCompatPopover({
return () => document.removeEventListener("mousedown", onDocClick);
}, [open]);
const updatePortalPanelRect = useCallback(() => {
if (!open || !ref.current) return;
const rect = ref.current.getBoundingClientRect();
const margin = 10;
const width = Math.min(window.innerWidth - 2 * margin, 24 * 16);
let left = rect.right - width;
left = Math.max(margin, Math.min(left, window.innerWidth - width - margin));
setPortalPanelRect({ top: rect.bottom + 8, left, width });
}, [open]);
useLayoutEffect(() => {
if (!open) {
setPortalPanelRect(null);
return;
}
updatePortalPanelRect();
window.addEventListener("resize", updatePortalPanelRect);
window.addEventListener("scroll", updatePortalPanelRect, true);
return () => {
window.removeEventListener("resize", updatePortalPanelRect);
window.removeEventListener("scroll", updatePortalPanelRect, true);
};
}, [open, updatePortalPanelRect]);
const panelChromeClass =
"flex max-h-[min(82vh,42rem)] flex-col overflow-hidden rounded-xl border-2 border-zinc-200 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-950";
return (
<div className="relative inline-block" ref={ref}>
<div className="relative inline-flex" ref={ref}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md border border-border bg-sidebar/50 hover:bg-sidebar text-text-muted hover:text-text-main disabled:opacity-50"
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
title={t("compatAdjustmentsTitle")}
>
<span className="material-symbols-outlined text-sm">tune</span>
<span className="material-symbols-outlined text-base leading-none">tune</span>
{t("compatButtonLabel")}
</button>
{open && (
<div
ref={panelRef}
className="absolute left-0 top-full mt-1 z-50 min-w-[220px] max-w-[92vw] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10"
>
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-muted mb-1">
{t("compatAdjustmentsTitle")}
</p>
<p className="text-[10px] text-text-muted mb-2 leading-snug">{t("compatProtocolHint")}</p>
<label className="block text-[10px] font-medium text-text-muted mb-1">
{t("compatProtocolLabel")}
</label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
disabled={disabled}
className="w-full mb-3 px-2 py-1.5 text-xs rounded-md border border-border bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-primary/50"
{open &&
typeof document !== "undefined" &&
portalPanelRect &&
createPortal(
<div
ref={panelRef}
className={panelChromeClass}
style={{
position: "fixed",
top: portalPanelRect.top,
left: portalPanelRect.left,
width: portalPanelRect.width,
zIndex: 10040,
}}
>
{MODEL_COMPAT_PROTOCOL_KEYS.map((p) => (
<option key={p} value={p}>
{t(compatProtocolLabelKey(p))}
</option>
))}
</select>
<div className="flex flex-col gap-3">
<Toggle
size="sm"
label={t("compatToolIdShort")}
title={t("normalizeToolCallIdLabel")}
checked={normalizeToolCallId}
onChange={(v) => onCompatPatch(protocol, { normalizeToolCallId: v })}
disabled={disabled}
/>
{devToggle && (
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) =>
onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked })
}
<div className="shrink-0 border-b-2 border-zinc-200 bg-zinc-100 px-3 py-2.5 dark:border-zinc-600 dark:bg-zinc-900">
<p className="text-xs font-semibold text-text-main">{t("compatAdjustmentsTitle")}</p>
<p className="text-[11px] text-text-muted mt-1 leading-relaxed">
{t("compatProtocolHint")}
</p>
</div>
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto bg-white p-3 [scrollbar-gutter:stable] [scrollbar-width:thin] dark:bg-zinc-950">
<label className="block text-[11px] font-medium text-text-muted mb-1.5">
{t("compatProtocolLabel")}
</label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
disabled={disabled}
/>
)}
</div>
</div>
)}
className="mb-4 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-2 text-xs text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
>
{MODEL_COMPAT_PROTOCOL_KEYS.map((p) => (
<option key={p} value={p}>
{t(compatProtocolLabelKey(p))}
</option>
))}
</select>
<div className="flex flex-col gap-3.5">
<Toggle
size="sm"
label={t("compatToolIdShort")}
title={t("normalizeToolCallIdLabel")}
checked={normalizeToolCallId}
onChange={(v) => onCompatPatch(protocol, { normalizeToolCallId: v })}
disabled={disabled}
/>
{devToggle && (
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) =>
onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked })
}
disabled={disabled}
/>
)}
</div>
<div className="mt-4 rounded-lg border-2 border-zinc-200 bg-zinc-100 p-3 dark:border-zinc-600 dark:bg-zinc-900">
<label className="block text-[11px] font-semibold text-text-main mb-1">
{t("compatUpstreamHeadersLabel")}
</label>
<p className="text-[11px] text-text-muted mb-3 leading-relaxed">
{t("compatUpstreamHeadersHint")}
</p>
<div className="space-y-2">
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-1.5 items-end text-[10px] font-medium uppercase tracking-wide text-text-muted px-0.5">
<span>{t("compatUpstreamHeaderName")}</span>
<span className="col-span-1">{t("compatUpstreamHeaderValue")}</span>
<span className="w-8 shrink-0" aria-hidden />
</div>
{headerRows.map((row) => (
<div
key={row.id}
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-1.5 items-center"
>
<Input
value={row.name}
onChange={(e) => updateHeaderRow(row.id, { name: e.target.value })}
onBlur={onHeaderFieldBlur}
disabled={disabled}
placeholder="Authentication"
className="gap-0 min-w-0"
inputClassName="h-9 bg-white py-1.5 px-2 text-xs font-mono dark:bg-zinc-900"
autoComplete="off"
/>
<div
className="min-w-0"
onMouseEnter={() => setValuePeekRowId(row.id)}
onMouseLeave={() =>
setValuePeekRowId((cur) => (cur === row.id ? null : cur))
}
>
<Input
type={
valuePeekRowId === row.id || valueFocusRowId === row.id
? "text"
: "password"
}
value={row.value}
onChange={(e) => updateHeaderRow(row.id, { value: e.target.value })}
onFocus={() => setValueFocusRowId(row.id)}
onBlur={() => {
setValueFocusRowId((cur) => (cur === row.id ? null : cur));
onHeaderFieldBlur();
}}
disabled={disabled}
placeholder="•••"
className="gap-0 min-w-0"
inputClassName="h-9 bg-white py-1.5 px-2 text-xs dark:bg-zinc-900"
autoComplete="off"
spellCheck={false}
/>
</div>
<button
type="button"
disabled={disabled || headerRows.length <= 1}
onClick={() => removeHeaderRow(row.id)}
title={t("compatUpstreamRemoveRow")}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/80 text-text-muted hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted transition-colors"
>
<span className="material-symbols-outlined text-lg leading-none">
close
</span>
</button>
</div>
))}
</div>
<button
type="button"
disabled={disabled || !canAddHeaderRow}
onClick={addHeaderRow}
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-2 text-xs font-medium text-primary hover:bg-primary/5 disabled:opacity-40 disabled:hover:bg-transparent transition-colors"
>
<span className="material-symbols-outlined text-base leading-none">add</span>
{t("compatUpstreamAddRow")}
</button>
</div>
</div>
</div>,
document.body
)}
</div>
);
}
@@ -1196,14 +1494,13 @@ export default function ProviderDetailPage() {
protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]
) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap);
const saveModelCompatFlags = async (
modelId: string,
patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
}
) => {
const getUpstreamHeadersRecordForModel = useCallback(
(modelId: string, protocol: string) =>
effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap),
[customMap, overrideMap]
);
const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => {
setCompatSavingModelId(modelId);
try {
const c = customMap.get(modelId) as Record<string, unknown> | undefined;
@@ -1211,7 +1508,8 @@ export default function ProviderDetailPage() {
const onlyCompatByProtocol =
patch.compatByProtocol &&
patch.normalizeToolCallId === undefined &&
patch.preserveOpenAIDeveloperRole === undefined;
patch.preserveOpenAIDeveloperRole === undefined &&
!("upstreamHeaders" in patch);
if (c) {
if (onlyCompatByProtocol) {
@@ -1253,7 +1551,10 @@ export default function ProviderDetailPage() {
body: JSON.stringify(body),
});
if (!res.ok) {
notify.error(t("failedSaveCustomModel"));
const detail = await formatProviderModelsErrorResponse(res);
notify.error(
detail ? `${t("failedSaveCustomModel")}${detail}` : t("failedSaveCustomModel")
);
return;
}
} catch {
@@ -1286,6 +1587,7 @@ export default function ProviderDetailPage() {
t={t}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
onModelsChanged={fetchProviderModelMeta}
@@ -1319,6 +1621,7 @@ export default function ProviderDetailPage() {
t={t}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
/>
@@ -1356,23 +1659,18 @@ export default function ProviderDetailPage() {
{importButton}
<div className="flex flex-wrap gap-3">
{models.map((model) => {
const fullModel = `${providerStorageAlias}/${model.id}`;
const oldFormatModel = `${providerId}/${model.id}`;
const existingAlias = Object.entries(modelAliases).find(
([, m]) => m === fullModel || m === oldFormatModel
)?.[0];
return (
<ModelRow
key={model.id}
model={model}
fullModel={`${providerDisplayAlias}/${model.id}`}
alias={existingAlias}
copied={copied}
onCopy={copy}
t={t}
showDeveloperToggle
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecordForModel(model.id, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === model.id}
/>
@@ -2008,28 +2306,28 @@ export default function ProviderDetailPage() {
function ModelRow({
model,
fullModel,
alias,
copied,
onCopy,
t,
showDeveloperToggle = true,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatDisabled,
}: ModelRowProps) {
return (
<div className="flex flex-col px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50 min-w-[220px] max-w-md">
<div className="flex items-center gap-2 flex-wrap">
<span className="material-symbols-outlined text-base text-text-muted shrink-0">
<div className="flex min-w-[220px] max-w-md items-center gap-2 rounded-lg border border-border px-3 py-2 hover:bg-sidebar/50">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<span className="material-symbols-outlined shrink-0 text-base text-text-muted">
smart_toy
</span>
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
<code className="rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted">
{fullModel}
</code>
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
@@ -2037,16 +2335,19 @@ function ModelRow({
</span>
</button>
</div>
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
}
showDeveloperToggle={showDeveloperToggle}
disabled={compatDisabled}
/>
<div className="shrink-0">
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
}
showDeveloperToggle={showDeveloperToggle}
disabled={compatDisabled}
/>
</div>
</div>
);
}
@@ -2056,13 +2357,13 @@ ModelRow.propTypes = {
id: PropTypes.string.isRequired,
}).isRequired,
fullModel: PropTypes.string.isRequired,
alias: PropTypes.string,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
t: PropTypes.func,
showDeveloperToggle: PropTypes.bool,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatDisabled: PropTypes.bool,
};
@@ -2077,6 +2378,7 @@ function PassthroughModelsSection({
t,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatSavingModelId,
}: PassthroughModelsSectionProps) {
@@ -2161,6 +2463,7 @@ function PassthroughModelsSection({
showDeveloperToggle
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === modelId}
/>
@@ -2181,6 +2484,7 @@ PassthroughModelsSection.propTypes = {
t: PropTypes.func.isRequired,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatSavingModelId: PropTypes.string,
};
@@ -2195,24 +2499,25 @@ function PassthroughModelRow({
showDeveloperToggle = true,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatDisabled,
}: PassthroughModelRowProps) {
return (
<div className="flex flex-col gap-0 p-3 rounded-lg border border-border hover:bg-sidebar/50">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-base text-text-muted shrink-0">
<div className="flex gap-0 rounded-lg border border-border p-3 hover:bg-sidebar/50">
<div className="flex min-w-0 flex-1 items-start gap-3">
<span className="material-symbols-outlined shrink-0 text-base text-text-muted">
smart_toy
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1 flex-wrap">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{modelId}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
<code className="rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted">
{fullModel}
</code>
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
@@ -2221,25 +2526,26 @@ function PassthroughModelRow({
</button>
</div>
</div>
<button
onClick={onDeleteAlias}
className="p-1 hover:bg-red-50 rounded text-red-500 shrink-0"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
<div className="pl-9">
<div className="flex shrink-0 items-center gap-1 self-start">
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)}
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })
}
showDeveloperToggle={showDeveloperToggle}
disabled={compatDisabled}
/>
<button
onClick={onDeleteAlias}
className="rounded p-1 text-red-500 hover:bg-red-50"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
</div>
);
@@ -2255,6 +2561,7 @@ PassthroughModelRow.propTypes = {
showDeveloperToggle: PropTypes.bool,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatDisabled: PropTypes.bool,
};
@@ -2381,7 +2688,10 @@ function CustomModelsSection({
body: JSON.stringify({ provider: providerId, modelId, ...patch }),
});
if (!res.ok) {
notify.error(t("failedSaveCustomModel"));
const detail = await formatProviderModelsErrorResponse(res);
notify.error(
detail ? `${t("failedSaveCustomModel")}${detail}` : t("failedSaveCustomModel")
);
return;
}
} catch {
@@ -2422,7 +2732,8 @@ function CustomModelsSection({
});
if (!res.ok) {
throw new Error("Failed to save model endpoint settings");
const detail = await formatProviderModelsErrorResponse(res);
throw new Error(detail || "Failed to save model endpoint settings");
}
await fetchCustomModels();
@@ -2431,7 +2742,9 @@ function CustomModelsSection({
cancelEdit();
} catch (e) {
console.error("Failed to save custom model:", e);
notify.error("Failed to save model endpoint settings");
notify.error(
e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings"
);
} finally {
setSavingModelId(null);
}
@@ -2542,10 +2855,14 @@ function CustomModelsSection({
return (
<div
key={model.id}
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-sidebar/50"
className="flex items-center gap-3 rounded-lg border border-border p-3 hover:bg-sidebar/50"
>
<span className="material-symbols-outlined text-base text-primary">tune</span>
<div className="flex-1 min-w-0">
{editingModelId !== model.id && (
<span className="material-symbols-outlined text-base text-primary shrink-0">
tune
</span>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{model.name || model.id}</p>
<div className="flex items-center gap-1 mt-1 flex-wrap">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
@@ -2596,32 +2913,39 @@ function CustomModelsSection({
{t("compatBadgeNoPreserve")}
</span>
)}
{anyUpstreamHeadersBadge(model.id, customMap, overrideMap) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/15 text-violet-400 font-medium"
title={t("compatUpstreamHeadersLabel")}
>
{t("compatBadgeUpstreamHeaders")}
</span>
)}
</div>
{editingModelId === model.id && (
<div className="mt-3 p-3 rounded-lg border border-border bg-sidebar/40">
<div className="flex items-end gap-3 flex-wrap">
<div className="w-44">
<div className="mt-3 min-w-0 max-w-full rounded-lg border border-border bg-muted p-3 dark:bg-zinc-900">
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-2">
<div className="w-[11rem] shrink-0 min-w-0">
<label className="text-xs text-text-muted mb-1 block">API Format</label>
<select
value={editingApiFormat}
onChange={(e) => setEditingApiFormat(e.target.value)}
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary"
>
<option value="chat-completions">Chat Completions</option>
<option value="responses">Responses API</option>
</select>
</div>
<div className="flex-1 min-w-[240px]">
<span className="text-xs text-text-muted mb-1 block">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-visible [scrollbar-width:thin]">
<span className="text-xs text-text-muted shrink-0">
Supported Endpoints
</span>
<div className="flex items-center gap-3 flex-wrap">
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 min-w-0">
{["chat", "embeddings", "images", "audio"].map((ep) => (
<label
key={ep}
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
>
<input
type="checkbox"
@@ -2648,51 +2972,52 @@ function CustomModelsSection({
))}
</div>
</div>
</div>
<div className="mt-3 pt-3 border-t border-border/80 w-full">
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) =>
effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap)
}
effectiveModelPreserveDeveloper={(p) =>
effectivePreserveForProtocol(model.id, p, customMap, overrideMap)
}
onCompatPatch={(protocol, payload) =>
saveCustomCompat(model.id, {
compatByProtocol: { [protocol]: payload },
})
}
showDeveloperToggle
disabled={savingModelId === model.id}
/>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
size="sm"
onClick={() => saveEdit(model.id)}
disabled={savingModelId === model.id}
>
{savingModelId === model.id ? t("saving") : t("save")}
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>
{t("cancel")}
</Button>
<div className="flex shrink-0 flex-wrap items-center gap-2 pb-0.5">
<Button
size="sm"
onClick={() => saveEdit(model.id)}
disabled={savingModelId === model.id}
>
{savingModelId === model.id ? t("saving") : t("save")}
</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>
{t("cancel")}
</Button>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => beginEdit(model)}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary"
className="rounded p-1 text-text-muted hover:bg-sidebar hover:text-primary"
title={t("edit")}
>
<span className="material-symbols-outlined text-sm">edit</span>
</button>
<ModelCompatPopover
t={t}
effectiveModelNormalize={(p) =>
effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap)
}
effectiveModelPreserveDeveloper={(p) =>
effectivePreserveForProtocol(model.id, p, customMap, overrideMap)
}
getUpstreamHeadersRecord={(p) =>
effectiveUpstreamHeadersForProtocol(model.id, p, customMap, overrideMap)
}
onCompatPatch={(protocol, payload) =>
saveCustomCompat(model.id, {
compatByProtocol: { [protocol]: payload },
})
}
showDeveloperToggle
disabled={savingModelId === model.id}
/>
<button
onClick={() => handleRemove(model.id)}
className="p-1 hover:bg-red-50 rounded text-red-500"
className="rounded p-1 text-red-500 hover:bg-red-50"
title={t("removeCustomModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
@@ -2731,6 +3056,7 @@ function CompatibleModelsSection({
t,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
saveModelCompatFlags,
compatSavingModelId,
onModelsChanged,
@@ -2944,6 +3270,7 @@ function CompatibleModelsSection({
showDeveloperToggle={!isAnthropic}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === modelId}
/>
@@ -2973,6 +3300,7 @@ CompatibleModelsSection.propTypes = {
t: PropTypes.func.isRequired,
effectiveModelNormalize: PropTypes.func.isRequired,
effectiveModelPreserveDeveloper: PropTypes.func.isRequired,
getUpstreamHeadersRecord: PropTypes.func.isRequired,
saveModelCompatFlags: PropTypes.func.isRequired,
compatSavingModelId: PropTypes.string,
onModelsChanged: PropTypes.func,
+10
View File
@@ -130,6 +130,7 @@ export async function PUT(request) {
supportedEndpoints,
normalizeToolCallId,
preserveOpenAIDeveloperRole,
upstreamHeaders,
compatByProtocol,
} = validation.data;
@@ -141,6 +142,7 @@ export async function PUT(request) {
if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId;
if ("preserveOpenAIDeveloperRole" in raw)
updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
if ("upstreamHeaders" in raw) updates.upstreamHeaders = upstreamHeaders;
if ("compatByProtocol" in raw && compatByProtocol !== undefined) {
updates.compatByProtocol = compatByProtocol;
}
@@ -157,11 +159,13 @@ export async function PUT(request) {
"modelId",
"normalizeToolCallId",
"preserveOpenAIDeveloperRole",
"upstreamHeaders",
"compatByProtocol",
].includes(k)
) &&
("normalizeToolCallId" in raw ||
"preserveOpenAIDeveloperRole" in raw ||
"upstreamHeaders" in raw ||
"compatByProtocol" in raw);
if (compatOnly) {
const knownProvider =
@@ -191,6 +195,12 @@ export async function PUT(request) {
if ("compatByProtocol" in raw && compatByProtocol && typeof compatByProtocol === "object") {
patch.compatByProtocol = compatByProtocol;
}
if ("upstreamHeaders" in raw) {
patch.upstreamHeaders =
upstreamHeaders === null || typeof upstreamHeaders === "object"
? upstreamHeaders
: undefined;
}
if (Object.keys(patch).length > 0) {
mergeModelCompatOverride(provider, modelId, patch);
}
+7
View File
@@ -1443,6 +1443,13 @@
"compatProtocolOpenAI": "OpenAI Chat Completions",
"compatProtocolOpenAIResponses": "OpenAI Responses API",
"compatProtocolClaude": "Anthropic Messages",
"compatUpstreamHeadersLabel": "Extra upstream headers",
"compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.",
"compatUpstreamHeaderName": "Header name",
"compatUpstreamHeaderValue": "Value",
"compatUpstreamAddRow": "Add header",
"compatUpstreamRemoveRow": "Remove row",
"compatBadgeUpstreamHeaders": "Headers",
"modelId": "Model ID",
"customModelPlaceholder": "e.g. gpt-4.5-turbo",
"loading": "Loading...",
+7
View File
@@ -1437,6 +1437,13 @@
"compatProtocolOpenAI": "OpenAI Chat Completions",
"compatProtocolOpenAIResponses": "OpenAI Responses API",
"compatProtocolClaude": "Anthropic Messages",
"compatUpstreamHeadersLabel": "上游额外请求头",
"compatUpstreamHeadersHint": "与修改厂商连接/API 配置同属高权限能力,仅可信管理员应使用。这些头会在 OmniRoute 按厂商 API Key 自动加好鉴权头之后再合并。若「名称」与系统已加的头相同(例如都叫 Authorization),则以你填的值为准,会整段替换自动那条(含 Bearer 令牌),上游请求里不再使用面板里保存的密钥来生成 Authorization。填错可能导致 401,请谨慎。每个请求头单独一行;部分网关需要额外 Authentication 等可在此加。鼠标移入或聚焦「值」可暂时看明文。点空白处、关闭本面板或切走焦点即保存。",
"compatUpstreamHeaderName": "请求头名称",
"compatUpstreamHeaderValue": "值",
"compatUpstreamAddRow": "添加请求头",
"compatUpstreamRemoveRow": "删除此行",
"compatBadgeUpstreamHeaders": "请求头",
"modelId": "模型 ID",
"customModelPlaceholder": "例如:gpt-4.5-turbo",
"loading": "正在加载...",
+4 -5
View File
@@ -10,11 +10,10 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Computed path prevents Turbopack from statically resolving the import
// for the Edge instrumentation bundle, avoiding spurious warnings about
// Node.js modules not being available in the Edge Runtime.
const nodeMod = "./instrumentation-" + "node";
const { registerNodejs } = await import(nodeMod);
// Literal path so Webpack emits the chunk (computed string breaks dev:
// MODULE_NOT_FOUND for ./instrumentation-node at runtime).
// Turbopack may still avoid tracing this into Edge when guarded by NEXT_RUNTIME.
const { registerNodejs } = await import("./instrumentation-node");
await registerNodejs();
}
}
+136 -2
View File
@@ -8,6 +8,7 @@ import {
MODEL_COMPAT_PROTOCOL_KEYS,
type ModelCompatProtocolKey,
} from "@/shared/constants/modelCompat";
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
type JsonRecord = Record<string, unknown>;
@@ -19,6 +20,8 @@ export { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey };
export type ModelCompatPerProtocol = {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
/** Merged into upstream HTTP requests for this model (after default auth headers). */
upstreamHeaders?: Record<string, string>;
};
type CompatByProtocolMap = Partial<Record<ModelCompatProtocolKey, ModelCompatPerProtocol>>;
@@ -27,6 +30,43 @@ function isCompatProtocolKey(p: string): p is ModelCompatProtocolKey {
return (MODEL_COMPAT_PROTOCOL_KEYS as readonly string[]).includes(p);
}
const UPSTREAM_HEADERS_MAX = 16;
const UPSTREAM_HEADER_NAME_MAX = 128;
const UPSTREAM_HEADER_VALUE_MAX = 4096;
function isValidUpstreamHeaderName(k: string): boolean {
if (!k || k.length > UPSTREAM_HEADER_NAME_MAX) return false;
if (isForbiddenUpstreamHeaderName(k)) return false;
if (/[\r\n\0]/.test(k)) return false;
if (/\s/.test(k)) return false;
if (k.includes(":")) return false;
return true;
}
/** Sanitize user-provided upstream header map (used when persisting and when reading for requests). */
export function sanitizeUpstreamHeadersMap(
raw: Record<string, unknown> | null | undefined
): Record<string, string> {
const out: Record<string, string> = {};
if (!raw || typeof raw !== "object") return out;
for (const [k0, v0] of Object.entries(raw)) {
const k = String(k0).trim();
if (!k || !isValidUpstreamHeaderName(k)) {
continue;
}
const v =
typeof v0 === "string"
? v0.trim().slice(0, UPSTREAM_HEADER_VALUE_MAX)
: String(v0 ?? "")
.trim()
.slice(0, UPSTREAM_HEADER_VALUE_MAX);
if (v.includes("\r") || v.includes("\n")) continue;
out[k] = v;
if (Object.keys(out).length >= UPSTREAM_HEADERS_MAX) break;
}
return out;
}
function deepMergeCompatByProtocol(
prev: CompatByProtocolMap | undefined,
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>
@@ -38,7 +78,8 @@ function deepMergeCompatByProtocol(
if (!deltas || typeof deltas !== "object") continue;
const hasDelta =
Object.prototype.hasOwnProperty.call(deltas, "normalizeToolCallId") ||
Object.prototype.hasOwnProperty.call(deltas, "preserveOpenAIDeveloperRole");
Object.prototype.hasOwnProperty.call(deltas, "preserveOpenAIDeveloperRole") ||
Object.prototype.hasOwnProperty.call(deltas, "upstreamHeaders");
if (!hasDelta) continue;
const cur: ModelCompatPerProtocol = { ...(out[key] || {}) };
if ("normalizeToolCallId" in deltas) {
@@ -47,6 +88,16 @@ function deepMergeCompatByProtocol(
if ("preserveOpenAIDeveloperRole" in deltas) {
cur.preserveOpenAIDeveloperRole = Boolean(deltas.preserveOpenAIDeveloperRole);
}
if ("upstreamHeaders" in deltas) {
const uh = deltas.upstreamHeaders;
if (uh === undefined) {
/* skip */
} else {
const s = sanitizeUpstreamHeadersMap(uh as Record<string, unknown>);
if (Object.keys(s).length === 0) delete cur.upstreamHeaders;
else cur.upstreamHeaders = s;
}
}
if (Object.keys(cur).length === 0) delete out[key];
else out[key] = cur;
}
@@ -58,6 +109,7 @@ export type ModelCompatOverride = {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
upstreamHeaders?: Record<string, string>;
};
function readCompatList(providerId: string): ModelCompatOverride[] {
@@ -100,6 +152,8 @@ export type ModelCompatPatch = {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean | null;
compatByProtocol?: CompatByProtocolMap;
/** Replace top-level extra headers for override-only rows; omit to leave unchanged. */
upstreamHeaders?: Record<string, string> | null;
};
function compatByProtocolHasEntries(map: CompatByProtocolMap | undefined): boolean {
@@ -135,12 +189,23 @@ export function mergeModelCompatOverride(
if (compatByProtocolHasEntries(merged)) next.compatByProtocol = merged;
else delete next.compatByProtocol;
}
if ("upstreamHeaders" in patch) {
if (patch.upstreamHeaders === null) {
delete next.upstreamHeaders;
} else if (patch.upstreamHeaders && typeof patch.upstreamHeaders === "object") {
const s = sanitizeUpstreamHeadersMap(patch.upstreamHeaders as Record<string, unknown>);
if (Object.keys(s).length === 0) delete next.upstreamHeaders;
else next.upstreamHeaders = s;
}
}
const filtered = list.filter((e) => e.id !== modelId);
const hasPreserveFlag = Object.prototype.hasOwnProperty.call(next, "preserveOpenAIDeveloperRole");
const hasTopUpstream = next.upstreamHeaders && Object.keys(next.upstreamHeaders).length > 0;
if (
next.normalizeToolCallId ||
hasPreserveFlag ||
compatByProtocolHasEntries(next.compatByProtocol)
compatByProtocolHasEntries(next.compatByProtocol) ||
hasTopUpstream
) {
filtered.push(next);
}
@@ -388,6 +453,17 @@ export async function updateCustomModel(
}
}
if (Object.prototype.hasOwnProperty.call(updates, "upstreamHeaders")) {
const uh = updates.upstreamHeaders;
if (uh === null || uh === undefined) {
delete next.upstreamHeaders;
} else if (typeof uh === "object" && !Array.isArray(uh)) {
const s = sanitizeUpstreamHeadersMap(uh as Record<string, unknown>);
if (Object.keys(s).length === 0) delete next.upstreamHeaders;
else next.upstreamHeaders = s;
}
}
models[index] = next;
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run(
@@ -491,3 +567,61 @@ export function getModelPreserveOpenAIDeveloperRole(
}
return undefined;
}
function readUpstreamFromJsonRecord(
row: JsonRecord | null | undefined,
key: "upstreamHeaders"
): Record<string, string> | undefined {
if (!row) return undefined;
const raw = row[key];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const s = sanitizeUpstreamHeadersMap(raw as Record<string, unknown>);
return Object.keys(s).length > 0 ? s : undefined;
}
/**
* Extra HTTP headers to send to the upstream provider for this model (after executor auth headers).
* Order: top-level `upstreamHeaders` on the custom model row (override list merged under custom),
* then per-protocol `compatByProtocol[sourceFormat].upstreamHeaders` (wins on key conflict).
* Use for gateways that expect `Authentication`, `X-API-Key`, etc. alongside Bearer.
*
* `modelId` should be the **canonical** model id when known. Callers that accept client aliases
* (e.g. chat proxy) should merge results for both alias and `resolveModelAlias(alias)` so UI
* config on the resolved id still applies see `chatCore` merge.
*/
export function getModelUpstreamExtraHeaders(
providerId: string,
modelId: string,
sourceFormat?: string | null
): Record<string, string> {
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
const m = getCustomModelRow(providerId, modelId);
const base: Record<string, string> = {};
if (m) {
const fromModel = readUpstreamFromJsonRecord(m, "upstreamHeaders");
if (fromModel) Object.assign(base, fromModel);
if (protocol) {
const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol];
const fromProto = pc?.upstreamHeaders;
if (fromProto && typeof fromProto === "object") {
Object.assign(base, sanitizeUpstreamHeadersMap(fromProto as Record<string, unknown>));
}
}
return base;
}
const co = readCompatList(providerId).find((e) => e.id === modelId);
if (co?.upstreamHeaders) {
Object.assign(base, sanitizeUpstreamHeadersMap(co.upstreamHeaders as Record<string, unknown>));
}
if (protocol && co?.compatByProtocol?.[protocol]?.upstreamHeaders) {
Object.assign(
base,
sanitizeUpstreamHeadersMap(
co.compatByProtocol[protocol]!.upstreamHeaders as Record<string, unknown>
)
);
}
return base;
}
+1
View File
@@ -55,6 +55,7 @@ export {
removeModelCompatOverride,
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
getModelUpstreamExtraHeaders,
} from "./db/models";
export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models";
+22
View File
@@ -0,0 +1,22 @@
/**
* User-supplied upstream extra headers: names we never forward (Host / hop-by-hop / framing).
* Changing this list requires syncing: `sanitizeUpstreamHeadersMap` (models.ts), Zod
* `upstreamHeaderNameSchema` / record refine (schemas.ts), and `upstream-headers-sanitize` tests.
*/
const FORBIDDEN = new Set(
[
"host",
"connection",
"content-length",
"keep-alive",
"proxy-connection",
"transfer-encoding",
"te",
"trailer",
"upgrade",
].map((s) => s.toLowerCase())
);
export function isForbiddenUpstreamHeaderName(name: string): boolean {
return FORBIDDEN.has(String(name).trim().toLowerCase());
}
+28 -1
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
function isHttpUrl(value: string): boolean {
try {
@@ -340,10 +341,34 @@ export const clearModelAvailabilitySchema = z.object({
model: modelIdSchema,
});
/** Align with `sanitizeUpstreamHeadersMap` — allow non-ASCII names; reject Host / hop-by-hop / whitespace / ":". */
const upstreamHeaderNameSchema = z
.string()
.trim()
.min(1)
.max(128)
.refine((s) => !/[\r\n\0]/.test(s), { message: "header name cannot contain control characters" })
.refine((s) => !/\s/.test(s), { message: "header name cannot contain whitespace" })
.refine((s) => !s.includes(":"), { message: "header name cannot contain ':'" })
.refine((s) => !isForbiddenUpstreamHeaderName(s), { message: "header name is not allowed" });
const upstreamHeaderValueSchema = z
.string()
.max(4096)
.refine((s) => !/[\r\n]/.test(s), { message: "header value cannot contain line breaks" });
const upstreamHeadersRecordSchema = z
.record(upstreamHeaderNameSchema, upstreamHeaderValueSchema)
.refine((rec) => Object.keys(rec).length <= 16, { message: "at most 16 custom headers" })
.refine((rec) => !Object.keys(rec).some((k) => isForbiddenUpstreamHeaderName(k)), {
message: "forbidden header name in record",
});
const modelCompatPerProtocolSchema = z
.object({
normalizeToolCallId: z.boolean().optional(),
preserveOpenAIDeveloperRole: z.boolean().optional(),
upstreamHeaders: upstreamHeadersRecordSchema.optional(),
})
.strict();
@@ -356,8 +381,10 @@ export const providerModelMutationSchema = z.object({
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]),
normalizeToolCallId: z.boolean().optional(),
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
upstreamHeaders: upstreamHeadersRecordSchema.nullable().optional(),
/** Zod 4: `z.record(z.enum([...]), …)` requires every enum key; use `partialRecord` for sparse patches. */
compatByProtocol: z
.record(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema)
.partialRecord(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema)
.optional(),
});
@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { sanitizeUpstreamHeadersMap } from "../../src/lib/db/models.ts";
test("sanitizeUpstreamHeadersMap: drops hop-by-hop / Host names", () => {
const out = sanitizeUpstreamHeadersMap({
Host: "evil",
Connection: "close",
"Content-Length": "999",
"X-Custom": "ok",
});
assert.deepEqual(out, { "X-Custom": "ok" });
});
test("sanitizeUpstreamHeadersMap: drops values with CR/LF", () => {
const out = sanitizeUpstreamHeadersMap({
Good: "a",
Bad: "x\ny",
Bad2: "x\ry",
});
assert.deepEqual(out, { Good: "a" });
});
test("sanitizeUpstreamHeadersMap: caps count at 16", () => {
const raw = Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`H${i}`, String(i)]));
const out = sanitizeUpstreamHeadersMap(raw);
assert.strictEqual(Object.keys(out).length, 16);
});