From a8ca88797ad01d18ddea515862491c0a5d215404 Mon Sep 17 00:00:00 2001 From: zhang-qiang Date: Tue, 24 Mar 2026 17:24:11 +0800 Subject: [PATCH] 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 --- AGENTS.md | 40 +- open-sse/config/credentialLoader.ts | 9 +- open-sse/executors/antigravity.ts | 5 +- open-sse/executors/base.ts | 30 +- open-sse/executors/cursor.ts | 5 +- open-sse/executors/kiro.ts | 12 +- open-sse/handlers/chatCore.ts | 31 +- scripts/run-next.mjs | 12 +- scripts/run-standalone.mjs | 4 +- scripts/runtime-env.mjs | 12 +- .../dashboard/providers/[id]/page.tsx | 662 +++++++++++++----- src/app/api/provider-models/route.ts | 10 + src/i18n/messages/en.json | 7 + src/i18n/messages/zh-CN.json | 7 + src/instrumentation.ts | 9 +- src/lib/db/models.ts | 138 +++- src/lib/localDb.ts | 1 + src/shared/constants/upstreamHeaders.ts | 22 + src/shared/validation/schemas.ts | 29 +- tests/unit/upstream-headers-sanitize.test.mjs | 28 + 20 files changed, 856 insertions(+), 217 deletions(-) create mode 100644 src/shared/constants/upstreamHeaders.ts create mode 100644 tests/unit/upstream-headers-sanitize.test.mjs diff --git a/AGENTS.md b/AGENTS.md index aed8d9c2..52a5fcb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 model’s 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/`) diff --git a/open-sse/config/credentialLoader.ts b/open-sse/config/credentialLoader.ts index 5cd2a06a..4e133ec6 100644 --- a/open-sse/config/credentialLoader.ts +++ b/open-sse/config/credentialLoader.ts @@ -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(); diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index fd09aaad..80f1e610 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -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 diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 19daaa80..d3a33060 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -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 | null; }; +/** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */ +export function mergeUpstreamExtraHeaders( + headers: Record, + extra?: Record | 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)); diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 8577a54f..425336d6 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -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 { diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index d80917ec..567d12f9 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -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, { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d7bcfde9..e0c1196d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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 => { + 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) { diff --git a/scripts/run-next.mjs b/scripts/run-next.mjs index db71a948..cbf40715 100644 --- a/scripts/run-next.mjs +++ b/scripts/run-next.mjs @@ -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"); } diff --git a/scripts/run-standalone.mjs b/scripts/run-standalone.mjs index 0bce2252..095f89b4 100644 --- a/scripts/run-standalone.mjs +++ b/scripts/run-standalone.mjs @@ -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", diff --git a/scripts/runtime-env.mjs b/scripts/runtime-env.mjs index 04753009..b3e761bf 100644 --- a/scripts/runtime-env.mjs +++ b/scripts/runtime-env.mjs @@ -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} [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 }; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c5a07553..f1be9646 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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; + } > >; + +/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ +type ModelCompatSavePatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; +}; + type CompatModelRow = { id?: string; name?: string; @@ -50,6 +64,7 @@ type CompatModelRow = { supportedEndpoints?: string[]; normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; compatByProtocol?: CompatByProtocolMap; }; @@ -155,24 +170,115 @@ function anyNoPreserveCompatBadge( return false; } +function upstreamHeadersRecordsEqual( + a: Record, + b: Record +): 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, 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 { + const out: Record = {}; + 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 { + 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 { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const base: Record = {}; + 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; 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; 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; compatDisabled?: boolean; } @@ -207,6 +307,7 @@ interface PassthroughModelsSectionProps { t: (key: string, values?: Record) => string; effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; saveModelCompatFlags: ( modelId: string, flags: { @@ -243,6 +344,7 @@ interface CompatibleModelsSectionProps { t: (key: string, values?: Record) => string; effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; 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; onCompatPatch: ( protocol: string, payload: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; } ) => void; showDeveloperToggle?: boolean; @@ -395,15 +500,85 @@ function ModelCompatPopover({ }) { const [open, setOpen] = useState(false); const [protocol, setProtocol] = useState(MODEL_COMPAT_PROTOCOL_KEYS[0]); + const [headerRows, setHeaderRows] = useState([]); + const [valuePeekRowId, setValuePeekRowId] = useState(null); + const [valueFocusRowId, setValueFocusRowId] = useState(null); const ref = useRef(null); const panelRef = useRef(null); + const [portalPanelRect, setPortalPanelRect] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + const headerRowIdRef = useRef(0); + const headerRowsRef = useRef([]); + 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>) => { + 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 ( -
+
- {open && ( -
-

- {t("compatAdjustmentsTitle")} -

-

{t("compatProtocolHint")}

- - -
- onCompatPatch(protocol, { normalizeToolCallId: v })} - disabled={disabled} - /> - {devToggle && ( - - onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) - } +
+

{t("compatAdjustmentsTitle")}

+

+ {t("compatProtocolHint")} +

+
+
+ + +
+ onCompatPatch(protocol, { normalizeToolCallId: v })} + disabled={disabled} + /> + {devToggle && ( + + onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) + } + disabled={disabled} + /> + )} +
+ +
+ +

+ {t("compatUpstreamHeadersHint")} +

+
+
+ {t("compatUpstreamHeaderName")} + {t("compatUpstreamHeaderValue")} + +
+ {headerRows.map((row) => ( +
+ 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" + /> +
setValuePeekRowId(row.id)} + onMouseLeave={() => + setValuePeekRowId((cur) => (cur === row.id ? null : cur)) + } + > + 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} + /> +
+ +
+ ))} +
+ +
+
+
, + document.body + )}
); } @@ -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 | 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}
{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 ( 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 ( -
-
- +
+
+ smart_toy - + {fullModel}
- effectiveModelNormalize(model.id, p)} - effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} - onCompatPatch={(protocol, payload) => - saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) - } - showDeveloperToggle={showDeveloperToggle} - disabled={compatDisabled} - /> +
+ 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} + /> +
); } @@ -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 ( -
-
- +
+
+ smart_toy -
-

{modelId}

-
- +
+

{modelId}

+
+ {fullModel}
-
-
+
effectiveModelNormalize(modelId, p)} effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} onCompatPatch={(protocol, payload) => saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } }) } showDeveloperToggle={showDeveloperToggle} disabled={compatDisabled} /> +
); @@ -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 (
- tune -
+ {editingModelId !== model.id && ( + + tune + + )} +

{model.name || model.id}

@@ -2596,32 +2913,39 @@ function CustomModelsSection({ {t("compatBadgeNoPreserve")} )} + {anyUpstreamHeadersBadge(model.id, customMap, overrideMap) && ( + + {t("compatBadgeUpstreamHeaders")} + + )}
{editingModelId === model.id && ( -
-
-
+
+
+
- -
- +
+ Supported Endpoints -
+
{["chat", "embeddings", "images", "audio"].map((ep) => (
-
-
- - 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} - /> -
-
- - +
+ + +
)}
-
+
+ + 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} + />