diff --git a/ZWS_README_V5.md b/ZWS_README_V5.md new file mode 100644 index 0000000000..9e16b68757 --- /dev/null +++ b/ZWS_README_V5.md @@ -0,0 +1,332 @@ +# ZWS_README_V5 — 按协议配置模型兼容性 + 前端性能优化 + +V4 内容(HMR 泄漏修复、Edge 警告消除、测试稳定性)已完成;V5 在 V4 基础上实现**按协议维度配置模型兼容性**,新增前端查找性能优化与类型安全改进。 + +--- + +## 一、如何发现问题 + +### 现象 + +- 同一模型被 **OpenAI Chat Completions**、**OpenAI Responses API**、**Anthropic Messages** 三种客户端请求形态调用时,V2 的兼容性开关(工具 ID 9 位、不保留 developer 角色)是**全局生效**的——无法为不同协议设置不同的兼容策略。 +- 例如:用户希望 OpenAI Responses API 请求时不保留 developer 角色(MiniMax 422 修复),但 OpenAI Chat Completions 请求时保留。V2 下只能二选一。 +- 前端兼容性弹层未标明当前配置对应哪种协议,容易误导。 +- 前端组件中 `Array.find()` 在每次渲染时对 customModels 和 modelCompatOverrides 做 O(n) 线性扫描,模型数量多时存在不必要的性能开销。 +- `ModelCompatPatch` 类型定义与运行时逻辑不一致:`preserveOpenAIDeveloperRole` 字段需要支持 `null`(表示取消设置/恢复默认),但类型仅允许 `boolean`。 + +### 排查过程 + +1. **需求分析**:梳理 `detectFormat(body)` 返回的三种协议键(`openai`、`openai-responses`、`claude`),确认每种协议对 developer 角色和 tool call ID 的需求不同。 +2. **数据模型设计**:在现有 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 顶层字段基础上,设计 `compatByProtocol` 嵌套结构,按协议键细分。 +3. **构建问题**:客户端 `"use client"` 组件直接从 `@/lib/localDb` 引入常量时,间接拉入了 `node:crypto`(经由 `db/proxies.ts`),触发 Webpack `UnhandledSchemeError`。需将常量拆到 `shared/` 层。 +4. **前端性能**:通过 React DevTools 和代码审计发现 `effectiveNormalizeForProtocol` 等函数每次调用都对数组做 `find()`,在渲染列表时存在 O(n²) 的隐患。 + +--- + +## 二、根因分析 + +### 根因 1(P0):兼容选项无协议维度 + +V2 的 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 存储在模型级别的顶层字段,无法区分请求来源协议。`chatCore.ts` 中的 getter 函数只接收 `(providerId, modelId)` 两个参数,不感知当前请求的 `sourceFormat`。 + +**影响**:跨协议场景下用户只能设置一个全局值,无法精确控制。 + +### 根因 2(P1):客户端构建拉入 Node.js 模块 + +`page.tsx`("use client")→ `@/lib/localDb` → `db/proxies.ts` → `import { randomUUID } from "node:crypto"` + +Webpack 无法处理 `node:` URI scheme,报 `UnhandledSchemeError`。虽然 V4 已将 `node:crypto` → `crypto` 修复了 `proxies.ts`,但 `localDb.ts` 的 barrel export 链仍然存在风险——客户端组件不应引入任何可能传递到 Node.js 模块的路径。 + +### 根因 3(P2):前端查找性能 + +`effectiveNormalizeForProtocol`、`effectivePreserveForProtocol`、`anyNormalizeCompatBadge`、`anyNoPreserveCompatBadge` 四个函数每次调用都使用 `Array.find()` 在 `customModels` 和 `modelCompatOverrides` 数组中查找目标模型。在模型列表渲染时,每个模型行会调用多次这些函数,导致 O(n × m) 的查找开销(n = 模型数,m = 每行调用次数)。 + +### 根因 4(P2):类型定义与运行时不一致 + +```typescript +// V3 暂存区版本(有问题) +export type ModelCompatPatch = Partial< + Pick< + ModelCompatOverride, + "normalizeToolCallId" | "preserveOpenAIDeveloperRole" | "compatByProtocol" + > +>; +``` + +`ModelCompatOverride.preserveOpenAIDeveloperRole` 类型为 `boolean | undefined`,但 `mergeModelCompatOverride()` 内部有 `=== null` 判断(用于取消设置/恢复默认),类型层面无法覆盖。 + +--- + +## 三、修复方案 + +### 修复 1:`compatByProtocol` 存储与读取(models.ts) + +**新增数据结构**: + +```typescript +type CompatByProtocolMap = Partial>; + +export type ModelCompatOverride = { + id: string; + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + compatByProtocol?: CompatByProtocolMap; // 新增 +}; +``` + +**读取优先级链**(适用于 `getModelNormalizeToolCallId` 和 `getModelPreserveOpenAIDeveloperRole`): + +``` +compatByProtocol[sourceFormat].field → 顶层 field → 默认值 +``` + +1. 若 `sourceFormat` 属于已知协议键(`openai` / `openai-responses` / `claude`),且 `compatByProtocol[sourceFormat]` 中存在目标字段,使用该值。 +2. 否则回退到顶层字段。 +3. 顶层字段也不存在时使用默认值(normalizeToolCallId=false,preserveOpenAIDeveloperRole=undefined)。 + +**深度合并逻辑** `deepMergeCompatByProtocol()`: + +- 对每个协议键,逐字段合并而非覆盖。 +- `normalizeToolCallId=false` 时删除该字段(不存储 false,减少冗余)。 +- 合并后若整个协议条目为空对象,删除该协议条目。 +- 协议键通过 `isCompatProtocolKey()` 白名单校验,拒绝未知键。 + +**Getter 签名扩展**(向后兼容,第三参数可选): + +```typescript +export function getModelNormalizeToolCallId( + providerId: string, + modelId: string, + sourceFormat?: string | null +): boolean; + +export function getModelPreserveOpenAIDeveloperRole( + providerId: string, + modelId: string, + sourceFormat?: string | null +): boolean | undefined; +``` + +**优点**: + +- 完全向后兼容:无 `sourceFormat` 参数时行为与 V2 完全一致。 +- 协议键白名单校验防止存储污染。 +- 深度合并保留未变更协议的配置。 + +**缺点/注意**: + +- JSON 存储体积略增(每个模型最多增加 3 个协议条目)。 +- 新增 ~80 行 TypeScript 代码。 + +### 修复 2:请求管线传入 sourceFormat(chatCore.ts) + +```typescript +const normalizeToolCallId = getModelNormalizeToolCallId( + provider || "", + model || "", + sourceFormat // 新增第三参 +); +const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole( + provider || "", + model || "", + sourceFormat // 新增第三参 +); +``` + +`sourceFormat` 由已有的 `detectFormat(body)` 返回,无需新增检测逻辑。 + +**优点**: + +- 改动仅 2 行,精准传参。 +- 不影响其他 handler(embeddings、imageGeneration 等不涉及 developer 角色和 tool call ID)。 + +### 修复 3:API 路由支持 compatByProtocol(route.ts) + +**PUT 请求体扩展**: + +- 解构 `compatByProtocol` 并传入 `updateCustomModel()`。 +- `compatOnly` 判断扩展:仅含 `provider` + `modelId` + 兼容字段时,走 `mergeModelCompatOverride()` 路径。 +- 使用 `ModelCompatPatch` 类型替代行内类型定义,统一类型来源。 + +**Zod 校验 schema**: + +```typescript +const modelCompatPerProtocolSchema = z.object({ + normalizeToolCallId: z.boolean().optional(), + preserveOpenAIDeveloperRole: z.boolean().optional(), +}).strict(); // strict: 拒绝额外字段 + +compatByProtocol: z + .record(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema) + .optional(), +``` + +**优点**: + +- `.strict()` 防止客户端注入额外字段。 +- `z.enum()` 限定协议键,与后端白名单一致。 +- 仅传 `compatByProtocol` 即可更新,前端无需拼装完整模型对象。 + +### 修复 4:客户端安全常量拆分(modelCompat.ts) + +**新增** `src/shared/constants/modelCompat.ts`: + +```typescript +export const MODEL_COMPAT_PROTOCOL_KEYS = ["openai", "openai-responses", "claude"] as const; +export type ModelCompatProtocolKey = (typeof MODEL_COMPAT_PROTOCOL_KEYS)[number]; +``` + +- 不依赖 Node.js / DB 代码,客户端组件可安全引入。 +- `models.ts` 从此模块引入并再导出。 +- `localDb.ts` 新增 `ModelCompatPatch` 类型导出(供 route.ts 使用),不导出协议常量。 +- `page.tsx` 改为从 `@/shared/constants/modelCompat` 引入。 + +**优点**: + +- 彻底切断客户端 → localDb → db → proxies → node:crypto 的依赖链。 +- 协议键定义单一来源(Single Source of Truth)。 + +### 修复 5:前端协议选择器与按协议解析(page.tsx) + +**ModelCompatPopover 重构**: + +- 新增协议下拉选择器(` 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" + > + {MODEL_COMPAT_PROTOCOL_KEYS.map((p) => ( + + ))} +
onCompatPatch(protocol, { normalizeToolCallId: v })} disabled={disabled} /> - {showDeveloperToggle && ( - <> - {/* Inversion: Toggle checked = "do not preserve" (UI). onPreserveChange(val) = value to store (true = preserve, false = do not preserve), so we pass !checked. */} - onPreserveChange(!checked)} - disabled={disabled} - /> - + {devToggle && ( + + onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) + } + disabled={disabled} + /> )}
@@ -350,12 +516,8 @@ export default function ProviderDetailPage() { importedCount: 0, }); const [modelMeta, setModelMeta] = useState<{ - customModels: Record[]; - modelCompatOverrides: { - id: string; - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - }[]; + customModels: CompatModelRow[]; + modelCompatOverrides: Array; }>({ customModels: [], modelCompatOverrides: [] }); const [compatSavingModelId, setCompatSavingModelId] = useState(null); @@ -1020,48 +1182,91 @@ export default function ProviderDetailPage() { const canImportModels = connections.some((conn) => conn.isActive !== false); - const effectiveModelNormalize = (modelId: string) => { - const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as - | { normalizeToolCallId?: boolean } - | undefined; - if (c) return Boolean(c.normalizeToolCallId); - const o = modelMeta.modelCompatOverrides.find((e) => e.id === modelId); - return Boolean(o?.normalizeToolCallId); - }; + const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]); + const overrideMap = useMemo( + () => buildCompatMap(modelMeta.modelCompatOverrides), + [modelMeta.modelCompatOverrides] + ); - const effectiveModelPreserveDeveloper = (modelId: string) => { - const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as - | Record - | undefined; - if (c && Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")) { - return Boolean(c.preserveOpenAIDeveloperRole); - } - const o = modelMeta.modelCompatOverrides.find((e) => e.id === modelId); - if (o && Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole")) { - return Boolean(o.preserveOpenAIDeveloperRole); - } - return true; - }; + const effectiveModelNormalize = (modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) => + effectiveNormalizeForProtocol(modelId, protocol, customMap, overrideMap); + + const effectiveModelPreserveDeveloper = ( + modelId: string, + protocol = MODEL_COMPAT_PROTOCOL_KEYS[0] + ) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap); const saveModelCompatFlags = async ( modelId: string, - patch: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean } + patch: { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + compatByProtocol?: CompatByProtocolMap; + } ) => { setCompatSavingModelId(modelId); try { - const body: Record = { provider: providerId, modelId, ...patch }; + const c = customMap.get(modelId) as Record | undefined; + let body: Record; + const onlyCompatByProtocol = + patch.compatByProtocol && + patch.normalizeToolCallId === undefined && + patch.preserveOpenAIDeveloperRole === undefined; + + if (c) { + if (onlyCompatByProtocol) { + body = { + provider: providerId, + modelId, + compatByProtocol: patch.compatByProtocol, + }; + } else { + body = { + provider: providerId, + modelId, + modelName: (c.name as string) || modelId, + source: (c.source as string) || "manual", + apiFormat: (c.apiFormat as string) || "chat-completions", + supportedEndpoints: + Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length + ? c.supportedEndpoints + : ["chat"], + normalizeToolCallId: + patch.normalizeToolCallId !== undefined + ? patch.normalizeToolCallId + : Boolean(c.normalizeToolCallId), + preserveOpenAIDeveloperRole: + patch.preserveOpenAIDeveloperRole !== undefined + ? patch.preserveOpenAIDeveloperRole + : Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") + ? Boolean(c.preserveOpenAIDeveloperRole) + : true, + }; + if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol; + } + } else { + body = { provider: providerId, modelId, ...patch }; + } const res = await fetch("/api/provider-models", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (res.ok) await fetchProviderModelMeta(); - else notify.error(t("failedSaveCustomModel")); + if (!res.ok) { + notify.error(t("failedSaveCustomModel")); + return; + } } catch { notify.error(t("failedSaveCustomModel")); + return; } finally { setCompatSavingModelId(null); } + try { + await fetchProviderModelMeta(); + } catch { + /* refresh failure is non-critical — data was already saved */ + } }; const renderModelsSection = () => { @@ -1166,14 +1371,9 @@ export default function ProviderDetailPage() { onCopy={copy} t={t} showDeveloperToggle - normalizeToolCallId={effectiveModelNormalize(model.id)} - preserveDeveloperRole={effectiveModelPreserveDeveloper(model.id)} - onNormalizeChange={(v) => - saveModelCompatFlags(model.id, { normalizeToolCallId: v }) - } - onPreserveChange={(v) => - saveModelCompatFlags(model.id, { preserveOpenAIDeveloperRole: v }) - } + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === model.id} /> ); @@ -1813,10 +2013,9 @@ function ModelRow({ onCopy, t, showDeveloperToggle = true, - normalizeToolCallId, - preserveDeveloperRole, - onNormalizeChange, - onPreserveChange, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + saveModelCompatFlags, compatDisabled, }: ModelRowProps) { return ( @@ -1840,11 +2039,12 @@ function ModelRow({ effectiveModelNormalize(model.id, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } }) + } showDeveloperToggle={showDeveloperToggle} - onNormalizeChange={onNormalizeChange} - onPreserveChange={onPreserveChange} disabled={compatDisabled} /> @@ -1861,10 +2061,9 @@ ModelRow.propTypes = { onCopy: PropTypes.func.isRequired, t: PropTypes.func, showDeveloperToggle: PropTypes.bool, - normalizeToolCallId: PropTypes.bool, - preserveDeveloperRole: PropTypes.bool, - onNormalizeChange: PropTypes.func, - onPreserveChange: PropTypes.func, + effectiveModelNormalize: PropTypes.func.isRequired, + effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + saveModelCompatFlags: PropTypes.func.isRequired, compatDisabled: PropTypes.bool, }; @@ -1960,12 +2159,9 @@ function PassthroughModelsSection({ onDeleteAlias={() => onDeleteAlias(alias)} t={t} showDeveloperToggle - normalizeToolCallId={effectiveModelNormalize(modelId)} - preserveDeveloperRole={effectiveModelPreserveDeveloper(modelId)} - onNormalizeChange={(v) => saveModelCompatFlags(modelId, { normalizeToolCallId: v })} - onPreserveChange={(v) => - saveModelCompatFlags(modelId, { preserveOpenAIDeveloperRole: v }) - } + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === modelId} /> ))} @@ -1997,10 +2193,9 @@ function PassthroughModelRow({ onDeleteAlias, t, showDeveloperToggle = true, - normalizeToolCallId, - preserveDeveloperRole, - onNormalizeChange, - onPreserveChange, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + saveModelCompatFlags, compatDisabled, }: PassthroughModelRowProps) { return ( @@ -2037,11 +2232,12 @@ function PassthroughModelRow({
effectiveModelNormalize(modelId, p)} + effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)} + onCompatPatch={(protocol, payload) => + saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } }) + } showDeveloperToggle={showDeveloperToggle} - onNormalizeChange={onNormalizeChange} - onPreserveChange={onPreserveChange} disabled={compatDisabled} />
@@ -2057,10 +2253,9 @@ PassthroughModelRow.propTypes = { onDeleteAlias: PropTypes.func.isRequired, t: PropTypes.func, showDeveloperToggle: PropTypes.bool, - normalizeToolCallId: PropTypes.bool, - preserveDeveloperRole: PropTypes.bool, - onNormalizeChange: PropTypes.func, - onPreserveChange: PropTypes.func, + effectiveModelNormalize: PropTypes.func.isRequired, + effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + saveModelCompatFlags: PropTypes.func.isRequired, compatDisabled: PropTypes.bool, }; @@ -2075,7 +2270,10 @@ function CustomModelsSection({ }: CustomModelsSectionProps) { const t = useTranslations("providers"); const notify = useNotificationStore(); - const [customModels, setCustomModels] = useState([]); + const [customModels, setCustomModels] = useState([]); + const [modelCompatOverrides, setModelCompatOverrides] = useState< + Array + >([]); const [newModelId, setNewModelId] = useState(""); const [newModelName, setNewModelName] = useState(""); const [newApiFormat, setNewApiFormat] = useState("chat-completions"); @@ -2085,16 +2283,18 @@ function CustomModelsSection({ const [editingModelId, setEditingModelId] = useState(null); const [editingApiFormat, setEditingApiFormat] = useState("chat-completions"); const [editingEndpoints, setEditingEndpoints] = useState(["chat"]); - const [editingNormalizeToolCallId, setEditingNormalizeToolCallId] = useState(false); - const [editingPreserveDeveloperRole, setEditingPreserveDeveloperRole] = useState(false); const [savingModelId, setSavingModelId] = useState(null); + const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); + const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); + const fetchCustomModels = useCallback(async () => { try { const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`); if (res.ok) { const data = await res.json(); setCustomModels(data.models || []); + setModelCompatOverrides(data.modelCompatOverrides || []); } } catch (e) { console.error("Failed to fetch custom models:", e); @@ -2160,23 +2360,44 @@ function CustomModelsSection({ ? model.supportedEndpoints : ["chat"] ); - setEditingNormalizeToolCallId(Boolean(model.normalizeToolCallId)); - setEditingPreserveDeveloperRole( - Object.prototype.hasOwnProperty.call(model, "preserveOpenAIDeveloperRole") - ? Boolean(model.preserveOpenAIDeveloperRole) - : true - ); }; const cancelEdit = () => { setEditingModelId(null); setEditingApiFormat("chat-completions"); setEditingEndpoints(["chat"]); - setEditingNormalizeToolCallId(false); - setEditingPreserveDeveloperRole(true); setSavingModelId(null); }; + const saveCustomCompat = async ( + modelId: string, + patch: { compatByProtocol?: CompatByProtocolMap } + ) => { + setSavingModelId(modelId); + try { + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, modelId, ...patch }), + }); + if (!res.ok) { + notify.error(t("failedSaveCustomModel")); + return; + } + } catch { + notify.error(t("failedSaveCustomModel")); + return; + } finally { + setSavingModelId(null); + } + try { + await fetchCustomModels(); + onModelsChanged?.(); + } catch { + /* refresh failure is non-critical — data was already saved */ + } + }; + const saveEdit = async (modelId) => { if (!editingModelId || editingModelId !== modelId) return; if (!editingEndpoints.length) { @@ -2197,8 +2418,6 @@ function CustomModelsSection({ source: model?.source || "manual", apiFormat: editingApiFormat, supportedEndpoints: editingEndpoints, - normalizeToolCallId: editingNormalizeToolCallId, - preserveOpenAIDeveloperRole: editingPreserveDeveloperRole, }), }); @@ -2361,7 +2580,7 @@ function CustomModelsSection({ 🔊 Audio )} - {model.normalizeToolCallId && ( + {anyNormalizeCompatBadge(model.id, customMap, overrideMap) && ( )} - {model.preserveOpenAIDeveloperRole === false && ( + {anyNoPreserveCompatBadge(model.id, customMap, overrideMap) && ( + effectiveNormalizeForProtocol(model.id, p, customMap, overrideMap) + } + effectiveModelPreserveDeveloper={(p) => + effectivePreserveForProtocol(model.id, p, customMap, overrideMap) + } + onCompatPatch={(protocol, payload) => + saveCustomCompat(model.id, { + compatByProtocol: { [protocol]: payload }, + }) + } showDeveloperToggle - onNormalizeChange={setEditingNormalizeToolCallId} - onPreserveChange={setEditingPreserveDeveloperRole} disabled={savingModelId === model.id} /> @@ -2716,12 +2942,9 @@ function CompatibleModelsSection({ onDeleteAlias={() => handleDeleteModel(modelId, alias)} t={t} showDeveloperToggle={!isAnthropic} - normalizeToolCallId={effectiveModelNormalize(modelId)} - preserveDeveloperRole={effectiveModelPreserveDeveloper(modelId)} - onNormalizeChange={(v) => saveModelCompatFlags(modelId, { normalizeToolCallId: v })} - onPreserveChange={(v) => - saveModelCompatFlags(modelId, { preserveOpenAIDeveloperRole: v }) - } + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + saveModelCompatFlags={saveModelCompatFlags} compatDisabled={compatSavingModelId === modelId} /> ))} diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index 647b68524e..59baa1ff6c 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -6,6 +6,7 @@ import { updateCustomModel, getModelCompatOverrides, mergeModelCompatOverride, + type ModelCompatPatch, } from "@/lib/localDb"; import { AI_PROVIDERS, @@ -129,6 +130,7 @@ export async function PUT(request) { supportedEndpoints, normalizeToolCallId, preserveOpenAIDeveloperRole, + compatByProtocol, } = validation.data; const raw = rawBody as Record; @@ -139,6 +141,9 @@ export async function PUT(request) { if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId; if ("preserveOpenAIDeveloperRole" in raw) updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole; + if ("compatByProtocol" in raw && compatByProtocol !== undefined) { + updates.compatByProtocol = compatByProtocol; + } const model = await updateCustomModel(provider, modelId, updates); @@ -147,9 +152,17 @@ export async function PUT(request) { const compatOnly = rawKeys.length > 0 && rawKeys.every((k) => - ["provider", "modelId", "normalizeToolCallId", "preserveOpenAIDeveloperRole"].includes(k) + [ + "provider", + "modelId", + "normalizeToolCallId", + "preserveOpenAIDeveloperRole", + "compatByProtocol", + ].includes(k) ) && - ("normalizeToolCallId" in raw || "preserveOpenAIDeveloperRole" in raw); + ("normalizeToolCallId" in raw || + "preserveOpenAIDeveloperRole" in raw || + "compatByProtocol" in raw); if (compatOnly) { const knownProvider = !!provider && @@ -165,10 +178,7 @@ export async function PUT(request) { { status: 400 } ); } - const patch: { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean | null; - } = {}; + const patch: ModelCompatPatch = {}; if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") { patch.normalizeToolCallId = normalizeToolCallId; } @@ -178,6 +188,9 @@ export async function PUT(request) { ? preserveOpenAIDeveloperRole : undefined; } + if ("compatByProtocol" in raw && compatByProtocol && typeof compatByProtocol === "object") { + patch.compatByProtocol = compatByProtocol; + } if (Object.keys(patch).length > 0) { mergeModelCompatOverride(provider, modelId, patch); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bfd7d14c56..a7e5f0cf57 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1437,6 +1437,11 @@ "compatDeveloperShort": "Developer role", "compatDoNotPreserveDeveloper": "Do not preserve developer role", "compatBadgeNoPreserve": "No preserve", + "compatProtocolLabel": "Client request protocol", + "compatProtocolHint": "These options apply when OmniRoute detects this request shape (OpenAI Chat, Responses API, or Anthropic Messages).", + "compatProtocolOpenAI": "OpenAI Chat Completions", + "compatProtocolOpenAIResponses": "OpenAI Responses API", + "compatProtocolClaude": "Anthropic Messages", "modelId": "Model ID", "customModelPlaceholder": "e.g. gpt-4.5-turbo", "loading": "Loading...", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 28875e3f9c..41a92ff06e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1432,6 +1432,11 @@ "compatDeveloperShort": "Developer 角色", "compatDoNotPreserveDeveloper": "不保留 developer 角色", "compatBadgeNoPreserve": "不保留", + "compatProtocolLabel": "客户端请求协议", + "compatProtocolHint": "以下选项在 OmniRoute 识别到该请求形态(OpenAI Chat、Responses API 或 Anthropic Messages)时生效。", + "compatProtocolOpenAI": "OpenAI Chat Completions", + "compatProtocolOpenAIResponses": "OpenAI Responses API", + "compatProtocolClaude": "Anthropic Messages", "modelId": "模型 ID", "customModelPlaceholder": "例如:gpt-4.5-turbo", "loading": "正在加载...", diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 82fe66a5c7..115a48934a 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -4,16 +4,60 @@ import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; +import { + MODEL_COMPAT_PROTOCOL_KEYS, + type ModelCompatProtocolKey, +} from "@/shared/constants/modelCompat"; type JsonRecord = Record; /** Built-in / alias models: tool-call + developer-role flags without a full custom row */ const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides"; +export { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey }; + +export type ModelCompatPerProtocol = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; +}; + +type CompatByProtocolMap = Partial>; + +function isCompatProtocolKey(p: string): p is ModelCompatProtocolKey { + return (MODEL_COMPAT_PROTOCOL_KEYS as readonly string[]).includes(p); +} + +function deepMergeCompatByProtocol( + prev: CompatByProtocolMap | undefined, + patch: Partial>> +): CompatByProtocolMap { + const out: CompatByProtocolMap = { ...(prev || {}) }; + for (const key of Object.keys(patch) as ModelCompatProtocolKey[]) { + if (!isCompatProtocolKey(key)) continue; + const deltas = patch[key]; + if (!deltas || typeof deltas !== "object") continue; + const hasDelta = + Object.prototype.hasOwnProperty.call(deltas, "normalizeToolCallId") || + Object.prototype.hasOwnProperty.call(deltas, "preserveOpenAIDeveloperRole"); + if (!hasDelta) continue; + const cur: ModelCompatPerProtocol = { ...(out[key] || {}) }; + if ("normalizeToolCallId" in deltas) { + cur.normalizeToolCallId = Boolean(deltas.normalizeToolCallId); + } + if ("preserveOpenAIDeveloperRole" in deltas) { + cur.preserveOpenAIDeveloperRole = Boolean(deltas.preserveOpenAIDeveloperRole); + } + if (Object.keys(cur).length === 0) delete out[key]; + else out[key] = cur; + } + return out; +} + export type ModelCompatOverride = { id: string; normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + compatByProtocol?: CompatByProtocolMap; }; function readCompatList(providerId: string): ModelCompatOverride[] { @@ -52,13 +96,24 @@ export function getModelCompatOverrides(providerId: string): ModelCompatOverride return readCompatList(providerId); } +export type ModelCompatPatch = { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean | null; + compatByProtocol?: CompatByProtocolMap; +}; + +function compatByProtocolHasEntries(map: CompatByProtocolMap | undefined): boolean { + if (!map || typeof map !== "object") return false; + return Object.keys(map).some((k) => { + const v = map[k as ModelCompatProtocolKey]; + return v && typeof v === "object" && Object.keys(v).length > 0; + }); +} + export function mergeModelCompatOverride( providerId: string, modelId: string, - patch: Partial<{ - normalizeToolCallId: boolean; - preserveOpenAIDeveloperRole: boolean | null; - }> + patch: ModelCompatPatch ) { const list = readCompatList(providerId); const idx = list.findIndex((e) => e.id === modelId); @@ -75,9 +130,18 @@ export function mergeModelCompatOverride( next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole); } } + if (patch.compatByProtocol && Object.keys(patch.compatByProtocol).length > 0) { + const merged = deepMergeCompatByProtocol(next.compatByProtocol, patch.compatByProtocol); + if (compatByProtocolHasEntries(merged)) next.compatByProtocol = merged; + else delete next.compatByProtocol; + } const filtered = list.filter((e) => e.id !== modelId); const hasPreserveFlag = Object.prototype.hasOwnProperty.call(next, "preserveOpenAIDeveloperRole"); - if (next.normalizeToolCallId || hasPreserveFlag) { + if ( + next.normalizeToolCallId || + hasPreserveFlag || + compatByProtocolHasEntries(next.compatByProtocol) + ) { filtered.push(next); } writeCompatList(providerId, filtered); @@ -281,6 +345,23 @@ export async function updateCustomModel( if (index === -1) return null; const current = models[index]; + const currentCompat = (current as JsonRecord).compatByProtocol as CompatByProtocolMap | undefined; + let mergedCompat: CompatByProtocolMap | undefined = currentCompat; + if ( + updates.compatByProtocol !== undefined && + typeof updates.compatByProtocol === "object" && + updates.compatByProtocol !== null && + !Array.isArray(updates.compatByProtocol) + ) { + mergedCompat = deepMergeCompatByProtocol( + currentCompat, + updates.compatByProtocol as Partial< + Record> + > + ); + if (!compatByProtocolHasEntries(mergedCompat)) mergedCompat = undefined; + } + const next: JsonRecord = { ...current, ...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}), @@ -299,6 +380,13 @@ export async function updateCustomModel( next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole); } } + if (updates.compatByProtocol !== undefined) { + if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) { + next.compatByProtocol = mergedCompat; + } else { + delete next.compatByProtocol; + } + } models[index] = next; @@ -335,11 +423,33 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu /** * Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled. * Custom model row wins; otherwise {@link getModelCompatOverrides}. + * When `sourceFormat` is one of `openai` | `openai-responses` | `claude`, per-protocol + * `compatByProtocol[sourceFormat].normalizeToolCallId` overrides the legacy top-level flag. */ -export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean { +export function getModelNormalizeToolCallId( + providerId: string, + modelId: string, + sourceFormat?: string | null +): boolean { const m = getCustomModelRow(providerId, modelId); - if (m) return Boolean(m.normalizeToolCallId); + const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; + + if (m) { + if (protocol) { + const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol]; + if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) { + return Boolean(pc.normalizeToolCallId); + } + } + return Boolean(m.normalizeToolCallId); + } const co = readCompatList(providerId).find((e) => e.id === modelId); + if (protocol && co?.compatByProtocol?.[protocol]) { + const pc = co.compatByProtocol[protocol]!; + if (Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) { + return Boolean(pc.normalizeToolCallId); + } + } return Boolean(co?.normalizeToolCallId); } @@ -347,19 +457,35 @@ export function getModelNormalizeToolCallId(providerId: string, modelId: string) * Explicit preserve-openai-developer preference for this provider/model. * `undefined` = unset → routing keeps legacy default (preserve developer for OpenAI format). * `false` = map developer → system (e.g. MiniMax). `true` = keep developer. + * Per-protocol overrides live under `compatByProtocol[sourceFormat]` when `sourceFormat` matches. */ export function getModelPreserveOpenAIDeveloperRole( providerId: string, - modelId: string + modelId: string, + sourceFormat?: string | null ): boolean | undefined { const m = getCustomModelRow(providerId, modelId); + const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null; + if (m) { + if (protocol) { + const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol]; + if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) { + return Boolean(pc.preserveOpenAIDeveloperRole); + } + } if (Object.prototype.hasOwnProperty.call(m, "preserveOpenAIDeveloperRole")) { return Boolean(m.preserveOpenAIDeveloperRole); } return undefined; } const co = readCompatList(providerId).find((e) => e.id === modelId); + if (protocol && co?.compatByProtocol?.[protocol]) { + const pc = co.compatByProtocol[protocol]!; + if (Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) { + return Boolean(pc.preserveOpenAIDeveloperRole); + } + } if (co && Object.prototype.hasOwnProperty.call(co, "preserveOpenAIDeveloperRole")) { return Boolean(co.preserveOpenAIDeveloperRole); } diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 24480e15a4..c327aed26c 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -48,6 +48,8 @@ export { getModelPreserveOpenAIDeveloperRole, } from "./db/models"; +export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models"; + export { // Combos getCombos, diff --git a/src/shared/constants/modelCompat.ts b/src/shared/constants/modelCompat.ts new file mode 100644 index 0000000000..e54cde0e76 --- /dev/null +++ b/src/shared/constants/modelCompat.ts @@ -0,0 +1,9 @@ +/** + * Model compatibility protocol keys — shared between client UI and server. + * Must not import Node or DB code so client components can import safely. + */ + +/** Client request shapes from detectFormat — compat options apply when the client uses this protocol */ +export const MODEL_COMPAT_PROTOCOL_KEYS = ["openai", "openai-responses", "claude"] as const; + +export type ModelCompatProtocolKey = (typeof MODEL_COMPAT_PROTOCOL_KEYS)[number]; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 1ac20b15e5..70d87421c0 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -340,6 +340,13 @@ export const clearModelAvailabilitySchema = z.object({ model: modelIdSchema, }); +const modelCompatPerProtocolSchema = z + .object({ + normalizeToolCallId: z.boolean().optional(), + preserveOpenAIDeveloperRole: z.boolean().optional(), + }) + .strict(); + export const providerModelMutationSchema = z.object({ provider: z.string().trim().min(1, "provider is required").max(120), modelId: z.string().trim().min(1, "modelId is required").max(240), @@ -349,6 +356,9 @@ 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(), + compatByProtocol: z + .record(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema) + .optional(), }); const pricingFieldsSchema = z