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
This commit is contained in:
zhang-qiang
2026-03-20 22:06:03 +08:00
parent dfbb9d5fff
commit 13c45807ef
10 changed files with 587 additions and 136 deletions

91
ZWS_README_V3.md Normal file
View File

@@ -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→systemtranslator 不硬编码三态存储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 i18nV3 新增)
- **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 在此基础上完成按协议配置兼容性及构建/深色模式修复。

View File

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

View File

@@ -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<string, { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean }>
>;
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<CompatModelRow & { id: string }>
): 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<CompatModelRow & { id: string }>
): 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<CompatModelRow & { id: string }>
): 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<CompatModelRow & { id: string }>
): 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<string>(MODEL_COMPAT_PROTOCOL_KEYS[0]);
const ref = useRef<HTMLDivElement>(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")}
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 min-w-[200px] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10">
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-muted mb-2">
<div className="absolute left-0 top-full mt-1 z-50 min-w-[220px] max-w-[92vw] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10">
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-muted mb-1">
{t("compatAdjustmentsTitle")}
</p>
<p className="text-[10px] text-text-muted mb-2 leading-snug">{t("compatProtocolHint")}</p>
<label className="block text-[10px] font-medium text-text-muted mb-1">
{t("compatProtocolLabel")}
</label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
disabled={disabled}
className="w-full mb-3 px-2 py-1.5 text-xs rounded-md border border-border bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{MODEL_COMPAT_PROTOCOL_KEYS.map((p) => (
<option key={p} value={p}>
{t(compatProtocolLabelKey(p))}
</option>
))}
</select>
<div className="flex flex-col gap-3">
<Toggle
size="sm"
label={t("compatToolIdShort")}
title={t("normalizeToolCallIdLabel")}
checked={normalizeToolCallId}
onChange={onNormalizeChange}
onChange={(v) => onCompatPatch(protocol, { normalizeToolCallId: v })}
disabled={disabled}
/>
{showDeveloperToggle && (
{devToggle && (
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) => 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<string, unknown>[];
modelCompatOverrides: {
id: string;
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
}[];
customModels: CompatModelRow[];
modelCompatOverrides: Array<CompatModelRow & { id: string }>;
}>({ customModels: [], modelCompatOverrides: [] });
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(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<string, unknown>
| 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<string, unknown>;
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({
</div>
<ModelCompatPopover
t={t}
normalizeToolCallId={Boolean(normalizeToolCallId)}
preserveDeveloperRole={preserveDeveloperRole}
effectiveModelNormalize={(p) => 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}
/>
</div>
@@ -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({
<div className="pl-9">
<ModelCompatPopover
t={t}
normalizeToolCallId={Boolean(normalizeToolCallId)}
preserveDeveloperRole={preserveDeveloperRole}
effectiveModelNormalize={(p) => effectiveModelNormalize(modelId, p)}
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(modelId, p)}
onCompatPatch={(protocol, payload) =>
saveModelCompatFlags(modelId, { compatByProtocol: { [protocol]: payload } })
}
showDeveloperToggle={showDeveloperToggle}
onNormalizeChange={onNormalizeChange}
onPreserveChange={onPreserveChange}
disabled={compatDisabled}
/>
</div>
@@ -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<CompatModelRow[]>([]);
const [modelCompatOverrides, setModelCompatOverrides] = useState<
Array<CompatModelRow & { id: string }>
>([]);
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<string | null>(null);
const [editingApiFormat, setEditingApiFormat] = useState("chat-completions");
const [editingEndpoints, setEditingEndpoints] = useState<string[]>(["chat"]);
const [editingNormalizeToolCallId, setEditingNormalizeToolCallId] = useState(false);
const [editingPreserveDeveloperRole, setEditingPreserveDeveloperRole] = useState(false);
const [savingModelId, setSavingModelId] = useState<string | null>(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
</span>
)}
{model.normalizeToolCallId && (
{anyNormalizeCompatBadge(model.id, customModels, modelCompatOverrides) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-500/15 text-slate-400 font-medium"
title={t("normalizeToolCallIdLabel")}
@@ -2037,7 +2196,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onMode
ID×9
</span>
)}
{model.preserveOpenAIDeveloperRole === false && (
{anyNoPreserveCompatBadge(model.id, customModels, modelCompatOverrides) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium"
title={t("compatDoNotPreserveDeveloper")}
@@ -2101,11 +2260,28 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onMode
<div className="mt-3 pt-3 border-t border-border/80 w-full">
<ModelCompatPopover
t={t}
normalizeToolCallId={editingNormalizeToolCallId}
preserveDeveloperRole={editingPreserveDeveloperRole}
effectiveModelNormalize={(p) =>
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}
/>
</div>
@@ -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}
/>
))}

View File

@@ -124,6 +124,7 @@ export async function PUT(request) {
supportedEndpoints,
normalizeToolCallId,
preserveOpenAIDeveloperRole,
compatByProtocol,
} = validation.data;
const raw = rawBody as Record<string, unknown>;
@@ -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,

View File

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

View File

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

View File

@@ -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<string, unknown>;
/** 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<Record<ModelCompatProtocolKey, ModelCompatPerProtocol>>;
function isCompatProtocolKey(p: string): p is ModelCompatProtocolKey {
return (MODEL_COMPAT_PROTOCOL_KEYS as readonly string[]).includes(p);
}
function deepMergeCompatByProtocol(
prev: CompatByProtocolMap | undefined,
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>
): 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<Pick<ModelCompatOverride, "normalizeToolCallId" | "preserveOpenAIDeveloperRole">>
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<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>
>
);
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);
}

View File

@@ -48,6 +48,8 @@ export {
getModelPreserveOpenAIDeveloperRole,
} from "./db/models";
export type { ModelCompatPerProtocol } from "./db/models";
export {
// Combos
getCombos,

View File

@@ -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];

View File

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