Merge PR #575: feat(dashboard,sse,api) — per-model upstream headers, compat PATCH, chat alignment (by @zhangqiang8vip)

This commit is contained in:
diegosouzapw
2026-03-24 09:46:59 -03:00
37 changed files with 1313 additions and 269 deletions

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/`)

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();

View File

@@ -1370,7 +1370,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "qwen/qwen3-32b", name: "Qwen3 32B (Puter)" },
{ id: "qwen/qwen3-coder", name: "Qwen3 Coder 480B (Puter)" },
],
passthroughModels: true, // 500+ models available — users can type any Puter model ID
passthroughModels: true, // 500+ models available — users can type arbitrary Puter model IDs
},
"cloudflare-ai": {

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

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));

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 {

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, {

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"
@@ -593,6 +614,7 @@ export async function handleChatCore({
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
})
);
@@ -724,16 +746,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) {

View File

@@ -1081,6 +1081,21 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
}
}
type Imagen3ImageGenArgs = {
model: string;
provider: string;
providerConfig: { baseUrl: string };
body: { prompt?: string; size?: string; n?: number };
credentials: { apiKey?: string; accessToken?: string };
log?: { info?: (tag: string, msg: string) => void; error?: (tag: string, msg: string) => void } | null;
};
type Imagen3NormalizedImage = {
b64_json?: unknown;
url?: unknown;
revised_prompt?: string;
};
/**
* Handle Imagen 3 image generation
*/
@@ -1091,7 +1106,7 @@ async function handleImagen3ImageGeneration({
body,
credentials,
log,
}: any) {
}: Imagen3ImageGenArgs) {
const startTime = Date.now();
const token = credentials.apiKey || credentials.accessToken;
const aspectRatio = mapImageSize(body.size);
@@ -1142,11 +1157,11 @@ async function handleImagen3ImageGeneration({
const data = await response.json();
// Normalize response to OpenAI format
const images: any[] = [];
const images: Imagen3NormalizedImage[] = [];
if (Array.isArray(data.images)) {
images.push(
...data.images.map((img: any) => ({
b64_json: img.image || img.b64_json || img.url || img,
...data.images.map((img: Record<string, unknown>) => ({
b64_json: img.image ?? img.b64_json ?? img.url ?? img,
revised_prompt: body.prompt,
}))
);
@@ -1174,8 +1189,9 @@ async function handleImagen3ImageGeneration({
success: true,
data: { created: data.created || Math.floor(Date.now() / 1000), data: images },
};
} catch (err: any) {
if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
if (log) log.error("IMAGE", `${provider} fetch error: ${errMsg}`);
saveCallLog({
method: "POST",
@@ -1184,9 +1200,9 @@ async function handleImagen3ImageGeneration({
model: `${provider}/${model}`,
provider,
duration: Date.now() - startTime,
error: err.message,
error: errMsg,
}).catch(() => {});
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
return { success: false, status: 502, error: `Image provider error: ${errMsg}` };
}
}

View File

@@ -23,12 +23,19 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
const first = chunks[0];
const contentParts = [];
const reasoningParts = [];
const accumulatedToolCalls = new Map<string, any>();
type AccumulatedToolCall = {
id: string | null;
index: number;
type: string;
function: { name: string; arguments: string };
};
const accumulatedToolCalls = new Map<string, AccumulatedToolCall>();
let unknownToolCallSeq = 0;
let finishReason = "stop";
let usage = null;
const getToolCallKey = (toolCall: any) => {
const getToolCallKey = (toolCall: Record<string, unknown>) => {
if (toolCall?.id) return `id:${toolCall.id}`;
if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`;
unknownToolCallSeq += 1;

View File

@@ -435,7 +435,7 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
let path = "/v1/models";
let isProviderSpecific = false;
let source = "local_catalog";
let warning = undefined;
let warning: string | undefined;
if (args.provider && !args.capability) {
// Use direct provider fetch to get real-time API status
@@ -451,7 +451,7 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
const raw = toRecord(await omniRouteFetch(path));
// If we used the direct provider endpoint
let rawModels = [];
let rawModels: unknown[] = [];
if (isProviderSpecific) {
rawModels = Array.isArray(raw.models) ? raw.models : [];
source = typeof raw.source === "string" ? raw.source : "api";

View File

@@ -29,7 +29,7 @@ type ClaudeTool = {
/**
* T02: Recursively strips empty text blocks from content arrays.
* Anthropic returns 400 "text content blocks must be non-empty" if any
* Anthropic returns 400 "text content blocks must be non-empty" when a
* text block has text: "". Must also recurse into nested tool_result.content.
* Ref: sub2api PR #1212
*/

View File

@@ -57,6 +57,8 @@ type TranslateState = ReturnType<typeof initState> & {
accumulatedContent?: string;
};
type UsageTokenRecord = Record<string, number>;
function getOpenAIIntermediateChunks(value: unknown): unknown[] {
if (!value || typeof value !== "object") return [];
const candidate = (value as JsonRecord)._openaiIntermediate;
@@ -108,7 +110,9 @@ export function createSSEStream(options: StreamOptions = {}) {
} = options;
let buffer = "";
let usage = null;
let usage: UsageTokenRecord | null = null;
/** Passthrough (OpenAI CC shape): saw tool_calls in stream before finish_reason */
let passthroughHasToolCalls = false;
// State for translate mode (accumulatedContent for call log response body)
const state: TranslateState | null =
@@ -223,9 +227,9 @@ export function createSSEStream(options: StreamOptions = {}) {
if (extracted) {
// Non-destructive merge: never overwrite a positive value with 0
// message_start carries input_tokens, message_delta carries output_tokens;
if (!usage) usage = {} as any;
const u = usage as Record<string, number>;
const eu = extracted as Record<string, number>;
if (!usage) usage = {};
const u = usage;
const eu = extracted as UsageTokenRecord;
if (eu.prompt_tokens > 0) u.prompt_tokens = eu.prompt_tokens;
if (eu.completion_tokens > 0) u.completion_tokens = eu.completion_tokens;
if (eu.total_tokens > 0) u.total_tokens = eu.total_tokens;
@@ -266,7 +270,7 @@ export function createSSEStream(options: StreamOptions = {}) {
// T18: Track if we saw tool calls
if (delta?.tool_calls && delta.tool_calls.length > 0) {
(state as any).passthroughHasToolCalls = true;
passthroughHasToolCalls = true;
}
const content = delta?.content || delta?.reasoning_content;
@@ -288,7 +292,7 @@ export function createSSEStream(options: StreamOptions = {}) {
// T18: Normalize finish_reason to 'tool_calls' if tool calls were used
if (
isFinishChunk &&
(state as any).passthroughHasToolCalls &&
passthroughHasToolCalls &&
parsed.choices[0].finish_reason !== "tool_calls"
) {
parsed.choices[0].finish_reason = "tool_calls";

View File

@@ -140,7 +140,7 @@ export function createDisconnectAwareStream(transformStream, streamController) {
const errorMsg = error instanceof Error ? error.message : "Upstream stream error";
const statusCode =
typeof error === "object" && error !== null && "statusCode" in error
? (error as any).statusCode
? Number((error as { statusCode?: unknown }).statusCode) || 500
: 500;
const errorEvent = {

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");
}

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",

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 };
}

View File

@@ -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,

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);
}

View File

@@ -1,4 +1,6 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import {
getProviderConnectionById,
updateProviderConnection,
@@ -92,6 +94,11 @@ const CLI_RUNTIME_PROVIDER_MAP = {
kilocode: "kilo",
};
/** POST body is optional; when present, only known fields are validated. */
const providerConnectionTestBodySchema = z.object({
validationModelId: z.string().max(500).optional(),
});
function toSafeMessage(value: any, fallback = "Unknown error"): string {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
@@ -683,14 +690,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
try {
const { id } = await params;
// Parse optional body for validationModelId
let validationModelId;
let rawBody: unknown = {};
try {
const body = await request.json();
validationModelId = body?.validationModelId;
rawBody = await request.json();
} catch {
// Body is optional
// Empty or non-JSON body — treat as {}
}
const validation = validateBody(providerConnectionTestBodySchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { validationModelId } = validation.data;
const data = await testSingleConnection(id, validationModelId);

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { setAccountKeyLimit, getAccountKeyLimit } from "@/lib/db/registeredKeys";
const limitsSchema = z.object({
@@ -32,20 +33,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
let rawBody: unknown;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = limitsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const validation = validateBody(limitsSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const resolvedParams = await params;
setAccountKeyLimit(resolvedParams.id, parsed.data);
setAccountKeyLimit(resolvedParams.id, validation.data);
const updated = getAccountKeyLimit(resolvedParams.id);
return NextResponse.json({ accountId: resolvedParams.id, limits: updated });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const reportSchema = z.object({
title: z.string().min(1).max(300),
@@ -26,19 +27,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
let rawBody: unknown;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = reportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const validation = validateBody(reportSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { title, provider, accountId, requestId, errorCode, details, labels = [] } = parsed.data;
const { title, provider, accountId, requestId, errorCode, details, labels = [] } = validation.data;
const repo = process.env.GITHUB_ISSUES_REPO;
const token = process.env.GITHUB_ISSUES_TOKEN;

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { setProviderKeyLimit, getProviderKeyLimit } from "@/lib/db/registeredKeys";
const limitsSchema = z.object({
@@ -32,20 +33,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ prov
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
let rawBody: unknown;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = limitsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const validation = validateBody(limitsSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider } = await params;
setProviderKeyLimit(provider, parsed.data);
setProviderKeyLimit(provider, validation.data);
const updated = getProviderKeyLimit(provider);
return NextResponse.json({ provider, limits: updated });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { issueRegisteredKey, checkQuota, listRegisteredKeys } from "@/lib/db/registeredKeys";
// ─── Validation ───────────────────────────────────────────────────────────────
@@ -53,19 +54,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
let rawBody: unknown;
try {
body = await request.json();
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = issueKeySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const validation = validateBody(issueKeySchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { provider, accountId } = parsed.data;
const { provider, accountId } = validation.data;
// ── Quota check ──
try {
@@ -83,7 +84,7 @@ export async function POST(request: Request) {
// ── Issue ──
try {
const result = issueRegisteredKey(parsed.data);
const result = issueRegisteredKey(validation.data);
if ("idempotencyConflict" in result) {
return NextResponse.json(

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...",

View File

@@ -1443,6 +1443,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": "正在加载...",

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();
}
}

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;
}

View File

@@ -55,6 +55,7 @@ export {
removeModelCompatOverride,
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
getModelUpstreamExtraHeaders,
} from "./db/models";
export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models";

View File

@@ -4,14 +4,31 @@
* Extracts OAuth credentials from OS keychain where Zed IDE stores them.
* Supports macOS (Keychain), Windows (Credential Manager), and Linux (libsecret).
*
* `keytar` is a native module (e.g. libsecret on Linux). Load it only via dynamic import
* so `next build` / CI without those libs does not fail when this module is analyzed.
*
* @see https://zed.dev/docs/ai/llm-providers - Official Zed documentation confirming keychain storage
*/
import keytar from "keytar";
import fs from "fs";
import os from "os";
import path from "path";
/** Minimal keytar surface (CJS/native; typings may not expose `default`). */
type KeytarModule = {
findCredentials: (service: string) => Promise<Array<{ account: string; password: string }>>;
getPassword: (service: string, account: string) => Promise<string | null>;
};
async function loadKeytar(): Promise<KeytarModule | null> {
try {
const mod = (await import("keytar")) as { default?: KeytarModule } & KeytarModule;
return mod.default ?? mod;
} catch {
return null;
}
}
export interface ZedCredential {
provider: string;
service: string;
@@ -87,6 +104,12 @@ function extractProviderFromService(service: string): string {
* @returns Array of discovered credentials with provider, service, and token
*/
export async function discoverZedCredentials(): Promise<ZedCredential[]> {
const keytar = await loadKeytar();
if (!keytar) {
console.debug("[Zed keychain] keytar not available — skipping keychain read");
return [];
}
const credentials: ZedCredential[] = [];
for (const pattern of ZED_SERVICE_PATTERNS) {
@@ -128,6 +151,9 @@ export async function discoverZedCredentials(): Promise<ZedCredential[]> {
* @returns The credential if found, null otherwise
*/
export async function getZedCredential(provider: string): Promise<ZedCredential | null> {
const keytar = await loadKeytar();
if (!keytar) return null;
const patterns = ZED_SERVICE_PATTERNS.filter((p) =>
p.toLowerCase().includes(provider.toLowerCase())
);

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());
}

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
function isHttpUrl(value: string): boolean {
try {
@@ -341,10 +342,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();
@@ -357,8 +382,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(),
});

View File

@@ -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);
});

103
zws_docs/README.md Normal file
View File

@@ -0,0 +1,103 @@
# zws_docsZWS 作者自用 + PR 备忘归档)
> **本目录**以 ZWS 作者备忘为主;其中 **「给上游 PR 的备忘」** 一节可直接给维护者看或贴进 PR 评论,便于 review。全员架构仍以仓库根目录 **`AGENTS.md`** 为准。
---
## 本目录用途
- **ZWS_README_V*.md**:版本化变更记录(现象、根因、方案、文件清单、回退、后续 CI 补丁)。
- **PR_DRAFT_FOR_UPSTREAM.md**:英文 PR 描述草稿(可复制到 GitHub
- **本 README**:项目速记 + **记忆归档**检查脚本行为、提交习惯、PR 链接等)。
---
## 给上游 PR 的备忘(记忆归档)
### 已开 PR示例以 GitHub 实际为准)
- **上游仓库**`diegosouzapw/OmniRoute`
- **PR**https://github.com/diegosouzapw/OmniRoute/pull/575若编号变化请自行替换
- **来源分支**fork `zhangqiang8vip/OmniRoute`**`feat/zws-v8`** → `base: main`
### PR 主体功能V8 大包)
- 模型级 **上游额外 HTTP 头**Dashboard → `PUT /api/provider-models` → DB → `chatCore``mergeUpstreamExtraHeaders`)。
- **别名**`buildUpstreamHeadersForExecute` 主路径合并 **客户端 model + `resolvedModel`**,解析后 id **同名覆盖**
- **T5 族内 fallback**:仅对 **fallback 模型 + `resolveModelAlias(fallback)`** 重算头,避免 A 的头带到 B。
- **401/403 重试**`retryModelId = String(translatedBody.model || effectiveModel)`,与 body 一致。
- **禁止头名单**`src/shared/constants/upstreamHeaders.ts`(与 `sanitize`、Zod 同步)。
- **Zod**`compatByProtocol` 稀疏 PATCHheader value 禁止 `\r\n`
- **Dev**`run-next.mjs``bootstrapEnv``instrumentation` 字面量子路径;`credentialLoader` 可防抖日志。
### 随 PR 跟进的小补丁(记在 V8 文档「九」及以后)
| 主题 | 说明 |
|------|------|
| **T06** `npm run check:route-validation:t06` | 凡 `request.json()` 的同文件须出现 **`validateBody(`**(脚本文本匹配)。已补 5 个路由;校验失败体为 `{ error: { message, details } }`。 |
| **Zed / Linux CI** | `keychain-reader.ts` **禁止顶层 `import keytar`**,改为 **`await import("keytar")`**,避免无 libsecret 时 `next build` 收集 `/api/providers/zed/import` 失败。 |
| **T11** `npm run check:any-budget:t11` | 用 **`/\bany\b/g` 数单词****注释里的 "any" 也算**。需改注释措辞或去掉 `: any` / `as any``stream.ts` passthrough 下 **`state` 为 null**,工具调用标记改用闭包变量 **`passthroughHasToolCalls`**。 |
### 提交 / 推送习惯(记忆)
- **不要提交**`.env``.cursor/``.idea/``.history/`;无 `package.json` 变更时 **不要提交无关的 `package-lock.json` 大 diff**
- **Husky**pre-commit 会跑整包 `test:unit`,很慢;本地赶进度可 **`HUSKY=0 git commit`**,但 **CI / 合并前务必自己跑** `npm run test:unit``npm run lint``npm run build`
- **给 PR 写正文**:用 **`PR_DRAFT_FOR_UPSTREAM.md`** 裁剪;验证结果可 **`gh pr comment`** 贴表格。
### 维护者若不想收 zws_docs
可在合并时 **只合代码路径**,本目录整夹删除或保留由上游决定;**`AGENTS.md`** 里上游头说明建议保留。
---
## 一、项目摘要(备忘)
**OmniRoute**:统一 AI 代理/路由;`open-sse` 处理 Chat 等Executor 发上游SQLite 经 `src/lib/db/*``localDb.ts` 仅再导出MCP、A2A、Combo 等见 `AGENTS.md`
**数据流(极简)**:客户端 → handlers → 可选 translator → executor鉴权 + `mergeUpstreamExtraHeaders`)→ 上游。
**易踩坑**:客户端组件勿经 `localDb` 拉 Node 链;协议常量用 `src/shared/constants/`;模型兼容按 `compatByProtocol` + `sourceFormat`;上游头合并与别名/T5 见 **V8****`AGENTS.md`**。
---
## 二、仓库地图(备忘)
| 关心什么 | 路径 |
|----------|------|
| Chat、别名、T5、上游头 | `open-sse/handlers/chatCore.ts` |
| `resolveModelAlias` 等 | `open-sse/services/modelDeprecation.ts` |
| 模型行、upstreamHeaders | `src/lib/db/models.ts` |
| 执行器合并头 | `open-sse/executors/base.ts` |
| Zod | `src/shared/validation/schemas.ts` |
| 厂商模型 API | `src/app/api/provider-models/route.ts` |
| 厂商详情 UI | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` |
| 启动 / env | `scripts/run-next.mjs` |
| instrumentation | `src/instrumentation.ts``instrumentation-node.ts` |
| Zed keychain | `src/lib/zed-oauth/keychain-reader.ts` |
---
## 三、ZWS 版本文档索引
| 文档 | 说明 |
|------|------|
| [ZWS_README_V4.md](./ZWS_README_V4.md) | 启动与 devHMR、globalThis、Turbopack、instrumentation 等 |
| [ZWS_README_V5.md](./ZWS_README_V5.md) | `compatByProtocol``sourceFormat`、Map 优化、Zod |
| [ZWS_README_V8.md](./ZWS_README_V8.md) | 上游额外头、别名/T5/401、禁止头、T06/T11/keytar 等 **后续节** |
| [PR_DRAFT_FOR_UPSTREAM.md](./PR_DRAFT_FOR_UPSTREAM.md) | 英文 PR 描述草稿 |
---
## 四、作者自用检查清单
1. 大改前先扫 **`AGENTS.md`** 对应节。
2. 动兼容/上游头:对照 **V5、V8**`chatCore` / `models` / `schemas`
3. 动 dev 启动:对照 **V4**`run-next.mjs` / instrumentation。
4. 提 PR 前跑:**`npm run check:route-validation:t06`**、**`npm run check:any-budget:t11`**(若 CI 启用)、`npm test``npm run lint``npm run build`
5. 新大块变更可新增 **`ZWS_README_V*.md`** 并在 **第三节** 表内加一行。
---
## 五、维护说明
**ZWS** 维护本目录;若 fork 随上游同步,可把本 README **复制一段「给上游 PR 的备忘」** 到 PR 描述或评论,减少维护者上下文切换。

235
zws_docs/ZWS_README_V8.md Normal file
View File

@@ -0,0 +1,235 @@
# ZWS_README_V8 — 上游额外 HTTP 头、别名/族内 fallback 一致性、校验与文案硬化
> **仅供 ZWS 作者自用**,非项目正式文档;其他协作者请以仓库根目录 `AGENTS.md` 为准。
V5 已完成按协议维度的模型兼容性(`compatByProtocol`、Map 查找优化等。V8 在 V5 基础上补齐 **模型级「发往上游的额外请求头」** 全链路Dashboard 配置 → API/Zod → SQLite → `chatCore` 与执行器合并;并修复 **别名解析与族内 fallback** 下「头与真实调用模型不一致」的行为;同步 **禁止头名单**、**Zod 与 sanitize 对齐**、**401 重试参数一致性**、**启动脚本读合并后 env**、**instrumentation 字面量 import** 等。
---
## 一、如何发现问题
### 现象与审查结论
1. **别名与 header 查找 key 不一致**
`getModelUpstreamExtraHeaders(provider, model, sourceFormat)` 若只使用客户端原始 `model`,而用户在 Dashboard 把 `upstreamHeaders` 配在 **解析后的 canonical id** 上,则客户端仍用别名调用时,**请求可能不带配置的头**。
2. **T5 族内模型 fallback**
`executeProviderRequest(nextModel)` 若始终传入外层一次性算好的 `upstreamExtraHeaders`(对应首次 **`effectiveModel`**),则族内从模型 A 切到 B 时,**可能仍带 A 的头**。
3. **401/403 刷新后重试**
`executor.execute` 第一个参数仍传 **原始 `model`**,与主路径里 `translatedBody.model === effectiveModel` 不一致,存在 **边缘路径与主路径漂移**
4. **API 与落库静默不一致**
Zod 对 header 值未禁止 `\r\n` 时,可能出现 **校验通过但 sanitize 落库丢字段**
5. **hop-by-hop / 帧相关头**
`Host` 外未统一禁止 `Connection``Transfer-Encoding``Content-Length` 等,纵深防御不足。
6. **自定义头覆盖鉴权**
`mergeUpstreamExtraHeaders` 在鉴权之后应用,**同名会覆盖**(如 `Authorization` 覆盖 Bearer。需在 UI/文档标明 **高权限**,且避免向终端用户强调实现细节。
7. **Zod 4 与 PATCH**
`compatByProtocol` 使用 `z.record` 时对缺失键校验过严,**仅 PATCH 单协议** 可能失败;需改为 **稀疏 patch**(如 `partialRecord`)。
8. **启动与 dev**
`.env``PORT` / `DASHBOARD_PORT` / `OMNIROUTE_USE_TURBOPACK` 若未在 `bootstrapEnv` 之后参与解析launcher 行为与预期不一致;`instrumentation` 动态 import 在 dev 下可能出现 **MODULE_NOT_FOUND**,需改为字面量子路径。
### 排查过程
1. 沿链路追踪:`providers/[id]/page.tsx``PUT /api/provider-models``models.ts``getModelUpstreamExtraHeaders``chatCore``BaseExecutor.mergeUpstreamExtraHeaders`
2. 对照 `resolveModelAlias``effectiveModel`、T5 `getNextFamilyFallback` 的调用点,确认 `upstreamExtraHeaders` 闭包是否绑定错误模型。
3. 对照 `sanitizeUpstreamHeadersMap``schemas.ts` 中 record 的 value 规则。
4. 将禁止头名抽为 **单一常量源**,避免 Zod / sanitize / 文档三套漂移。
---
## 二、根因分析
### 根因 1P1`chatCore` 仅用「原始 model」取 upstream headers
`getModelUpstreamExtraHeaders` 单次调用只覆盖传入的 `modelId`。别名场景下配置写在 **resolved id** 上时,仅传客户端别名会 **lookup miss**
### 根因 2P1族内 fallback 复用首次合并结果
`upstreamExtraHeaders``executeProviderRequest` 外只算一次,**不随 `modelToCall` 变化**,导致 fallback 模型与头配置 **错配**
### 根因 3P2401 重试路径未与主路径对齐
重试分支直接 `executor.execute({ model, ... })`**未使用 `effectiveModel`**,与 `translatedBody.model` 及格式检测链不一致。
### 根因 4P2Zod 与 sanitize 对 header value 规则不一致
`z.string().max(4096)` 允许换行sanitize 丢弃含 `\r`/`\n` 的值 → **静默丢配置**
### 根因 5P3禁止头名单分散
Host 单独判断、其余 hop-by-hop 未系统化,维护成本高且易漏(如 `content-length`)。
### 根因 6工程PATCH 语义与 Zod `record` 行为
Zod 4 下全键 `record` 与「只提交部分协议」的 PATCH 语义冲突,需 **partialRecord** 或等价稀疏结构。
---
## 三、修复方案
### 修复 1`src/shared/constants/upstreamHeaders.ts`(单一事实来源)
- 导出 `isForbiddenUpstreamHeaderName(name)`
- 禁止集合包含:`host``connection``content-length``keep-alive``proxy-connection``transfer-encoding``te``trailer``upgrade`(小写比较)。
- 文件头注释注明:**改列表须同步** `models.ts`sanitize`schemas.ts`Zod`tests/unit/upstream-headers-sanitize.test.mjs`
### 修复 2`models.ts` — `sanitizeUpstreamHeadersMap` / `isValidUpstreamHeaderName`
- 使用 `isForbiddenUpstreamHeaderName` 替代仅 `host` 特判。
- **`getModelUpstreamExtraHeaders` JSDoc**:说明 `modelId` 以 canonical 为准;接受别名的调用方(如 chat 代理)应 **合并别名与 `resolveModelAlias`**,并指向 `chatCore`
### 修复 3`schemas.ts` — 与 sanitize 对齐
- `upstreamHeaderNameSchema`refine 调用 `isForbiddenUpstreamHeaderName`
- `upstreamHeaderValueSchema``max(4096)` + **禁止 `\r`/`\n`**
- `upstreamHeadersRecordSchema`:条数上限 + 键层面禁止集合。
- `compatByProtocol`**partialRecord**(或项目内等价实现),支持稀疏 PATCH。
### 修复 4`chatCore.ts` — `buildUpstreamHeadersForExecute(modelToCall)`
- **`modelToCall === effectiveModel`(主路径)**
```text
spread: getModelUpstreamExtraHeaders(provider, model, sourceFormat)
then: getModelUpstreamExtraHeaders(provider, resolvedModel, sourceFormat)
```
**后者覆盖同名 key**(解析后 id 侧优先于客户端别名侧)。
- **`modelToCall !== effectiveModel`T5 族内 fallback**
`getModelUpstreamExtraHeaders(provider, modelToCall, …)`
+
`getModelUpstreamExtraHeaders(provider, resolveModelAlias(modelToCall), …)`
**不再混入**原始请求 `model` / 首次 `resolvedModel`,避免 A 的头带到 B。
- **`executeProviderRequest`**`upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall)`**每轮调用按模型重算**。
### 修复 5`chatCore.ts` — 401/403 刷新后重试
- `model: effectiveModel`(不再用原始 `model`)。
- `upstreamExtraHeaders: buildUpstreamHeadersForExecute(effectiveModel)`。
### 修复 6执行器 — `mergeUpstreamExtraHeaders`(行为保持,文档化)
- 仍在 **鉴权等默认头之后** 合并;**同名 key 覆盖**。
- `ExecuteInput` 注释已说明「values override same-named defaults」。
### 修复 7紧急预算回退emergency fallback
- **`fbExecutor.execute` 不传 `upstreamExtraHeaders`**(换 provider/model避免把原模型头带到 fallback 目标)。**保持 intentionally**。
### 修复 8Dashboard — `page.tsx`
- **`ModelCompatSavePatch`**`normalizeToolCallId`、`preserveOpenAIDeveloperRole`、顶层 **`upstreamHeaders`**、`compatByProtocol`(与 API / `ModelCompatPerProtocol` 形状一致)。
- 上游头 UI`ModelCompatPopover`、失焦/关面板提交、`compatUpstreamHeadersHint` 等(细节见 V5 UI 演进与本轮 i18n
### 修复 9i18n — `compatUpstreamHeadersHint`zh-CN / en
- 说明:高权限、合并顺序、**同名覆盖鉴权头**、401 风险。
- **不**在文案中强调「明文存数据库」等实现细节(产品要求)。
### 修复 10`AGENTS.md`
- 增加 **Upstream model extra headers** 短节:主路径双 lookup 与 **resolved 侧同名优先**T5 **仅对 fallback 模型重算**;禁止名单与三处同步。
### 修复 11单测 — `tests/unit/upstream-headers-sanitize.test.mjs`
- 禁止名(含 `content-length`)、值含换行丢弃、最多 16 条。
### 修复 12启动与 instrumentation与 V4 并列的工程项)
- `scripts/run-next.mjs`**先 `bootstrapEnv()`,再 `resolveRuntimePorts(env)`**`OMNIROUTE_USE_TURBOPACK` 从合并后的 `env` 读取。
- `instrumentation.ts`:使用 **字面量** `import("./instrumentation-node")`(或项目内等价路径),避免 dev 下动态段导致的 **MODULE_NOT_FOUND**。
- `open-sse/config/credentialLoader.ts`:可用 `globalThis` **防抖日志**(避免 HMR 刷屏),不引入新敏感数据。
---
## 四、使用方式(运维 / 产品)
1. **Dashboard** → 厂商 → 模型 → **兼容性**弹层:按协议配置 **上游额外请求头**(名称 + 值;值字段可悬停/聚焦查看)。
2. 保存时机:失焦、点空白、关闭弹层等(以当前 `page.tsx` 行为为准)。
3. **主路径**:同时识别 **客户端写的 model id** 与 **解析后的 id** 上的配置;**两处都有且同名 header 冲突时,解析后 id 侧获胜**。
4. **族内 fallback**:自动切换为 **当前 fallback 模型** 及其别名解析上的配置,**不继承**首次模型的额外头。
5. **不要**在自定义头里随意填写与系统重复的 `Authorization`,除非明确需要覆盖 Bearer高权限场景
6. 禁止的头名在 UI/API 层会被拒绝;与 framing 相关的头不应由用户注入。
---
## 五、预期效果
| 维度 | 修复前 | 修复后 |
|------|--------|--------|
| 别名 + 配置在 canonical id | 可能不带配置头 | 主路径双 lookupresolved 侧覆盖同名键 |
| T5 族内 fallback | 可能携带首次模型的头 | 按 fallback 模型(+其 alias 解析)重算 |
| 401/403 重试 `execute.model` | 可能为原始 `model` | 与 `effectiveModel` / body 一致 |
| Zod vs sanitizeheader value | 可能 200 后静默丢 | 值含换行 → 400 |
| 禁止头 | Host 等零散 | 统一常量 + content-length 等 |
| PATCH `compatByProtocol` | 易触发全键校验问题 | 稀疏 partialRecord |
| 文案 | 不易理解覆盖 Bearer | 高权限与风险说明清楚,不强调存库实现细节 |
---
## 六、涉及文件清单(核心)
| 区域 | 文件 | 说明 |
|------|------|------|
| 禁止头常量 | `src/shared/constants/upstreamHeaders.ts` | 单一来源 |
| 存储与 sanitize | `src/lib/db/models.ts` | `sanitizeUpstreamHeadersMap`、`getModelUpstreamExtraHeaders` 注释 |
| Zod | `src/shared/validation/schemas.ts` | header 名/值、`compatByProtocol` partialRecord |
| Chat 管线 | `open-sse/handlers/chatCore.ts` | `buildUpstreamHeadersForExecute`、401 重试 |
| 执行器 | `open-sse/executors/base.ts` | `mergeUpstreamExtraHeaders`(行为未改,语义依赖) |
| API | `src/app/api/provider-models/route.ts` | 与 schema 一致 |
| Dashboard | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | `ModelCompatSavePatch`、上游头 UI |
| i18n | `src/i18n/messages/zh-CN.json`、`en.json` | `compatUpstreamHeadersHint` 等 |
| 文档 | `AGENTS.md` | 上游头合并与 T5 行为摘要 |
| 测试 | `tests/unit/upstream-headers-sanitize.test.mjs` | sanitize 行为 |
| 启动 | `scripts/run-next.mjs`、`src/instrumentation.ts` | env / Turbopack / instrumentation-node |
| 日志防抖 | `open-sse/config/credentialLoader.ts` | 可选 HMR 防抖 |
---
## 七、回退方案
- **关闭「按模型重算头」**:将 `executeProviderRequest` 内改回固定对象(**不推荐**,会复活 T5 错配)。
- **主路径不合并别名**:去掉对 `resolvedModel` 的第二次 `getModelUpstreamExtraHeaders`**不推荐**,会复活 canonical 配置不生效)。
- **禁止头列表**:从 `upstreamHeaders.ts` 删减时务必同步 Zod、sanitize、单测否则会出现「API 与运行时行为不一致」。
- **partialRecord**:若需恢复严格全键校验,需同时调整前端 PATCH 载荷为「总是带全协议键」。
---
## 八、与 V5 的边界
- **V5**`compatByProtocol` 下 **normalizeToolCallId / preserveOpenAIDeveloperRole** 的按协议读写、`sourceFormat` 传入 `chatCore` getter、前端协议选择与 Map 优化。
- **V8**:在同一 `compatByProtocol`(及顶层)上扩展 **`upstreamHeaders`** 的端到端行为,以及 **chat 路径上「头与 model id 一致」** 的修正与校验硬化。
- 阅读顺序建议:**V4启动/HMR→ V5按协议兼容开关→ V8上游头 + fallback/别名)**。
---
## 九、后续补丁T06 路由校验与 Zed `keytar`CI / `next build`
### T06`check-route-validation.mjs`
脚本要求:凡调用 `request.json()` 的同文件内须出现 `validateBody(`。已补全:
- `src/app/api/providers/[id]/test/route.ts` — 可选 body`validationModelId`
- `src/app/api/v1/accounts/[id]/limits/route.ts`
- `src/app/api/v1/issues/report/route.ts`
- `src/app/api/v1/providers/[provider]/limits/route.ts`
- `src/app/api/v1/registered-keys/route.ts`POST
校验失败时错误体与项目其余 API 一致:`{ error: { message, details[] } }`(部分路由由原先的 Zod `flatten()` 改为该形状)。
### Zed 导入与 Linux CI
`src/lib/zed-oauth/keychain-reader.ts` 顶层 **`import keytar`** 会在 `next build` 收集路由数据时加载原生模块;无 `libsecret` 的 Linux runner 会失败。改为 **`await import("keytar")`** 动态加载,失败则 **跳过读钥匙串**(返回空列表 / null构建不再依赖本机 keytar。
> 若本文随上游合并,可删除文首「仅供 ZWS 作者自用」一句;**ZWS** 为贡献者笔名,可保留作变更索引。
### T11`check:any-budget:t11`
脚本 `scripts/check-t11-any-budget.mjs` 用正则统计文件中 **单词 `any`**(含注释里的英文 *any*)。失败原因通常是:注释误触(如 “type **any** model ID”、或真实 `: any` / `as any`。处理方式:改写注释用词、用 `unknown` / `Record<string, unknown>` / 显式接口替代 `any`。`open-sse/utils/stream.ts` 中 passthrough 分支原先对 `state`(在 passthrough 模式下为 `null`)做 `(state as any).passthroughHasToolCalls`,已改为闭包内独立布尔变量 `passthroughHasToolCalls`。