From a7fe369ea0ab760203e43d51a4b05d4b249b99ed Mon Sep 17 00:00:00 2001 From: zhang-qiang Date: Fri, 20 Mar 2026 21:06:10 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20resolve=20role=20param=20error=20for?= =?UTF-8?q?=20Responses=20API=20+=20MiniMax=20(developer=E2=86=92system)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add preserveDeveloperRole option and model compat override - Normalize developer→system in roleNormalizer when not preserving - Translator runs normalizeRoles for Responses API with option - UI: ModelCompatPopover with do not preserve developer toggle - Add ZWS_README_V2 documenting cause and fix Made-with: Cursor --- ZWS_README_V2.md | 83 ++++ open-sse/handlers/chatCore.ts | 8 +- open-sse/services/roleNormalizer.ts | 33 +- open-sse/translator/index.ts | 30 +- .../dashboard/providers/[id]/page.tsx | 443 +++++++++++++++--- src/app/api/provider-models/route.ts | 53 ++- src/i18n/messages/en.json | 10 + src/i18n/messages/zh-CN.json | 10 + src/lib/db/models.ts | 143 +++++- src/lib/localDb.ts | 5 + src/shared/validation/schemas.ts | 1 + 11 files changed, 714 insertions(+), 105 deletions(-) create mode 100644 ZWS_README_V2.md diff --git a/ZWS_README_V2.md b/ZWS_README_V2.md new file mode 100644 index 0000000000..d038fc12a9 --- /dev/null +++ b/ZWS_README_V2.md @@ -0,0 +1,83 @@ +# ZWS_README_V2 — developer 角色与「role param error」修复说明 + +## 一、为什么要修 + +### 现象 + +- 使用 **OpenAI Responses API**(`/v1/responses`,body 带 `input`)经 OmniRoute 转发到 **MiniMax** 等 OpenAI 兼容网关时,上游返回 **422**,报错文案为 **`role param error`**。 + +### 原因 + +- **Responses API** 允许并会下发消息角色 **`developer`**(与 `system` 语义接近,用于模型指令)。 +- 多数 **OpenAI 兼容网关**(如 MiniMax)只接受 **`system` / `user` / `assistant` / `tool`**,不识别 `developer`,收到即报 422。 +- 修复前,OmniRoute 对这类请求**未做角色转换**,直接把带 `developer` 的 body 转给上游,因此触发 `role param error`。 + +--- + +## 二、怎么修的 + +### 1. 后端:统一用「是否保留 developer」控制是否转换 + +- **`open-sse/services/roleNormalizer.ts`** + - `normalizeDeveloperRole(messages, targetFormat, preserveDeveloperRole)`: + - 当 **`targetFormat === "openai"` 且 `preserveDeveloperRole !== false`** 时:**保留** `developer`(不转换),兼容官方 OpenAI 等支持 developer 的后端。 + - 否则:将消息中的 **`developer` 改为 `system`**,避免 MiniMax 等报 422。 + - 即:**默认保留**(与「以前没有此功能」时的行为一致);只有**显式关闭保留**(见下文「开关」)时才做 developer→system。 + +- **不在 translator 里硬编码** + - **`open-sse/translator/request/openai-responses.ts`** 中 **不再** 在从 `input` 构建 `messages` 时写死 developer→system,保持 `messages.push({ role: toString(item.role), content })`。 + - 所有「是否转换 developer」由 **`normalizeRoles`** 根据 `preserveDeveloperRole` 统一处理。 + +- **Responses 路径补跑一遍 role 管道** + - 在 **`open-sse/translator/index.ts`** 的 `translateRequest` 中:当 **`sourceFormat === OPENAI_RESPONSES`** 且已有 `result.messages` 时,在翻译完成后**再执行一次 `normalizeRoles`**,这样从 `input` 刚转出来的 `messages` 也会按开关做 developer→system,与 flag 一致。 + +- **三态与存储** + - **`src/lib/db/models.ts`** 中 **`getModelPreserveOpenAIDeveloperRole(providerId, modelId)`** 返回 **`boolean | undefined`**: + - **`undefined`**:未配置 → 路由侧视为「保留 developer」。 + - **`true`**:显式保留。 + - **`false`**:显式不保留(developer→system,修 MiniMax 422)。 + - 配置来源:**custom model 行** 或 **modelCompatOverrides**(无完整 custom 行时用 compat 存该模型的两项兼容选项);**`mergeModelCompatOverride`** 可写入 **`preserveOpenAIDeveloperRole: false`**,便于「不保留」持久化。 + +- **chatCore** + - 从 **`getModelPreserveOpenAIDeveloperRole`** 取值传给 **`translateRequest`** 的 `options.preserveDeveloperRole`,不再强制 `=== true`,从而支持 `undefined` 的默认保留语义。 + +### 2. 前端:兼容性入口与「不保留」开关 + +- **一个「兼容性」按钮 + 弹层** + - 每个模型(内置、OpenRouter/兼容、自定义)行上有一个 **「兼容性」** 按钮;点击后弹出**不透明**下拉面板(白/深色背景 + 阴影),内含: + - **工具 ID 9 位**:原有「将 tool call id 规范为 9 位」选项。 + - **不保留 developer 角色**:勾选 = 不保留 = 写入 **`preserveOpenAIDeveloperRole: false`**,路由时 developer→system;**默认不勾选** = 保留 = 与历史行为一致。 + - 弹层点击外部关闭;结构便于后续增加更多兼容项。 + +- **绑定关系** + - 后端仍只存「是否保留」:`preserveOpenAIDeveloperRole`(true/false/未设置)。 + - 弹层内「不保留 developer 角色」开关:**勾选 ⟺ `preserveDeveloperRole === false`**,`onChange(checked) => onPreserveChange(!checked)`,不改变后端字段含义。 + +- **角标** + - 当某模型为「不保留」时,在列表上显示短角标(如「不保留」),便于一眼看出该模型已开启 developer→system。 + +### 3. 文档与 i18n + +- 新增/沿用 i18n:**兼容性** 按钮、**不保留 developer 角色** 选项、角标「不保留」等(中/英),见 `src/i18n/messages/`。 + +--- + +## 三、使用方式(如何避免 422) + +- **默认**:不勾选「不保留 developer 角色」→ 保留 developer,行为与修复前一致。 +- **遇到 MiniMax 等 422**:在该模型(或对应兼容节点下的模型)上点击 **「兼容性」**,勾选 **「不保留 developer 角色」** 并保存;之后该模型请求会做 developer→system,422 消失。 +- 仅在使用**官方 OpenAI 且确实需要 developer 角色**时,再保持不勾选或显式保留。 + +--- + +## 四、涉及文件摘要 + +| 区域 | 文件 | +| ---------- | ------------------------------------------------------------------------------------------------------------------------ | +| 角色转换 | `open-sse/services/roleNormalizer.ts`,`open-sse/translator/index.ts`,`open-sse/translator/request/openai-responses.ts` | +| 配置与读写 | `src/lib/db/models.ts`,`src/lib/localDb.ts`,`src/app/api/provider-models/route.ts` | +| 请求管线 | `open-sse/handlers/chatCore.ts` | +| 前端 UI | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx`(兼容性按钮、弹层、不保留开关与角标) | +| 文案 | `src/i18n/messages/zh-CN.json`,`src/i18n/messages/en.json` | + +以上即为「为什么修」与「怎么修」的说明;按 CONTRIBUTING 流程在分支上提交即可。 diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 84eb651b8f..1d2a44d191 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -23,7 +23,7 @@ import { appendRequestLog, saveCallLog, } from "@/lib/usageDb"; -import { getModelNormalizeToolCallId } from "@/lib/db/models"; +import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb"; import { getExecutor } from "../executors/index.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractUsageFromResponse } from "./usageExtractor.ts"; @@ -318,6 +318,10 @@ export async function handleChatCore({ } const normalizeToolCallId = getModelNormalizeToolCallId(provider || "", model || ""); + const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole( + provider || "", + model || "" + ); translatedBody = translateRequest( sourceFormat, targetFormat, @@ -327,7 +331,7 @@ export async function handleChatCore({ credentials, provider, reqLogger, - { normalizeToolCallId } + { normalizeToolCallId, preserveDeveloperRole } ); } } catch (error) { diff --git a/open-sse/services/roleNormalizer.ts b/open-sse/services/roleNormalizer.ts index a991cf473c..23cad54ec0 100644 --- a/open-sse/services/roleNormalizer.ts +++ b/open-sse/services/roleNormalizer.ts @@ -76,26 +76,27 @@ function supportsSystemRole(provider: string, model: string): boolean { } /** - * Normalize the `developer` role to `system` for non-OpenAI providers. - * OpenAI introduced `developer` as a replacement for `system` in newer models, - * but most other providers still expect `system`. + * Normalize the `developer` role to `system` when the upstream does not support it. + * OpenAI Responses API sends `developer`; MiniMax and most OpenAI-compatible gateways + * only accept system/user/assistant/tool and return "role param error" otherwise. * * @param messages - Array of messages * @param targetFormat - The target format (e.g., "openai", "claude", "gemini") - * @returns Modified messages array + * @param preserveDeveloperRole - For targetFormat openai: undefined/true = keep developer (legacy default); false = map to system (MiniMax etc.) */ export function normalizeDeveloperRole( messages: NormalizedMessage[] | unknown, - targetFormat: string + targetFormat: string, + preserveDeveloperRole?: boolean ): NormalizedMessage[] | unknown { if (!Array.isArray(messages)) return messages; - // For OpenAI format, keep developer role as-is (it's valid) - // For all other formats, convert developer → system - if (targetFormat === "openai") return messages; + if (targetFormat === "openai" && preserveDeveloperRole !== false) return messages; return messages.map((msg: NormalizedMessage) => { - if (msg.role === "developer") { + if (!msg || typeof msg !== "object") return msg; + const role = typeof msg.role === "string" ? msg.role : ""; + if (role.toLowerCase() === "developer") { return { ...msg, role: "system" }; } return msg; @@ -170,24 +171,18 @@ export function normalizeSystemRole( * Full role normalization pipeline. * Call this before sending the request to the provider. * - * @param messages - Array of messages - * @param provider - Provider name/id - * @param model - Model name - * @param targetFormat - Target API format - * @returns Normalized messages array + * @param preserveDeveloperRole - See {@link normalizeDeveloperRole} */ export function normalizeRoles( messages: NormalizedMessage[] | unknown, provider: string, model: string, - targetFormat: string + targetFormat: string, + preserveDeveloperRole?: boolean ): NormalizedMessage[] | unknown { if (!Array.isArray(messages)) return messages; - // Step 1: Normalize developer → system (for non-OpenAI formats) - let result = normalizeDeveloperRole(messages, targetFormat); - - // Step 2: Normalize system → user (for providers that don't support system role) + let result = normalizeDeveloperRole(messages, targetFormat, preserveDeveloperRole); result = normalizeSystemRole(result, provider, model); return result; diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 2ed9c6da79..267e035a12 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -67,6 +67,7 @@ function normalizeOpenAIResponsesRequest(body) { } /** @param options.normalizeToolCallId - When true, use 9-char tool call ids (e.g. Mistral); when false, leave ids as-is */ +/** @param options.preserveDeveloperRole - undefined/true: keep developer for OpenAI format (default); false: map to system */ // Translate request: source -> openai -> target export function translateRequest( sourceFormat, @@ -77,10 +78,11 @@ export function translateRequest( credentials = null, provider = null, reqLogger = null, - options?: { normalizeToolCallId?: boolean } + options?: { normalizeToolCallId?: boolean; preserveDeveloperRole?: boolean } ) { let result = body; const use9CharId = options?.normalizeToolCallId === true; + const preserveDeveloperRole = options?.preserveDeveloperRole; // Phase 2: Apply thinking budget control before normalization result = applyThinkingBudget(result); @@ -94,9 +96,15 @@ export function translateRequest( // Fix missing tool responses (insert empty tool_result if needed) fixMissingToolResponses(result); - // Normalize roles: developer→system for non-OpenAI, system→user for incompatible models + // Normalize roles: developer→system unless preserved, system→user for incompatible models if (result.messages && Array.isArray(result.messages)) { - result.messages = normalizeRoles(result.messages, provider || "", model || "", targetFormat); + result.messages = normalizeRoles( + result.messages, + provider || "", + model || "", + targetFormat, + preserveDeveloperRole + ); } // If same format, skip translation steps @@ -143,6 +151,22 @@ export function translateRequest( result = normalizeOpenAIResponsesRequest(result); } + // After OPENAI_RESPONSES → OPENAI, messages are built from input; first normalizeRoles was a no-op. + // Run role pipeline again so developer→system respects preserveDeveloperRole (no hardcoding in translator). + if ( + sourceFormat === FORMATS.OPENAI_RESPONSES && + result.messages && + Array.isArray(result.messages) + ) { + result.messages = normalizeRoles( + result.messages, + provider || "", + model || "", + targetFormat, + preserveDeveloperRole + ); + } + // Ensure unique tool_call ids on final payload (translators may have introduced duplicates) ensureToolCallIds(result, { use9CharId }); fixMissingToolResponses(result); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index bc071e8b76..4d8fcf55e9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -43,6 +43,78 @@ function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly }; } +function ModelCompatPopover({ + t, + normalizeToolCallId, + preserveDeveloperRole, + showDeveloperToggle = true, + onNormalizeChange, + onPreserveChange, + disabled, +}: { + t: (key: string) => string; + normalizeToolCallId: boolean; + preserveDeveloperRole?: boolean; + showDeveloperToggle?: boolean; + onNormalizeChange: (v: boolean) => void; + onPreserveChange: (v: boolean) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, [open]); + + return ( +
+ + {open && ( +
+

+ {t("compatAdjustmentsTitle")} +

+
+ + {showDeveloperToggle && ( + onPreserveChange(!checked)} + disabled={disabled} + /> + )} +
+
+ )} +
+ ); +} + export default function ProviderDetailPage() { const params = useParams(); const router = useRouter(); @@ -76,6 +148,15 @@ export default function ProviderDetailPage() { error: "", importedCount: 0, }); + const [modelMeta, setModelMeta] = useState<{ + customModels: Record[]; + modelCompatOverrides: { + id: string; + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + }[]; + }>({ customModels: [], modelCompatOverrides: [] }); + const [compatSavingModelId, setCompatSavingModelId] = useState(null); const providerInfo = providerNode ? { @@ -119,6 +200,23 @@ export default function ProviderDetailPage() { } }, []); + const fetchProviderModelMeta = useCallback(async () => { + if (isSearchProvider) return; + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, { + cache: "no-store", + }); + if (!res.ok) return; + const data = await res.json(); + setModelMeta({ + customModels: data.models || [], + modelCompatOverrides: data.modelCompatOverrides || [], + }); + } catch (e) { + console.error("fetchProviderModelMeta", e); + } + }, [providerId, isSearchProvider]); + const fetchConnections = useCallback(async () => { try { const [connectionsRes, nodesRes] = await Promise.all([ @@ -186,6 +284,11 @@ export default function ProviderDetailPage() { .catch(() => {}); }, [fetchConnections, fetchAliases]); + useEffect(() => { + if (loading || isSearchProvider) return; + fetchProviderModelMeta(); + }, [loading, isSearchProvider, fetchProviderModelMeta]); + // Auto-open Add Connection modal when no connections exist (better UX) // Only fires once on initial load, not on HMR remounts or after user dismissal useEffect(() => { @@ -670,6 +773,79 @@ 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 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 saveModelCompatFlags = async ( + modelId: string, + patch: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean } + ) => { + setCompatSavingModelId(modelId); + try { + const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as Record< + string, + unknown + > | null; + let body: Record; + 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, + }; + } 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")); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setCompatSavingModelId(null); + } + }; + const renderModelsSection = () => { if (isCompatible) { return ( @@ -684,6 +860,12 @@ export default function ProviderDetailPage() { connections={connections} isAnthropic={isAnthropicCompatible} onImportWithProgress={handleCompatibleImportWithProgress} + t={t} + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + saveModelCompatFlags={saveModelCompatFlags} + compatSavingModelId={compatSavingModelId} + onModelsChanged={fetchProviderModelMeta} /> ); } @@ -711,6 +893,11 @@ export default function ProviderDetailPage() { onCopy={copy} onSetAlias={handleSetAlias} onDeleteAlias={handleDeleteAlias} + t={t} + effectiveModelNormalize={effectiveModelNormalize} + effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper} + saveModelCompatFlags={saveModelCompatFlags} + compatSavingModelId={compatSavingModelId} /> ); @@ -759,8 +946,17 @@ export default function ProviderDetailPage() { alias={existingAlias} copied={copied} onCopy={copy} - onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)} - onDeleteAlias={() => handleDeleteAlias(existingAlias)} + 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 }) + } + compatDisabled={compatSavingModelId === model.id} /> ); })} @@ -1078,6 +1274,7 @@ export default function ProviderDetailPage() { providerAlias={providerDisplayAlias} copied={copied} onCopy={copy} + onModelsChanged={fetchProviderModelMeta} /> )} @@ -1282,23 +1479,48 @@ export default function ProviderDetailPage() { ); } -function ModelRow({ model, fullModel, alias, copied, onCopy, onSetAlias, onDeleteAlias }: any) { - const t = useTranslations("providers"); +function ModelRow({ + model, + fullModel, + alias, + copied, + onCopy, + t, + showDeveloperToggle = true, + normalizeToolCallId, + preserveDeveloperRole, + onNormalizeChange, + onPreserveChange, + compatDisabled, +}: any) { return ( -
- smart_toy - - {fullModel} - - + + {fullModel} + + +
+ ); } @@ -1311,6 +1533,13 @@ ModelRow.propTypes = { alias: PropTypes.string, copied: PropTypes.string, onCopy: PropTypes.func.isRequired, + t: PropTypes.func, + showDeveloperToggle: PropTypes.bool, + normalizeToolCallId: PropTypes.bool, + preserveDeveloperRole: PropTypes.bool, + onNormalizeChange: PropTypes.func, + onPreserveChange: PropTypes.func, + compatDisabled: PropTypes.bool, }; function PassthroughModelsSection({ @@ -1320,12 +1549,15 @@ function PassthroughModelsSection({ onCopy, onSetAlias, onDeleteAlias, + t, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + saveModelCompatFlags, + compatSavingModelId, }) { - const t = useTranslations("providers"); const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); - // Filter aliases for this provider - models are persisted via alias const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) => (model as string).startsWith(`${providerAlias}/`) ); @@ -1400,6 +1632,15 @@ function PassthroughModelsSection({ copied={copied} onCopy={onCopy} onDeleteAlias={() => onDeleteAlias(alias)} + t={t} + showDeveloperToggle + normalizeToolCallId={effectiveModelNormalize(modelId)} + preserveDeveloperRole={effectiveModelPreserveDeveloper(modelId)} + onNormalizeChange={(v) => saveModelCompatFlags(modelId, { normalizeToolCallId: v })} + onPreserveChange={(v) => + saveModelCompatFlags(modelId, { preserveOpenAIDeveloperRole: v }) + } + compatDisabled={compatSavingModelId === modelId} /> ))} @@ -1415,41 +1656,69 @@ PassthroughModelsSection.propTypes = { onCopy: PropTypes.func.isRequired, onSetAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + effectiveModelNormalize: PropTypes.func.isRequired, + effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + saveModelCompatFlags: PropTypes.func.isRequired, + compatSavingModelId: PropTypes.string, }; -function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) { - const t = useTranslations("providers"); +function PassthroughModelRow({ + modelId, + fullModel, + copied, + onCopy, + onDeleteAlias, + t, + showDeveloperToggle = true, + normalizeToolCallId, + preserveDeveloperRole, + onNormalizeChange, + onPreserveChange, + compatDisabled, +}: any) { return ( -
- smart_toy - -
-

{modelId}

- -
- - {fullModel} - - +
+
+ + smart_toy + +
+

{modelId}

+
+ + {fullModel} + + +
+ +
+
+
- - {/* Delete button */} -
); } @@ -1460,11 +1729,18 @@ PassthroughModelRow.propTypes = { copied: PropTypes.string, onCopy: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, + t: PropTypes.func, + showDeveloperToggle: PropTypes.bool, + normalizeToolCallId: PropTypes.bool, + preserveDeveloperRole: PropTypes.bool, + onNormalizeChange: PropTypes.func, + onPreserveChange: PropTypes.func, + compatDisabled: PropTypes.bool, }; // ============ Custom Models Section (for ALL providers) ============ -function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { +function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onModelsChanged }) { const t = useTranslations("providers"); const notify = useNotificationStore(); const [customModels, setCustomModels] = useState([]); @@ -1478,6 +1754,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { 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 () => { @@ -1519,6 +1796,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { setNewApiFormat("chat-completions"); setNewEndpoints(["chat"]); await fetchCustomModels(); + onModelsChanged?.(); } } catch (e) { console.error("Failed to add custom model:", e); @@ -1536,6 +1814,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { } ); await fetchCustomModels(); + onModelsChanged?.(); } catch (e) { console.error("Failed to remove custom model:", e); } @@ -1550,6 +1829,11 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { : ["chat"] ); setEditingNormalizeToolCallId(Boolean(model.normalizeToolCallId)); + setEditingPreserveDeveloperRole( + Object.prototype.hasOwnProperty.call(model, "preserveOpenAIDeveloperRole") + ? Boolean(model.preserveOpenAIDeveloperRole) + : true + ); }; const cancelEdit = () => { @@ -1557,6 +1841,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { setEditingApiFormat("chat-completions"); setEditingEndpoints(["chat"]); setEditingNormalizeToolCallId(false); + setEditingPreserveDeveloperRole(true); setSavingModelId(null); }; @@ -1581,6 +1866,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { apiFormat: editingApiFormat, supportedEndpoints: editingEndpoints, normalizeToolCallId: editingNormalizeToolCallId, + preserveOpenAIDeveloperRole: editingPreserveDeveloperRole, }), }); @@ -1589,6 +1875,7 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { } await fetchCustomModels(); + onModelsChanged?.(); notify.success("Saved model endpoint settings"); cancelEdit(); } catch (e) { @@ -1745,11 +2032,19 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { {model.normalizeToolCallId && ( ID×9 )} + {model.preserveOpenAIDeveloperRole === false && ( + + {t("compatBadgeNoPreserve")} + + )}
{editingModelId === model.id && ( @@ -1802,16 +2097,17 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) { ))}
- - + +
+
@@ -2102,6 +2415,12 @@ CompatibleModelsSection.propTypes = { ).isRequired, isAnthropic: PropTypes.bool, onImportWithProgress: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + effectiveModelNormalize: PropTypes.func.isRequired, + effectiveModelPreserveDeveloper: PropTypes.func.isRequired, + saveModelCompatFlags: PropTypes.func.isRequired, + compatSavingModelId: PropTypes.string, + onModelsChanged: PropTypes.func, }; function CooldownTimer({ until }) { diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index 9941d32ffa..e4cae2814b 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -4,6 +4,8 @@ import { addCustomModel, removeCustomModel, updateCustomModel, + getModelCompatOverrides, + mergeModelCompatOverride, } from "@/lib/localDb"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { providerModelMutationSchema } from "@/shared/validation/schemas"; @@ -27,8 +29,9 @@ export async function GET(request) { const provider = searchParams.get("provider"); const models = provider ? await getCustomModels(provider) : await getAllCustomModels(); + const modelCompatOverrides = provider ? getModelCompatOverrides(provider) : []; - return Response.json({ models }); + return Response.json({ models, modelCompatOverrides }); } catch (error) { return Response.json( { error: { message: error.message, type: "server_error" } }, @@ -113,17 +116,55 @@ export async function PUT(request) { return Response.json({ error: validation.error }, { status: 400 }); } - const { provider, modelId, modelName, apiFormat, supportedEndpoints, normalizeToolCallId } = - validation.data; - - const model = await updateCustomModel(provider, modelId, { + const { + provider, + modelId, modelName, apiFormat, supportedEndpoints, normalizeToolCallId, - }); + preserveOpenAIDeveloperRole, + } = validation.data; + + const raw = rawBody as Record; + const updates: Record = {}; + if ("modelName" in raw) updates.modelName = modelName; + if ("apiFormat" in raw) updates.apiFormat = apiFormat; + if ("supportedEndpoints" in raw) updates.supportedEndpoints = supportedEndpoints; + if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId; + if ("preserveOpenAIDeveloperRole" in raw) + updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole; + + const model = await updateCustomModel(provider, modelId, updates); if (!model) { + const rawKeys = Object.keys(raw); + const compatOnly = + rawKeys.length > 0 && + rawKeys.every((k) => + ["provider", "modelId", "normalizeToolCallId", "preserveOpenAIDeveloperRole"].includes(k) + ) && + ("normalizeToolCallId" in raw || "preserveOpenAIDeveloperRole" in raw); + if (compatOnly) { + const patch: { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + } = {}; + if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") { + patch.normalizeToolCallId = normalizeToolCallId; + } + if ( + "preserveOpenAIDeveloperRole" in raw && + typeof preserveOpenAIDeveloperRole === "boolean" + ) { + patch.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole; + } + mergeModelCompatOverride(provider, modelId, patch); + return Response.json({ + ok: true, + modelCompatOverrides: getModelCompatOverrides(provider), + }); + } return Response.json( { error: { message: "Model not found", type: "not_found" } }, { status: 404 } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e599261498..c10b509a06 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1382,6 +1382,8 @@ "addFirstConnectionHint": "Add your first connection to get started", "addConnection": "Add Connection", "availableModels": "Available Models", + "builtInModels": "Built-in models", + "builtInModelsHint": "Registry models for this provider. Use the pencil to set compatibility options.", "pageAutoRefresh": "Page will refresh automatically...", "statusDisabled": "disabled", "statusConnected": "connected", @@ -1422,6 +1424,14 @@ "openRouterModelPlaceholder": "anthropic/claude-3-opus", "customModels": "Custom Models", "customModelsHint": "Add model IDs not in the default list. These will be available for routing.", + "normalizeToolCallIdLabel": "Normalize tool call IDs to 9 characters (e.g. Mistral)", + "preserveDeveloperRoleLabel": "Keep OpenAI Responses developer role (do not map to system)", + "compatAdjustmentsTitle": "Compatibility", + "compatButtonLabel": "Compatibility", + "compatToolIdShort": "Tool ID 9", + "compatDeveloperShort": "Developer role", + "compatDoNotPreserveDeveloper": "Do not preserve developer role", + "compatBadgeNoPreserve": "No preserve", "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 b9ddbb4a22..28875e3f9c 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1382,6 +1382,8 @@ "addFirstConnectionHint": "添加您的第一个连接以开始使用", "addConnection": "添加连接", "availableModels": "可用模型", + "builtInModels": "内置模型", + "builtInModelsHint": "该提供商的注册表模型。点击铅笔可设置兼容选项。", "pageAutoRefresh": "页面会自动刷新...", "statusDisabled": "已禁用", "statusConnected": "已连接", @@ -1422,6 +1424,14 @@ "openRouterModelPlaceholder": "anthropic/claude-3-opus", "customModels": "自定义模型", "customModelsHint": "添加默认列表中没有的模型 ID,这些模型也能参与路由。", + "normalizeToolCallIdLabel": "将工具调用 ID 规范为 9 位(如 Mistral)", + "preserveDeveloperRoleLabel": "保留 Responses 的 developer 角色(不映射为 system)", + "compatAdjustmentsTitle": "兼容性", + "compatButtonLabel": "兼容性", + "compatToolIdShort": "工具 ID 9 位", + "compatDeveloperShort": "Developer 角色", + "compatDoNotPreserveDeveloper": "不保留 developer 角色", + "compatBadgeNoPreserve": "不保留", "modelId": "模型 ID", "customModelPlaceholder": "例如:gpt-4.5-turbo", "loading": "正在加载...", diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index ef66785ac4..c0272fc577 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -7,6 +7,82 @@ import { backupDbFile } from "./backup"; type JsonRecord = Record; +/** Built-in / alias models: tool-call + developer-role flags without a full custom row */ +const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides"; + +export type ModelCompatOverride = { + id: string; + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; +}; + +function readCompatList(providerId: string): ModelCompatOverride[] { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(MODEL_COMPAT_NAMESPACE, providerId); + const value = getKeyValue(row).value; + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function writeCompatList(providerId: string, list: ModelCompatOverride[]) { + const db = getDbInstance(); + if (list.length === 0) { + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( + MODEL_COMPAT_NAMESPACE, + providerId + ); + } else { + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( + MODEL_COMPAT_NAMESPACE, + providerId, + JSON.stringify(list) + ); + } + backupDbFile("pre-write"); +} + +export function getModelCompatOverrides(providerId: string): ModelCompatOverride[] { + return readCompatList(providerId); +} + +export function mergeModelCompatOverride( + providerId: string, + modelId: string, + patch: Partial> +) { + const list = readCompatList(providerId); + const idx = list.findIndex((e) => e.id === modelId); + const prev = idx >= 0 ? { ...list[idx] } : { id: modelId }; + const next: ModelCompatOverride = { ...prev, id: modelId }; + if ("normalizeToolCallId" in patch) { + if (patch.normalizeToolCallId) next.normalizeToolCallId = true; + else delete next.normalizeToolCallId; + } + if ("preserveOpenAIDeveloperRole" in patch) { + next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole); + } + const filtered = list.filter((e) => e.id !== modelId); + const hasPreserveFlag = Object.prototype.hasOwnProperty.call(next, "preserveOpenAIDeveloperRole"); + if (next.normalizeToolCallId || hasPreserveFlag) { + filtered.push(next); + } + writeCompatList(providerId, filtered); +} + +export function removeModelCompatOverride(providerId: string, modelId: string) { + const list = readCompatList(providerId); + const filtered = list.filter((e) => e.id !== modelId); + if (filtered.length === list.length) return; + writeCompatList(providerId, filtered); +} + function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } @@ -174,11 +250,16 @@ export async function removeCustomModel(providerId, modelId) { ); } + removeModelCompatOverride(providerId, modelId); backupDbFile("pre-write"); return true; } -export async function updateCustomModel(providerId, modelId, updates = {}) { +export async function updateCustomModel( + providerId: string, + modelId: string, + updates: Record = {} +) { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") @@ -203,6 +284,9 @@ export async function updateCustomModel(providerId, modelId, updates = {}) { ...(updates.normalizeToolCallId !== undefined ? { normalizeToolCallId: Boolean(updates.normalizeToolCallId) } : {}), + ...(updates.preserveOpenAIDeveloperRole !== undefined + ? { preserveOpenAIDeveloperRole: Boolean(updates.preserveOpenAIDeveloperRole) } + : {}), }; models[index] = next; @@ -216,24 +300,57 @@ export async function updateCustomModel(providerId, modelId, updates = {}) { return next; } -/** - * Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled. - * Only custom models can have this set; returns false for built-in models. - */ -export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean { +/** Single custom model row from key_value customModels, or null */ +function getCustomModelRow(providerId: string, modelId: string): JsonRecord | null { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") .get(providerId); const value = getKeyValue(row).value; - if (!value) return false; - let models: { id: string; normalizeToolCallId?: boolean }[]; + if (!value) return null; try { - models = JSON.parse(value); + const models = JSON.parse(value) as unknown; + if (!Array.isArray(models)) return null; + const m = models.find((x: unknown) => { + if (!x || typeof x !== "object" || Array.isArray(x)) return false; + return (x as { id?: string }).id === modelId; + }) as JsonRecord | undefined; + return m ?? null; } catch { - return false; + return null; } - if (!Array.isArray(models)) return false; - const m = models.find((x: { id: string }) => x.id === modelId); - return Boolean(m?.normalizeToolCallId); +} + +/** + * Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled. + * Custom model row wins; otherwise {@link getModelCompatOverrides}. + */ +export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean { + const m = getCustomModelRow(providerId, modelId); + if (m) return Boolean(m.normalizeToolCallId); + const co = readCompatList(providerId).find((e) => e.id === modelId); + return Boolean(co?.normalizeToolCallId); +} + +/** + * 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. + */ +export function getModelPreserveOpenAIDeveloperRole( + providerId: string, + modelId: string +): boolean | undefined { + const m = getCustomModelRow(providerId, modelId); + if (m) { + if (Object.prototype.hasOwnProperty.call(m, "preserveOpenAIDeveloperRole")) { + return Boolean(m.preserveOpenAIDeveloperRole); + } + return undefined; + } + const co = readCompatList(providerId).find((e) => e.id === modelId); + if (co && Object.prototype.hasOwnProperty.call(co, "preserveOpenAIDeveloperRole")) { + return Boolean(co.preserveOpenAIDeveloperRole); + } + return undefined; } diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 8103776286..24480e15a4 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -41,6 +41,11 @@ export { addCustomModel, removeCustomModel, updateCustomModel, + getModelCompatOverrides, + mergeModelCompatOverride, + removeModelCompatOverride, + getModelNormalizeToolCallId, + getModelPreserveOpenAIDeveloperRole, } from "./db/models"; export { diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index d3c6c3c72f..70d0bf0e92 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -348,6 +348,7 @@ export const providerModelMutationSchema = z.object({ apiFormat: z.enum(["chat-completions", "responses"]).default("chat-completions"), supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]), normalizeToolCallId: z.boolean().optional(), + preserveOpenAIDeveloperRole: z.boolean().optional(), }); const pricingFieldsSchema = z From dfbb9d5fff06f93caf01b079a1ad690867340527 Mon Sep 17 00:00:00 2001 From: zhang-qiang Date: Fri, 20 Mar 2026 21:47:02 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20add=20ZWS=5FREADME=5FV2=20=E2=80=94?= =?UTF-8?q?=20developer=20role=20fix=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- ZWS_README_V2.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ZWS_README_V2.md b/ZWS_README_V2.md index d038fc12a9..cbed703397 100644 --- a/ZWS_README_V2.md +++ b/ZWS_README_V2.md @@ -70,7 +70,21 @@ --- -## 四、涉及文件摘要 +## 四、当前范围与后续扩展(按协议区分兼容性) + +### 当前实现仅针对 OpenAI 协议 + +- 本轮的「兼容性」配置(**工具 ID 9 位**、**不保留 developer 角色**)在实现上**只对 OpenAI 系协议生效**:请求经 OpenAI Chat Completions / Responses 格式转发时,才会应用这些选项。 +- **同一个模型**可能被多种协议调用(例如同一物理模型既走 OpenAI 兼容端点,也走 Anthropic Messages、或其它网关),理想情况下**兼容性应按「协议」维度**配置,由用户为每个协议单独选择是否做 9 位 tool id、是否不保留 developer 等。 +- **当前页面的「兼容性」入口没有标明协议**,容易让用户误以为这些选项对该模型的所有调用方式都生效;实际上只影响 **OpenAI 协议** 下的行为。 + +### 后续计划 + +- **按协议维护兼容性**:将兼容项(如 `normalizeToolCallId`、`preserveOpenAIDeveloperRole`)归属到「协议」维度(如 `openai`、`anthropic` 等),存储与 UI 均按协议展示与编辑,避免误导并支持多协议并存时的差异化配置。 + +--- + +## 五、涉及文件摘要 | 区域 | 文件 | | ---------- | ------------------------------------------------------------------------------------------------------------------------ |