From 13c45807ef627a12dc6c9deb17bd9191eeb2dd56 Mon Sep 17 00:00:00 2001 From: zhang-qiang Date: Fri, 20 Mar 2026 22:06:03 +0800 Subject: [PATCH] feat: protocol-scoped model compat (V3) - compatByProtocol per openai/openai-responses/claude - getters take sourceFormat; chatCore passes it - UI: protocol selector in compat popover, dark mode select - shared/constants/modelCompat for client-safe import (fix node:crypto build) - ZWS_README_V3.md Made-with: Cursor --- ZWS_README_V3.md | 91 ++++ open-sse/handlers/chatCore.ts | 9 +- .../dashboard/providers/[id]/page.tsx | 425 ++++++++++++------ src/app/api/provider-models/route.ts | 26 +- src/i18n/messages/en.json | 5 + src/i18n/messages/zh-CN.json | 5 + src/lib/db/models.ts | 143 +++++- src/lib/localDb.ts | 2 + src/shared/constants/modelCompat.ts | 9 + src/shared/validation/schemas.ts | 8 + 10 files changed, 587 insertions(+), 136 deletions(-) create mode 100644 ZWS_README_V3.md create mode 100644 src/shared/constants/modelCompat.ts diff --git a/ZWS_README_V3.md b/ZWS_README_V3.md new file mode 100644 index 0000000000..5c6596a7ab --- /dev/null +++ b/ZWS_README_V3.md @@ -0,0 +1,91 @@ +# ZWS_README_V3 — V2 完成 + 按协议配置兼容性 + +V2 内容(developer 角色与 role param error 修复)已完成;V3 在 V2 基础上实现**按协议维度配置兼容性**,并修复客户端构建与深色模式显示问题。 + +--- + +## 一、V2 摘要(已完成) + +- **问题**:OpenAI Responses API 的 `developer` 角色经 OmniRoute 转发到 MiniMax 等网关时触发 422 `role param error`。 +- **修复**:`roleNormalizer` 按 `preserveDeveloperRole` 统一做 developer→system;translator 不硬编码;三态存储(undefined/true/false);前端「兼容性」弹层提供「不保留 developer 角色」开关,默认不勾选=保留。 +- 详见 **ZWS_README_V2.md** 第一~三节及第五节文件列表。 + +--- + +## 二、V3:按协议配置兼容性 + +### 2.1 目标 + +- 同一模型可被多种**客户端请求形态**调用(OpenAI Chat、OpenAI Responses、Anthropic Messages 等),兼容选项应对**协议**生效,而非全局。 +- 用户为每种协议单独配置「工具 ID 9 位」「不保留 developer 角色」,避免误导(V2 时未标明协议)。 + +### 2.2 协议维度 + +- **协议键**:`openai`(Chat Completions)、`openai-responses`(Responses API)、`claude`(Anthropic Messages),与 `detectFormat(body)` 的返回值一致。 +- **存储**:在原有 `normalizeToolCallId` / `preserveOpenAIDeveloperRole` 顶层字段基础上,增加 **`compatByProtocol`**: + - `compatByProtocol[protocol] = { normalizeToolCallId?, preserveOpenAIDeveloperRole? }` + - 路由时按请求的 **sourceFormat** 优先读取 `compatByProtocol[sourceFormat]`,无则回退到顶层字段。 + +### 2.3 后端 + +- **`src/lib/db/models.ts`** + - 引入 **`MODEL_COMPAT_PROTOCOL_KEYS`** 与 **`ModelCompatProtocolKey`** 自 **`@/shared/constants/modelCompat`**(见 2.5)。 + - **ModelCompatOverride** / custom model 行支持 **`compatByProtocol`**;**mergeModelCompatOverride**、**updateCustomModel** 支持对 `compatByProtocol` 做深度合并。 + - **getModelNormalizeToolCallId(providerId, modelId, sourceFormat?)**、**getModelPreserveOpenAIDeveloperRole(..., sourceFormat?)** 增加第三参 **sourceFormat**;若为已知协议则优先用 `compatByProtocol[sourceFormat]`,否则用顶层字段。 +- **`open-sse/handlers/chatCore.ts`** + - 调用上述 getter 时传入当前请求的 **sourceFormat**(由 `detectFormat(body)` 得到),从而按客户端请求形态选用对应协议配置。 +- **`src/app/api/provider-models/route.ts`** + - PUT body 支持 **compatByProtocol**(校验与写入);仅更新兼容配置时也可只传 `compatByProtocol`,走 **mergeModelCompatOverride**。 +- **`src/shared/validation/schemas.ts`** + - **providerModelMutationSchema** 增加 **compatByProtocol**:`z.record(z.string(), modelCompatPerProtocolSchema).optional()`。 + +### 2.4 前端 + +- **兼容性弹层(ModelCompatPopover)** + - 增加 **「客户端请求协议」** 下拉:OpenAI Chat Completions、OpenAI Responses API、Anthropic Messages。 + - 下方两个开关(工具 ID 9 位、不保留 developer)**针对当前选中的协议**生效;选 Claude 时仅展示工具 ID 开关(developer 仅对 OpenAI 系有意义)。 + - 保存时调用 **saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })**,与后端按协议合并。 +- **解析与角标** + - **effectiveModelNormalize** / **effectiveModelPreserveDeveloper** 增加 **protocol** 参数,按 **effectiveNormalizeForProtocol** / **effectivePreserveForProtocol** 从 customModels + modelCompatOverrides 的 **compatByProtocol** 与顶层字段解析。 + - 角标「ID×9」「不保留」:任意协议存在对应配置即显示(**anyNormalizeCompatBadge** / **anyNoPreserveCompatBadge**)。 +- **自定义模型** + - 列表拉取并展示 **modelCompatOverrides**;编辑区内兼容性弹层同样按协议选择并保存 **compatByProtocol**;支持仅传 **compatByProtocol** 的 PUT。 +- **深色模式** + - 协议下拉框使用 **bg-white dark:bg-zinc-800**、**text-zinc-900 dark:text-zinc-100**,保证深色主题下可读。 + +### 2.5 客户端安全常量(解决构建报错) + +- **问题**:`page.tsx`("use client")从 **@/lib/localDb** 引入 **MODEL_COMPAT_PROTOCOL_KEYS** 时,会间接拉取 **db/proxies.ts**(使用 **node:crypto**),Webpack 报 `UnhandledSchemeError: Reading from "node:crypto" is not handled`。 +- **处理**: + - 新增 **`src/shared/constants/modelCompat.ts`**,仅定义 **MODEL_COMPAT_PROTOCOL_KEYS** 与 **ModelCompatProtocolKey**,不依赖 Node/DB。 + - **models.ts** 从 **@/shared/constants/modelCompat** 引入并再导出;**localDb** 不再导出 **MODEL_COMPAT_PROTOCOL_KEYS**;**page.tsx** 改为从 **@/shared/constants/modelCompat** 引入。客户端不再经过 localDb → db → proxies → node:crypto。 + +### 2.6 i18n(V3 新增) + +- **compatProtocolLabel**、**compatProtocolHint**、**compatProtocolOpenAI**、**compatProtocolOpenAIResponses**、**compatProtocolClaude**(中/英),见 `src/i18n/messages/`。 + +--- + +## 三、使用方式(V3) + +- 点击模型行的 **「兼容性」**,在弹层内先选择 **「客户端请求协议」**(OpenAI Chat / OpenAI Responses / Anthropic Messages),再勾选该协议下的「工具 ID 9 位」或「不保留 developer 角色」;保存后仅在该协议形态的请求下生效。 +- 未配置某协议时,该协议下行为回退到顶层兼容字段(若存在)或默认(保留 developer、不规范化 tool id)。 + +--- + +## 四、涉及文件摘要(V3) + +| 区域 | 文件 | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| 协议常量 | `src/shared/constants/modelCompat.ts`(新建,客户端安全) | +| 配置与读写 | `src/lib/db/models.ts`(compatByProtocol、getter 第三参)、`src/lib/localDb.ts`(不再导出协议常量)、`src/app/api/provider-models/route.ts` | +| 校验 | `src/shared/validation/schemas.ts`(compatByProtocol) | +| 请求管线 | `open-sse/handlers/chatCore.ts`(传入 sourceFormat) | +| 前端 UI | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx`(协议选择器、按协议解析/保存、角标、深色 select) | +| 文案 | `src/i18n/messages/zh-CN.json`,`src/i18n/messages/en.json` | + +V2 涉及文件仍见 **ZWS_README_V2.md** 第五节;上表仅列出 V3 新增或修改部分。 + +--- + +以上为 V3 版本说明;V2 逻辑保留,V3 在此基础上完成按协议配置兼容性及构建/深色模式修复。 diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1d2a44d191..bb501133ea 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -317,10 +317,15 @@ export async function handleChatCore({ } } - const normalizeToolCallId = getModelNormalizeToolCallId(provider || "", model || ""); + const normalizeToolCallId = getModelNormalizeToolCallId( + provider || "", + model || "", + sourceFormat + ); const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole( provider || "", - model || "" + model || "", + sourceFormat ); translatedBody = translateRequest( sourceFormat, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 4d8fcf55e9..fe3a950294 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -31,6 +31,115 @@ import { } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { MODEL_COMPAT_PROTOCOL_KEYS } from "@/shared/constants/modelCompat"; + +type CompatByProtocolMap = Partial< + Record +>; +type CompatModelRow = { + id?: string; + name?: string; + source?: string; + apiFormat?: string; + supportedEndpoints?: string[]; + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + compatByProtocol?: CompatByProtocolMap; +}; + +function getProtoSlice( + c: CompatModelRow | undefined, + o: (CompatModelRow & { id: string }) | undefined, + protocol: string +) { + return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol]; +} + +function effectiveNormalizeForProtocol( + modelId: string, + protocol: string, + customModels: CompatModelRow[], + overrides: Array +): boolean { + const c = customModels.find((m) => m.id === modelId); + const o = overrides.find((e) => e.id === modelId); + const pc = getProtoSlice(c, o, protocol); + if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) { + return Boolean(pc.normalizeToolCallId); + } + if (c?.normalizeToolCallId) return true; + return Boolean(o?.normalizeToolCallId); +} + +function effectivePreserveForProtocol( + modelId: string, + protocol: string, + customModels: CompatModelRow[], + overrides: Array +): boolean { + const c = customModels.find((m) => m.id === modelId); + const o = overrides.find((e) => e.id === modelId); + const pc = getProtoSlice(c, o, protocol); + if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) { + return Boolean(pc.preserveOpenAIDeveloperRole); + } + if (c && Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")) { + return Boolean(c.preserveOpenAIDeveloperRole); + } + if (o && Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole")) { + return Boolean(o.preserveOpenAIDeveloperRole); + } + return true; +} + +function anyNormalizeCompatBadge( + modelId: string, + customModels: CompatModelRow[], + overrides: Array +): boolean { + const c = customModels.find((m) => m.id === modelId); + const o = overrides.find((e) => e.id === modelId); + if (c?.normalizeToolCallId || o?.normalizeToolCallId) return true; + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if (pc?.normalizeToolCallId) return true; + } + return false; +} + +function anyNoPreserveCompatBadge( + modelId: string, + customModels: CompatModelRow[], + overrides: Array +): boolean { + const c = customModels.find((m) => m.id === modelId); + const o = overrides.find((e) => e.id === modelId); + if ( + c && + Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") && + c.preserveOpenAIDeveloperRole === false + ) { + return true; + } + if ( + o && + Object.prototype.hasOwnProperty.call(o, "preserveOpenAIDeveloperRole") && + o.preserveOpenAIDeveloperRole === false + ) { + return true; + } + for (const p of MODEL_COMPAT_PROTOCOL_KEYS) { + const pc = getProtoSlice(c, o, p); + if ( + pc && + Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole") && + pc.preserveOpenAIDeveloperRole === false + ) { + return true; + } + } + return false; +} function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { const record = @@ -43,26 +152,42 @@ function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly }; } +function compatProtocolLabelKey(protocol: string): string { + if (protocol === "openai") return "compatProtocolOpenAI"; + if (protocol === "openai-responses") return "compatProtocolOpenAIResponses"; + if (protocol === "claude") return "compatProtocolClaude"; + return "compatProtocolOpenAI"; +} + function ModelCompatPopover({ t, - normalizeToolCallId, - preserveDeveloperRole, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + onCompatPatch, showDeveloperToggle = true, - onNormalizeChange, - onPreserveChange, disabled, }: { t: (key: string) => string; - normalizeToolCallId: boolean; - preserveDeveloperRole?: boolean; + effectiveModelNormalize: (protocol: string) => boolean; + effectiveModelPreserveDeveloper: (protocol: string) => boolean; + onCompatPatch: ( + protocol: string, + payload: { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + } + ) => void; showDeveloperToggle?: boolean; - onNormalizeChange: (v: boolean) => void; - onPreserveChange: (v: boolean) => void; disabled?: boolean; }) { const [open, setOpen] = useState(false); + const [protocol, setProtocol] = useState(MODEL_COMPAT_PROTOCOL_KEYS[0]); const ref = useRef(null); + const normalizeToolCallId = effectiveModelNormalize(protocol); + const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol); + const devToggle = showDeveloperToggle && protocol !== "claude"; + useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { @@ -85,26 +210,44 @@ function ModelCompatPopover({ {t("compatButtonLabel")} {open && ( -
-

+

+

{t("compatAdjustmentsTitle")}

+

{t("compatProtocolHint")}

+ +
onCompatPatch(protocol, { normalizeToolCallId: v })} disabled={disabled} /> - {showDeveloperToggle && ( + {devToggle && ( onPreserveChange(!checked)} + onChange={(checked) => + onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked }) + } disabled={disabled} /> )} @@ -149,12 +292,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); @@ -773,62 +912,76 @@ 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 effectiveModelNormalize = (modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) => + effectiveNormalizeForProtocol( + modelId, + protocol, + modelMeta.customModels, + 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 effectiveModelPreserveDeveloper = ( + modelId: string, + protocol = MODEL_COMPAT_PROTOCOL_KEYS[0] + ) => + effectivePreserveForProtocol( + modelId, + protocol, + modelMeta.customModels, + modelMeta.modelCompatOverrides + ); const saveModelCompatFlags = async ( modelId: string, - patch: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean } + patch: { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + compatByProtocol?: CompatByProtocolMap; + } ) => { setCompatSavingModelId(modelId); try { - const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as Record< + const c = modelMeta.customModels.find((m) => m.id === modelId) as Record< string, unknown > | null; let body: Record; + const onlyCompatByProtocol = + patch.compatByProtocol && + patch.normalizeToolCallId === undefined && + patch.preserveOpenAIDeveloperRole === undefined; + if (c) { - 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 (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 }; } @@ -948,14 +1101,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} /> ); @@ -1487,10 +1635,9 @@ function ModelRow({ onCopy, t, showDeveloperToggle = true, - normalizeToolCallId, - preserveDeveloperRole, - onNormalizeChange, - onPreserveChange, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + saveModelCompatFlags, compatDisabled, }: any) { return ( @@ -1514,11 +1661,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} />
@@ -1535,10 +1683,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, }; @@ -1634,12 +1781,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} /> ))} @@ -1671,10 +1815,9 @@ function PassthroughModelRow({ onDeleteAlias, t, showDeveloperToggle = true, - normalizeToolCallId, - preserveDeveloperRole, - onNormalizeChange, - onPreserveChange, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + saveModelCompatFlags, compatDisabled, }: any) { return ( @@ -1711,11 +1854,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} />
@@ -1731,10 +1875,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, }; @@ -1743,7 +1886,10 @@ PassthroughModelRow.propTypes = { function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onModelsChanged }) { 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"); @@ -1753,8 +1899,6 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onMode 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 fetchCustomModels = useCallback(async () => { @@ -1763,6 +1907,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onMode if (res.ok) { const data = await res.json(); setCustomModels(data.models || []); + setModelCompatOverrides(data.modelCompatOverrides || []); } } catch (e) { console.error("Failed to fetch custom models:", e); @@ -1828,23 +1973,39 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onMode ? 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) { + await fetchCustomModels(); + onModelsChanged?.(); + } else { + notify.error(t("failedSaveCustomModel")); + } + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setSavingModelId(null); + } + }; + const saveEdit = async (modelId) => { if (!editingModelId || editingModelId !== modelId) return; if (!editingEndpoints.length) { @@ -1865,8 +2026,6 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onMode source: model?.source || "manual", apiFormat: editingApiFormat, supportedEndpoints: editingEndpoints, - normalizeToolCallId: editingNormalizeToolCallId, - preserveOpenAIDeveloperRole: editingPreserveDeveloperRole, }), }); @@ -2029,7 +2188,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onMode 🔊 Audio )} - {model.normalizeToolCallId && ( + {anyNormalizeCompatBadge(model.id, customModels, modelCompatOverrides) && ( )} - {model.preserveOpenAIDeveloperRole === false && ( + {anyNoPreserveCompatBadge(model.id, customModels, modelCompatOverrides) && ( + effectiveNormalizeForProtocol( + model.id, + p, + customModels, + modelCompatOverrides + ) + } + effectiveModelPreserveDeveloper={(p) => + effectivePreserveForProtocol( + model.id, + p, + customModels, + modelCompatOverrides + ) + } + onCompatPatch={(protocol, payload) => + saveCustomCompat(model.id, { + compatByProtocol: { [protocol]: payload }, + }) + } showDeveloperToggle - onNormalizeChange={setEditingNormalizeToolCallId} - onPreserveChange={setEditingPreserveDeveloperRole} disabled={savingModelId === model.id} />
@@ -2384,12 +2560,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 e4cae2814b..eb34fa8a38 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -124,6 +124,7 @@ export async function PUT(request) { supportedEndpoints, normalizeToolCallId, preserveOpenAIDeveloperRole, + compatByProtocol, } = validation.data; const raw = rawBody as Record; @@ -134,6 +135,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); @@ -142,13 +146,25 @@ 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 patch: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean; + compatByProtocol?: Record< + string, + { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean } + >; } = {}; if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") { patch.normalizeToolCallId = normalizeToolCallId; @@ -159,6 +175,12 @@ export async function PUT(request) { ) { patch.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole; } + if ("compatByProtocol" in raw && compatByProtocol && typeof compatByProtocol === "object") { + patch.compatByProtocol = compatByProtocol as Record< + string, + { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean } + >; + } mergeModelCompatOverride(provider, modelId, patch); return Response.json({ ok: true, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index c10b509a06..e2ce86d732 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1432,6 +1432,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 c0272fc577..dc0c707dc5 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -4,16 +4,61 @@ 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) { + if (deltas.normalizeToolCallId) cur.normalizeToolCallId = true; + else delete cur.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,10 +97,25 @@ export function getModelCompatOverrides(providerId: string): ModelCompatOverride return readCompatList(providerId); } +export type ModelCompatPatch = Partial< + Pick< + ModelCompatOverride, + "normalizeToolCallId" | "preserveOpenAIDeveloperRole" | "compatByProtocol" + > +>; + +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> + patch: ModelCompatPatch ) { const list = readCompatList(providerId); const idx = list.findIndex((e) => e.id === modelId); @@ -68,9 +128,18 @@ export function mergeModelCompatOverride( if ("preserveOpenAIDeveloperRole" in patch) { 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); @@ -274,7 +343,24 @@ export async function updateCustomModel( if (index === -1) return null; const current = models[index]; - const next = { + 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 } : {}), ...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}), @@ -288,6 +374,13 @@ export async function updateCustomModel( ? { preserveOpenAIDeveloperRole: Boolean(updates.preserveOpenAIDeveloperRole) } : {}), }; + if (updates.compatByProtocol !== undefined) { + if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) { + next.compatByProtocol = mergedCompat; + } else { + delete next.compatByProtocol; + } + } models[index] = next; @@ -324,11 +417,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); } @@ -336,19 +451,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..54cb201ef1 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -48,6 +48,8 @@ export { getModelPreserveOpenAIDeveloperRole, } from "./db/models"; +export type { ModelCompatPerProtocol } 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 70d0bf0e92..38d8bf42db 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,7 @@ export const providerModelMutationSchema = z.object({ supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]), normalizeToolCallId: z.boolean().optional(), preserveOpenAIDeveloperRole: z.boolean().optional(), + compatByProtocol: z.record(z.string(), modelCompatPerProtocolSchema).optional(), }); const pricingFieldsSchema = z