diff --git a/AGENTS.md b/AGENTS.md index aed8d9c21a..52a5fcb6a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,19 +49,22 @@ but the real logic lives in `src/lib/db/`. Translation between provider formats: `open-sse/translator/` +**Upstream model extra headers** (`compatByProtocol` / custom models): merged in executors after default auth; **same header name replaces** the executor value (e.g. custom `Authorization` overrides Bearer). In `open-sse/handlers/chatCore.ts`, the primary request merges headers for **both** the client model id and `resolveModelAlias(clientModel)` (resolved id wins on key conflicts). **T5 intra-family fallback** recomputes headers using only the fallback model id and `resolveModelAlias(fallback)` so sibling models do not inherit another model’s headers. Forbidden header names live in `src/shared/constants/upstreamHeaders.ts` — keep sanitize (`models.ts`), Zod (`schemas.ts`), and unit tests aligned when editing that list. + ### MCP Server (`open-sse/mcp-server/`) 16 tools for AI agent control via **3 transport modes**: + - **stdio** — Local IDE integration (Claude Desktop, Cursor, VS Code) - **SSE** — Remote Server-Sent Events at `/api/mcp/sse` - **Streamable HTTP** — Modern bidirectional HTTP at `/api/mcp/stream` HTTP transports run in-process via `httpTransport.ts` singleton using `WebStandardStreamableHTTPServerTransport`. -| Category | Tools | -| ---------- | ------------------------------------------------------------------------------------------------------------------------- | -| Essential | `get_health`, `list_combos`, `get_combo_metrics`, `switch_combo`, `check_quota`, `route_request`, `cost_report`, `list_models_catalog` | -| Advanced | `simulate_route`, `set_budget_guard`, `set_resilience_profile`, `test_combo`, `get_provider_metrics`, `best_combo_for_task`, `explain_route`, `get_session_snapshot` | +| Category | Tools | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Essential | `get_health`, `list_combos`, `get_combo_metrics`, `switch_combo`, `check_quota`, `route_request`, `cost_report`, `list_models_catalog` | +| Advanced | `simulate_route`, `set_budget_guard`, `set_resilience_profile`, `test_combo`, `get_provider_metrics`, `best_combo_for_task`, `explain_route`, `get_session_snapshot` | - Scoped authorization (9 scopes), audit logging, Zod schemas - IDE configs for Claude Desktop, Cursor, VS Code Copilot @@ -79,25 +82,26 @@ Agent-to-Agent v0.3 protocol: ### Auto-Combo Engine (`open-sse/services/autoCombo/`) Self-healing routing optimization: + - 6-factor scoring, 4 mode packs, bandit exploration - Progressive cooldown, probe-based re-admission ### Dashboard (`src/app/(dashboard)/`) -| Page | Description | -| ---------------------------- | -------------------------------------------------------------- | -| `/dashboard` | Home with quick start, provider overview | -| `/dashboard/endpoint` | **Endpoints** (tabbed): Endpoint Proxy, MCP, A2A, API Endpoints | -| `/dashboard/providers` | Provider management and connections | -| `/dashboard/combos` | Combo configurations with routing strategies | -| `/dashboard/logs` | Request, Proxy, Audit, Console logs (tabbed) | -| `/dashboard/analytics` | Usage analytics and evaluations | -| `/dashboard/costs` | Cost tracking and breakdown | -| `/dashboard/health` | Uptime, circuit breakers, latency | -| `/dashboard/cli-tools` | CLI tool integrations (Claude, Codex, Antigravity, etc.) | -| `/dashboard/media` | Image, Video, Music generation playground | -| `/dashboard/settings` | System settings with multiple tabs | -| `/dashboard/api-manager` | API key management with model permissions | +| Page | Description | +| ------------------------ | --------------------------------------------------------------- | +| `/dashboard` | Home with quick start, provider overview | +| `/dashboard/endpoint` | **Endpoints** (tabbed): Endpoint Proxy, MCP, A2A, API Endpoints | +| `/dashboard/providers` | Provider management and connections | +| `/dashboard/combos` | Combo configurations with routing strategies | +| `/dashboard/logs` | Request, Proxy, Audit, Console logs (tabbed) | +| `/dashboard/analytics` | Usage analytics and evaluations | +| `/dashboard/costs` | Cost tracking and breakdown | +| `/dashboard/health` | Uptime, circuit breakers, latency | +| `/dashboard/cli-tools` | CLI tool integrations (Claude, Codex, Antigravity, etc.) | +| `/dashboard/media` | Image, Video, Music generation playground | +| `/dashboard/settings` | System settings with multiple tabs | +| `/dashboard/api-manager` | API key management with model permissions | ### OAuth & Tokens (`src/lib/oauth/`) diff --git a/open-sse/config/credentialLoader.ts b/open-sse/config/credentialLoader.ts index 5cd2a06ac4..4e133ec687 100644 --- a/open-sse/config/credentialLoader.ts +++ b/open-sse/config/credentialLoader.ts @@ -26,6 +26,12 @@ const CONFIG_TTL_MS = 60_000; let lastLoadTime = 0; let cachedProviders = null; +// Survives Next.js dev HMR: module-level cache resets but process is the same (V4 pattern). +type CredGlobals = typeof globalThis & { __omnirouteCredNoFileLogged?: boolean }; +function credGlobals(): CredGlobals { + return globalThis as CredGlobals; +} + /** * Resolve the path to provider-credentials.json * Priority: DATA_DIR env → ./data (project root) @@ -51,8 +57,9 @@ export function loadProviderCredentials(providers) { const credPath = resolveCredentialsPath(); if (!existsSync(credPath)) { - if (!cachedProviders) { + if (!credGlobals().__omnirouteCredNoFileLogged) { console.log("[CREDENTIALS] No external credentials file found, using defaults."); + credGlobals().__omnirouteCredNoFileLogged = true; } cachedProviders = providers; lastLoadTime = Date.now(); diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 1c8c80d583..a8f5b532fc 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1370,7 +1370,7 @@ export const REGISTRY: Record = { { 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": { diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index fd09aaad3f..80f1e6105e 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -1,5 +1,5 @@ import crypto from "crypto"; -import { BaseExecutor } from "./base.ts"; +import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts"; const MAX_RETRY_AFTER_MS = 10000; @@ -198,7 +198,7 @@ export class AntigravityExecutor extends BaseExecutor { return totalMs > 0 ? totalMs : null; } - async execute({ model, body, stream, credentials, signal, log }) { + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { const fallbackCount = this.getFallbackCount(); let lastError = null; let lastStatus = 0; @@ -208,6 +208,7 @@ export class AntigravityExecutor extends BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex); const headers = this.buildHeaders(credentials, stream); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const transformedBody = this.transformRequest(model, body, stream, credentials); // Initialize retry counter for this URL diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 19daaa80b2..d3a33060a3 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -58,8 +58,23 @@ export type ExecuteInput = { signal?: AbortSignal | null; log?: ExecutorLog | null; extendedContext?: boolean; + /** Merged after auth + CLI fingerprint headers (values override same-named defaults). */ + upstreamExtraHeaders?: Record | null; }; +/** Apply model-level extra upstream headers (e.g. Authentication, X-Custom-Auth). */ +export function mergeUpstreamExtraHeaders( + headers: Record, + extra?: Record | null +): void { + if (!extra) return; + for (const [k, v] of Object.entries(extra)) { + if (typeof k === "string" && k.length > 0 && typeof v === "string") { + headers[k] = v; + } + } +} + function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): AbortSignal { const controller = new AbortController(); @@ -204,7 +219,16 @@ export class BaseExecutor { return { status: response.status, message: bodyText || `HTTP ${response.status}` }; } - async execute({ model, body, stream, credentials, signal, log, extendedContext }: ExecuteInput) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + extendedContext, + upstreamExtraHeaders, + }: ExecuteInput) { const fallbackCount = this.getFallbackCount(); let lastError: unknown = null; let lastStatus = 0; @@ -258,6 +282,8 @@ export class BaseExecutor { bodyString = fingerprinted.bodyString; } + mergeUpstreamExtraHeaders(finalHeaders, upstreamExtraHeaders); + const fetchOptions: RequestInit = { method: "POST", headers: finalHeaders, @@ -289,7 +315,7 @@ export class BaseExecutor { continue; } - return { response, url, headers, transformedBody }; + return { response, url, headers: finalHeaders, transformedBody }; } catch (error) { // Distinguish timeout errors from other abort errors const err = error instanceof Error ? error : new Error(String(error)); diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 8577a54f16..425336d6e4 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -20,7 +20,7 @@ declare const EdgeRuntime: string | undefined; * @see cursorProtobuf.js for Protobuf encoding/decoding utilities */ -import { BaseExecutor } from "./base.ts"; +import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts"; import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts"; import { generateCursorBody, @@ -363,9 +363,10 @@ export class CursorExecutor extends BaseExecutor { }); } - async execute({ model, body, stream, credentials, signal, log }) { + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) { const url = this.buildUrl(); const headers = this.buildHeaders(credentials); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const transformedBody = this.transformRequest(model, body, stream, credentials); try { diff --git a/open-sse/executors/kiro.ts b/open-sse/executors/kiro.ts index d80917ec72..567d12f99d 100644 --- a/open-sse/executors/kiro.ts +++ b/open-sse/executors/kiro.ts @@ -1,5 +1,6 @@ import { BaseExecutor, + mergeUpstreamExtraHeaders, type ExecuteInput, type ExecutorLog, type ProviderCredentials, @@ -89,9 +90,18 @@ export class KiroExecutor extends BaseExecutor { /** * Custom execute for Kiro - handles AWS EventStream binary response */ - async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) { + async execute({ + model, + body, + stream, + credentials, + signal, + log, + upstreamExtraHeaders, + }: ExecuteInput) { const url = this.buildUrl(model, stream, 0); const headers = this.buildHeaders(credentials, stream); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); const transformedBody = this.transformRequest(model, body, stream, credentials); const response = await fetch(url, { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 40dbe6daf9..fe86cbb48f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -26,7 +26,11 @@ import { appendRequestLog, saveCallLog, } from "@/lib/usageDb"; -import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb"; +import { + getModelNormalizeToolCallId, + getModelPreserveOpenAIDeveloperRole, + getModelUpstreamExtraHeaders, +} from "@/lib/localDb"; import { getExecutor } from "../executors/index.ts"; import { parseCodexQuotaHeaders, @@ -280,6 +284,23 @@ export async function handleChatCore({ const modelTargetFormat = getModelTargetFormat(alias, resolvedModel); const targetFormat = modelTargetFormat || getTargetFormat(provider); + // Primary path: merge client model id + alias target so config on either key applies; resolved + // id wins on same header name. T5 family fallback uses only (nextModel, resolveModelAlias(next)) + // so A-model headers are not sent to B — see buildUpstreamHeadersForExecute. + const buildUpstreamHeadersForExecute = (modelToCall: string): Record => { + if (modelToCall === effectiveModel) { + return { + ...getModelUpstreamExtraHeaders(provider || "", model || "", sourceFormat), + ...getModelUpstreamExtraHeaders(provider || "", resolvedModel || "", sourceFormat), + }; + } + const r = resolveModelAlias(modelToCall); + return { + ...getModelUpstreamExtraHeaders(provider || "", modelToCall || "", sourceFormat), + ...getModelUpstreamExtraHeaders(provider || "", r || "", sourceFormat), + }; + }; + // Default to false unless client explicitly sets stream: true (OpenAI spec compliant) const acceptHeader = clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function" @@ -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) { diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 3aae2aa4fe..6a1ec17ec0 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -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) => ({ + 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}` }; } } diff --git a/open-sse/handlers/sseParser.ts b/open-sse/handlers/sseParser.ts index 577a13fbe6..f0b958383a 100644 --- a/open-sse/handlers/sseParser.ts +++ b/open-sse/handlers/sseParser.ts @@ -23,12 +23,19 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const first = chunks[0]; const contentParts = []; const reasoningParts = []; - const accumulatedToolCalls = new Map(); + type AccumulatedToolCall = { + id: string | null; + index: number; + type: string; + function: { name: string; arguments: string }; + }; + + const accumulatedToolCalls = new Map(); let unknownToolCallSeq = 0; let finishReason = "stop"; let usage = null; - const getToolCallKey = (toolCall: any) => { + const getToolCallKey = (toolCall: Record) => { if (toolCall?.id) return `id:${toolCall.id}`; if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`; unknownToolCallSeq += 1; diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index b18ad52255..d570bc9ee4 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -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"; diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 4b002e36da..14231ad544 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -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 */ diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 74e1e84e48..987d309dbd 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -57,6 +57,8 @@ type TranslateState = ReturnType & { accumulatedContent?: string; }; +type UsageTokenRecord = Record; + 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; - const eu = extracted as Record; + 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"; diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 40832c341a..825945fb2f 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -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 = { diff --git a/scripts/run-next.mjs b/scripts/run-next.mjs index db71a94861..cbf40715b7 100644 --- a/scripts/run-next.mjs +++ b/scripts/run-next.mjs @@ -9,15 +9,15 @@ import { bootstrapEnv } from "./bootstrap-env.mjs"; const mode = process.argv[2] === "start" ? "start" : "dev"; -const runtimePorts = resolveRuntimePorts(); +// Load .env / server.env first so PORT / DASHBOARD_PORT from files affect --port below. +const env = bootstrapEnv(); +const runtimePorts = resolveRuntimePorts(env); const { dashboardPort } = runtimePorts; -// Auto-generate secrets on first run, merge .env + process.env -const env = bootstrapEnv(); - const args = ["./node_modules/next/dist/bin/next", mode, "--port", String(dashboardPort)]; -// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 to use Turbopack (faster dev). -if (mode === "dev" && process.env.OMNIROUTE_USE_TURBOPACK !== "1") { +// Default: use webpack (stable). Set OMNIROUTE_USE_TURBOPACK=1 in .env for Turbopack (faster dev). +// Must read merged `env` from bootstrap — .env is not applied to process.env in the launcher. +if (mode === "dev" && env.OMNIROUTE_USE_TURBOPACK !== "1") { args.splice(2, 0, "--webpack"); } diff --git a/scripts/run-standalone.mjs b/scripts/run-standalone.mjs index 0bce22522f..095f89b482 100644 --- a/scripts/run-standalone.mjs +++ b/scripts/run-standalone.mjs @@ -7,10 +7,8 @@ import { } from "./runtime-env.mjs"; import { bootstrapEnv } from "./bootstrap-env.mjs"; -const runtimePorts = resolveRuntimePorts(); - -// Auto-generate secrets on first run, merge .env + process.env const env = bootstrapEnv(); +const runtimePorts = resolveRuntimePorts(env); spawnWithForwardedSignals("node", ["server.js"], { stdio: "inherit", diff --git a/scripts/runtime-env.mjs b/scripts/runtime-env.mjs index 04753009ce..b3e761bf15 100644 --- a/scripts/runtime-env.mjs +++ b/scripts/runtime-env.mjs @@ -5,10 +5,14 @@ export function parsePort(value, fallback) { return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; } -export function resolveRuntimePorts() { - const basePort = parsePort(process.env.PORT || "20128", 20128); - const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort); - const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort); +/** + * @param {NodeJS.ProcessEnv | Record} [fromEnv] + * Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn. + */ +export function resolveRuntimePorts(fromEnv = process.env) { + const basePort = parsePort(fromEnv.PORT || "20128", 20128); + const apiPort = parsePort(fromEnv.API_PORT || String(basePort), basePort); + const dashboardPort = parsePort(fromEnv.DASHBOARD_PORT || String(basePort), basePort); return { basePort, apiPort, dashboardPort }; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c5a075530f..f1be9646e3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react"; +import { createPortal } from "react-dom"; import { useNotificationStore } from "@/store/notificationStore"; import PropTypes from "prop-types"; import { useParams, useRouter } from "next/navigation"; @@ -39,9 +40,22 @@ import { type CompatByProtocolMap = Partial< Record< ModelCompatProtocolKey, - { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean } + { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + } > >; + +/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ +type ModelCompatSavePatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; +}; + type CompatModelRow = { id?: string; name?: string; @@ -50,6 +64,7 @@ type CompatModelRow = { supportedEndpoints?: string[]; normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; compatByProtocol?: CompatByProtocolMap; }; @@ -155,24 +170,115 @@ function anyNoPreserveCompatBadge( return false; } +function upstreamHeadersRecordsEqual( + a: Record, + b: Record +): boolean { + const ka = Object.keys(a).sort(); + const kb = Object.keys(b).sort(); + if (ka.length !== kb.length) return false; + return ka.every((k, i) => k === kb[i] && a[k] === b[k]); +} + +type HeaderDraftRow = { id: string; name: string; value: string }; + +const UPSTREAM_HEADERS_UI_MAX = 16; + +function recordToHeaderRows(rec: Record, genId: () => string): HeaderDraftRow[] { + const entries = Object.entries(rec).filter(([k]) => k.trim()); + if (entries.length === 0) return [{ id: genId(), name: "", value: "" }]; + return entries.map(([name, value]) => ({ id: genId(), name, value })); +} + +function headerRowsToRecord(rows: HeaderDraftRow[]): Record { + const out: Record = {}; + for (const r of rows) { + const k = r.name.trim(); + if (!k) continue; + out[k] = r.value; + } + return out; +} + +type ProviderModelsApiErrorBody = { + error?: { + message?: string; + details?: Array<{ field?: string; message?: string }>; + }; +}; + +async function formatProviderModelsErrorResponse(res: Response): Promise { + try { + const data = (await res.json()) as ProviderModelsApiErrorBody; + const err = data?.error; + if (Array.isArray(err?.details) && err.details.length > 0) { + return err.details + .map((d) => { + const f = typeof d.field === "string" && d.field ? d.field : "?"; + const m = typeof d.message === "string" ? d.message : ""; + return m ? `${f}: ${m}` : f; + }) + .join("; "); + } + if (typeof err?.message === "string" && err.message.trim()) { + return err.message.trim(); + } + } catch { + /* ignore */ + } + const st = res.statusText?.trim(); + return st || `HTTP ${res.status}`; +} + +function effectiveUpstreamHeadersForProtocol( + modelId: string, + protocol: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): Record { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const base: Record = {}; + if (c?.upstreamHeaders && typeof c.upstreamHeaders === "object") { + Object.assign(base, c.upstreamHeaders); + } else if (o?.upstreamHeaders && typeof o.upstreamHeaders === "object") { + Object.assign(base, o.upstreamHeaders); + } + const pc = getProtoSlice(c, o, protocol); + if (pc?.upstreamHeaders && typeof pc.upstreamHeaders === "object") { + Object.assign(base, pc.upstreamHeaders); + } + return base; +} + +function anyUpstreamHeadersBadge( + modelId: string, + customMap: CompatModelMap, + overrideMap: CompatModelMap +): boolean { + const c = customMap.get(modelId); + const o = overrideMap.get(modelId); + const nonempty = (u: unknown) => + u && typeof u === "object" && !Array.isArray(u) && Object.keys(u as object).length > 0; + if (nonempty(c?.upstreamHeaders) || nonempty(o?.upstreamHeaders)) return true; + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if (nonempty(pc?.upstreamHeaders)) return true; + } + return false; +} + interface ModelRowProps { model: { id: string }; fullModel: string; - alias?: string; copied?: string; onCopy: (text: string, key: string) => void; t: (key: string, values?: Record) => string; showDeveloperToggle?: boolean; effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; - saveModelCompatFlags: ( - modelId: string, - patch: { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - compatByProtocol?: CompatByProtocolMap; - } - ) => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; compatDisabled?: boolean; } @@ -186,14 +292,8 @@ interface PassthroughModelRowProps { showDeveloperToggle?: boolean; effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; - saveModelCompatFlags: ( - modelId: string, - patch: { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - compatByProtocol?: CompatByProtocolMap; - } - ) => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => void; + getUpstreamHeadersRecord: (protocol: string) => Record; compatDisabled?: boolean; } @@ -207,6 +307,7 @@ interface PassthroughModelsSectionProps { t: (key: string, values?: Record) => string; effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; saveModelCompatFlags: ( modelId: string, flags: { @@ -243,6 +344,7 @@ interface CompatibleModelsSectionProps { t: (key: string, values?: Record) => string; effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; + getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; saveModelCompatFlags: ( modelId: string, flags: { @@ -376,6 +478,7 @@ function ModelCompatPopover({ t, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, onCompatPatch, showDeveloperToggle = true, disabled, @@ -383,11 +486,13 @@ function ModelCompatPopover({ t: (key: string) => string; effectiveModelNormalize: (protocol: string) => boolean; effectiveModelPreserveDeveloper: (protocol: string) => boolean; + getUpstreamHeadersRecord: (protocol: string) => Record; onCompatPatch: ( protocol: string, payload: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; } ) => void; showDeveloperToggle?: boolean; @@ -395,15 +500,85 @@ function ModelCompatPopover({ }) { const [open, setOpen] = useState(false); const [protocol, setProtocol] = useState(MODEL_COMPAT_PROTOCOL_KEYS[0]); + const [headerRows, setHeaderRows] = useState([]); + const [valuePeekRowId, setValuePeekRowId] = useState(null); + const [valueFocusRowId, setValueFocusRowId] = useState(null); const ref = useRef(null); const panelRef = useRef(null); + const [portalPanelRect, setPortalPanelRect] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + const headerRowIdRef = useRef(0); + const headerRowsRef = useRef([]); + headerRowsRef.current = headerRows; + + const genHeaderRowId = () => { + headerRowIdRef.current += 1; + return `uh-${headerRowIdRef.current}`; + }; const normalizeToolCallId = effectiveModelNormalize(protocol); const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol); const devToggle = showDeveloperToggle && protocol !== "claude"; - // Click-outside: check both trigger and panel so that if the panel is ever rendered - // in a portal (outside this subtree), clicks inside the panel still do not close it. + const tryCommitHeaderRows = useCallback( + (rows: HeaderDraftRow[]) => { + const parsed = headerRowsToRecord(rows); + const current = getUpstreamHeadersRecord(protocol); + if (upstreamHeadersRecordsEqual(parsed, current)) return; + onCompatPatch(protocol, { upstreamHeaders: parsed }); + }, + [getUpstreamHeadersRecord, onCompatPatch, protocol] + ); + + const onHeaderFieldBlur = useCallback(() => { + queueMicrotask(() => tryCommitHeaderRows(headerRowsRef.current)); + }, [tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + return () => { + tryCommitHeaderRows(headerRowsRef.current); + }; + }, [open, tryCommitHeaderRows]); + + useEffect(() => { + if (!open) return; + const rec = getUpstreamHeadersRecord(protocol); + setHeaderRows(recordToHeaderRows(rec, genHeaderRowId)); + // Only re-load rows when opening or switching protocol — not when the parent passes a new + // inline callback every render (would wipe in-progress edits). + // eslint-disable-next-line react-hooks/exhaustive-deps -- see above + }, [open, protocol]); + + useEffect(() => { + setValuePeekRowId(null); + setValueFocusRowId(null); + }, [open, protocol]); + + const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length; + const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX; + + const updateHeaderRow = (id: string, patch: Partial>) => { + setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))); + }; + + const addHeaderRow = () => { + if (!canAddHeaderRow) return; + setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]); + }; + + const removeHeaderRow = (id: string) => { + setHeaderRows((prev) => { + const next = prev.filter((r) => r.id !== id); + const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next; + queueMicrotask(() => tryCommitHeaderRows(normalized)); + return normalized; + }); + }; + useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { @@ -416,66 +591,189 @@ function ModelCompatPopover({ return () => document.removeEventListener("mousedown", onDocClick); }, [open]); + const updatePortalPanelRect = useCallback(() => { + if (!open || !ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const margin = 10; + const width = Math.min(window.innerWidth - 2 * margin, 24 * 16); + let left = rect.right - width; + left = Math.max(margin, Math.min(left, window.innerWidth - width - margin)); + setPortalPanelRect({ top: rect.bottom + 8, left, width }); + }, [open]); + + useLayoutEffect(() => { + if (!open) { + setPortalPanelRect(null); + return; + } + updatePortalPanelRect(); + window.addEventListener("resize", updatePortalPanelRect); + window.addEventListener("scroll", updatePortalPanelRect, true); + return () => { + window.removeEventListener("resize", updatePortalPanelRect); + window.removeEventListener("scroll", updatePortalPanelRect, true); + }; + }, [open, updatePortalPanelRect]); + + const panelChromeClass = + "flex max-h-[min(82vh,42rem)] flex-col overflow-hidden rounded-xl border-2 border-zinc-200 bg-white shadow-2xl dark:border-zinc-600 dark:bg-zinc-950"; + return ( -
+
- {open && ( -
-

- {t("compatAdjustmentsTitle")} -

-

{t("compatProtocolHint")}

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

{t("compatAdjustmentsTitle")}

+

+ {t("compatProtocolHint")} +

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

+ {t("compatUpstreamHeadersHint")} +

+
+
+ {t("compatUpstreamHeaderName")} + {t("compatUpstreamHeaderValue")} + +
+ {headerRows.map((row) => ( +
+ updateHeaderRow(row.id, { name: e.target.value })} + onBlur={onHeaderFieldBlur} + disabled={disabled} + placeholder="Authentication" + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs font-mono dark:bg-zinc-900" + autoComplete="off" + /> +
setValuePeekRowId(row.id)} + onMouseLeave={() => + setValuePeekRowId((cur) => (cur === row.id ? null : cur)) + } + > + updateHeaderRow(row.id, { value: e.target.value })} + onFocus={() => setValueFocusRowId(row.id)} + onBlur={() => { + setValueFocusRowId((cur) => (cur === row.id ? null : cur)); + onHeaderFieldBlur(); + }} + disabled={disabled} + placeholder="•••" + className="gap-0 min-w-0" + inputClassName="h-9 bg-white py-1.5 px-2 text-xs dark:bg-zinc-900" + autoComplete="off" + spellCheck={false} + /> +
+ +
+ ))} +
+ +
+
+
, + document.body + )}
); } @@ -1196,14 +1494,13 @@ export default function ProviderDetailPage() { protocol = MODEL_COMPAT_PROTOCOL_KEYS[0] ) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap); - const saveModelCompatFlags = async ( - modelId: string, - patch: { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - compatByProtocol?: CompatByProtocolMap; - } - ) => { + const getUpstreamHeadersRecordForModel = useCallback( + (modelId: string, protocol: string) => + effectiveUpstreamHeadersForProtocol(modelId, protocol, customMap, overrideMap), + [customMap, overrideMap] + ); + + const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => { setCompatSavingModelId(modelId); try { const c = customMap.get(modelId) as Record | undefined; @@ -1211,7 +1508,8 @@ export default function ProviderDetailPage() { const onlyCompatByProtocol = patch.compatByProtocol && patch.normalizeToolCallId === undefined && - patch.preserveOpenAIDeveloperRole === undefined; + patch.preserveOpenAIDeveloperRole === undefined && + !("upstreamHeaders" in patch); if (c) { if (onlyCompatByProtocol) { @@ -1253,7 +1551,10 @@ export default function ProviderDetailPage() { body: JSON.stringify(body), }); if (!res.ok) { - notify.error(t("failedSaveCustomModel")); + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); return; } } catch { @@ -1286,6 +1587,7 @@ export default function ProviderDetailPage() { t={t} effectiveModelNormalize={effectiveModelNormalize} effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel} saveModelCompatFlags={saveModelCompatFlags} compatSavingModelId={compatSavingModelId} onModelsChanged={fetchProviderModelMeta} @@ -1319,6 +1621,7 @@ export default function ProviderDetailPage() { t={t} effectiveModelNormalize={effectiveModelNormalize} effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel} saveModelCompatFlags={saveModelCompatFlags} compatSavingModelId={compatSavingModelId} /> @@ -1356,23 +1659,18 @@ export default function ProviderDetailPage() { {importButton}
{models.map((model) => { - const fullModel = `${providerStorageAlias}/${model.id}`; - const oldFormatModel = `${providerId}/${model.id}`; - const existingAlias = Object.entries(modelAliases).find( - ([, m]) => m === fullModel || m === oldFormatModel - )?.[0]; return ( getUpstreamHeadersRecordForModel(model.id, p)} saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === model.id} /> @@ -2008,28 +2306,28 @@ export default function ProviderDetailPage() { function ModelRow({ model, fullModel, - alias, copied, onCopy, t, showDeveloperToggle = true, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, saveModelCompatFlags, compatDisabled, }: ModelRowProps) { return ( -
-
- +
+
+ smart_toy - + {fullModel}
- effectiveModelNormalize(model.id, p)} - effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} - onCompatPatch={(protocol, payload) => - saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) - } - showDeveloperToggle={showDeveloperToggle} - disabled={compatDisabled} - /> +
+ effectiveModelNormalize(model.id, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) + } + showDeveloperToggle={showDeveloperToggle} + disabled={compatDisabled} + /> +
); } @@ -2056,13 +2357,13 @@ ModelRow.propTypes = { id: PropTypes.string.isRequired, }).isRequired, fullModel: PropTypes.string.isRequired, - alias: PropTypes.string, copied: PropTypes.string, onCopy: PropTypes.func.isRequired, t: PropTypes.func, showDeveloperToggle: PropTypes.bool, effectiveModelNormalize: PropTypes.func.isRequired, effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatDisabled: PropTypes.bool, }; @@ -2077,6 +2378,7 @@ function PassthroughModelsSection({ t, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, saveModelCompatFlags, compatSavingModelId, }: PassthroughModelsSectionProps) { @@ -2161,6 +2463,7 @@ function PassthroughModelsSection({ showDeveloperToggle effectiveModelNormalize={effectiveModelNormalize} effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecord(modelId, p)} saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === modelId} /> @@ -2181,6 +2484,7 @@ PassthroughModelsSection.propTypes = { t: PropTypes.func.isRequired, effectiveModelNormalize: PropTypes.func.isRequired, effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatSavingModelId: PropTypes.string, }; @@ -2195,24 +2499,25 @@ function PassthroughModelRow({ showDeveloperToggle = true, effectiveModelNormalize, effectiveModelPreserveDeveloper, + getUpstreamHeadersRecord, saveModelCompatFlags, compatDisabled, }: PassthroughModelRowProps) { return ( -
-
- +
+
+ smart_toy -
-

{modelId}

-
- +
+

{modelId}

+
+ {fullModel}
-
-
+
effectiveModelNormalize(modelId, p)} effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)} + getUpstreamHeadersRecord={getUpstreamHeadersRecord} onCompatPatch={(protocol, payload) => saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } }) } showDeveloperToggle={showDeveloperToggle} disabled={compatDisabled} /> +
); @@ -2255,6 +2561,7 @@ PassthroughModelRow.propTypes = { showDeveloperToggle: PropTypes.bool, effectiveModelNormalize: PropTypes.func.isRequired, effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + getUpstreamHeadersRecord: PropTypes.func.isRequired, saveModelCompatFlags: PropTypes.func.isRequired, compatDisabled: PropTypes.bool, }; @@ -2381,7 +2688,10 @@ function CustomModelsSection({ body: JSON.stringify({ provider: providerId, modelId, ...patch }), }); if (!res.ok) { - notify.error(t("failedSaveCustomModel")); + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); return; } } catch { @@ -2422,7 +2732,8 @@ function CustomModelsSection({ }); if (!res.ok) { - throw new Error("Failed to save model endpoint settings"); + const detail = await formatProviderModelsErrorResponse(res); + throw new Error(detail || "Failed to save model endpoint settings"); } await fetchCustomModels(); @@ -2431,7 +2742,9 @@ function CustomModelsSection({ cancelEdit(); } catch (e) { console.error("Failed to save custom model:", e); - notify.error("Failed to save model endpoint settings"); + notify.error( + e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings" + ); } finally { setSavingModelId(null); } @@ -2542,10 +2855,14 @@ function CustomModelsSection({ return (
- tune -
+ {editingModelId !== model.id && ( + + tune + + )} +

{model.name || model.id}

@@ -2596,32 +2913,39 @@ function CustomModelsSection({ {t("compatBadgeNoPreserve")} )} + {anyUpstreamHeadersBadge(model.id, customMap, overrideMap) && ( + + {t("compatBadgeUpstreamHeaders")} + + )}
{editingModelId === model.id && ( -
-
-
+
+
+
- -
- +
+ Supported Endpoints -
+
{["chat", "embeddings", "images", "audio"].map((ep) => (
-
-
- - effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap) - } - effectiveModelPreserveDeveloper={(p) => - effectivePreserveForProtocol(model.id, p, customMap, overrideMap) - } - onCompatPatch={(protocol, payload) => - saveCustomCompat(model.id, { - compatByProtocol: { [protocol]: payload }, - }) - } - showDeveloperToggle - disabled={savingModelId === model.id} - /> -
-
- - +
+ + +
)}
-
+
+ + effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap) + } + effectiveModelPreserveDeveloper={(p) => + effectivePreserveForProtocol(model.id, p, customMap, overrideMap) + } + getUpstreamHeadersRecord={(p) => + effectiveUpstreamHeadersForProtocol(model.id, p, customMap, overrideMap) + } + onCompatPatch={(protocol, payload) => + saveCustomCompat(model.id, { + compatByProtocol: { [protocol]: payload }, + }) + } + showDeveloperToggle + disabled={savingModelId === model.id} + />