Merge pull request #494 from zhangqiang8vip/fix/developer-role-param-error

fix: resolve 422 "role param error" when forwarding OpenAI Responses API to MiniMax (developer → system)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-03-20 12:21:57 -03:00
committed by GitHub
11 changed files with 728 additions and 105 deletions

97
ZWS_README_V2.md Normal file
View File

@@ -0,0 +1,97 @@
# 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→system422 消失。
- 仅在使用**官方 OpenAI 且确实需要 developer 角色**时,再保持不勾选或显式保留。
---
## 四、当前范围与后续扩展(按协议区分兼容性)
### 当前实现仅针对 OpenAI 协议
- 本轮的「兼容性」配置(**工具 ID 9 位**、**不保留 developer 角色**)在实现上**只对 OpenAI 系协议生效**:请求经 OpenAI Chat Completions / Responses 格式转发时,才会应用这些选项。
- **同一个模型**可能被多种协议调用(例如同一物理模型既走 OpenAI 兼容端点,也走 Anthropic Messages、或其它网关理想情况下**兼容性应按「协议」维度**配置,由用户为每个协议单独选择是否做 9 位 tool id、是否不保留 developer 等。
- **当前页面的「兼容性」入口没有标明协议**,容易让用户误以为这些选项对该模型的所有调用方式都生效;实际上只影响 **OpenAI 协议** 下的行为。
### 后续计划
- **按协议维护兼容性**:将兼容项(如 `normalizeToolCallId``preserveOpenAIDeveloperRole`)归属到「协议」维度(如 `openai``anthropic` 等),存储与 UI 均按协议展示与编辑,避免误导并支持多协议并存时的差异化配置。
---
## 五、涉及文件摘要
| 区域 | 文件 |
| ---------- | ------------------------------------------------------------------------------------------------------------------------ |
| 角色转换 | `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 流程在分支上提交即可。

View File

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

View File

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

View File

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

View File

@@ -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<HTMLDivElement>(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 (
<div className="relative inline-block" ref={ref}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md border border-border bg-sidebar/50 hover:bg-sidebar text-text-muted hover:text-text-main disabled:opacity-50"
title={t("compatAdjustmentsTitle")}
>
<span className="material-symbols-outlined text-sm">tune</span>
{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">
{t("compatAdjustmentsTitle")}
</p>
<div className="flex flex-col gap-3">
<Toggle
size="sm"
label={t("compatToolIdShort")}
title={t("normalizeToolCallIdLabel")}
checked={normalizeToolCallId}
onChange={onNormalizeChange}
disabled={disabled}
/>
{showDeveloperToggle && (
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) => onPreserveChange(!checked)}
disabled={disabled}
/>
)}
</div>
</div>
)}
</div>
);
}
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<string, unknown>[];
modelCompatOverrides: {
id: string;
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
}[];
}>({ customModels: [], modelCompatOverrides: [] });
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(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<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 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<string, unknown>;
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}
/>
</div>
);
@@ -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}
/>
)}
</Card>
@@ -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 (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50">
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
{fullModel}
</code>
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
<div className="flex flex-col px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50 min-w-[220px] max-w-md">
<div className="flex items-center gap-2 flex-wrap">
<span className="material-symbols-outlined text-base text-text-muted shrink-0">
smart_toy
</span>
</button>
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
{fullModel}
</code>
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
</div>
<ModelCompatPopover
t={t}
normalizeToolCallId={Boolean(normalizeToolCallId)}
preserveDeveloperRole={preserveDeveloperRole}
showDeveloperToggle={showDeveloperToggle}
onNormalizeChange={onNormalizeChange}
onPreserveChange={onPreserveChange}
disabled={compatDisabled}
/>
</div>
);
}
@@ -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}
/>
))}
</div>
@@ -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 (
<div className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-sidebar/50">
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
{fullModel}
</code>
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
<div className="flex flex-col gap-0 p-3 rounded-lg border border-border hover:bg-sidebar/50">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-base text-text-muted shrink-0">
smart_toy
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1 flex-wrap">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">
{fullModel}
</code>
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title={t("copyModel")}
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
</div>
</div>
<button
onClick={onDeleteAlias}
className="p-1 hover:bg-red-50 rounded text-red-500 shrink-0"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
<div className="pl-9">
<ModelCompatPopover
t={t}
normalizeToolCallId={Boolean(normalizeToolCallId)}
preserveDeveloperRole={preserveDeveloperRole}
showDeveloperToggle={showDeveloperToggle}
onNormalizeChange={onNormalizeChange}
onPreserveChange={onPreserveChange}
disabled={compatDisabled}
/>
</div>
{/* Delete button */}
<button
onClick={onDeleteAlias}
className="p-1 hover:bg-red-50 rounded text-red-500"
title={t("removeModel")}
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
);
}
@@ -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<string[]>(["chat"]);
const [editingNormalizeToolCallId, setEditingNormalizeToolCallId] = useState(false);
const [editingPreserveDeveloperRole, setEditingPreserveDeveloperRole] = useState(false);
const [savingModelId, setSavingModelId] = useState<string | null>(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 && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-500/15 text-slate-400 font-medium"
title="9-char tool call ID (Mistral)"
title={t("normalizeToolCallIdLabel")}
>
ID×9
</span>
)}
{model.preserveOpenAIDeveloperRole === false && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium"
title={t("compatDoNotPreserveDeveloper")}
>
{t("compatBadgeNoPreserve")}
</span>
)}
</div>
{editingModelId === model.id && (
@@ -1802,16 +2097,17 @@ function CustomModelsSection({ providerId, providerAlias, copied, onCopy }) {
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-text-main cursor-pointer">
<input
type="checkbox"
checked={editingNormalizeToolCallId}
onChange={(e) => setEditingNormalizeToolCallId(e.target.checked)}
className="rounded border-border"
/>
Normalize Tool Call ID (9 chars, Mistral)
</label>
</div>
<div className="mt-3 pt-3 border-t border-border/80 w-full">
<ModelCompatPopover
t={t}
normalizeToolCallId={editingNormalizeToolCallId}
preserveDeveloperRole={editingPreserveDeveloperRole}
showDeveloperToggle
onNormalizeChange={setEditingNormalizeToolCallId}
onPreserveChange={setEditingPreserveDeveloperRole}
disabled={savingModelId === model.id}
/>
</div>
<div className="mt-3 flex items-center gap-2">
<Button
@@ -1860,6 +2156,7 @@ CustomModelsSection.propTypes = {
providerAlias: PropTypes.string.isRequired,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onModelsChanged: PropTypes.func,
};
function CompatibleModelsSection({
@@ -1873,8 +2170,13 @@ function CompatibleModelsSection({
connections,
isAnthropic,
onImportWithProgress,
t,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
saveModelCompatFlags,
compatSavingModelId,
onModelsChanged,
}) {
const t = useTranslations("providers");
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [importing, setImporting] = useState(false);
@@ -1940,6 +2242,7 @@ function CompatibleModelsSection({
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
setNewModel("");
notify.success(t("modelAddedSuccess", { modelId }));
onModelsChanged?.();
} catch (error) {
console.error("Error adding model:", error);
notify.error(error instanceof Error ? error.message : t("failedAddModelTryAgain"));
@@ -2016,6 +2319,7 @@ function CompatibleModelsSection({
// Also delete the alias
await onDeleteAlias(alias);
notify.success(t("modelRemovedSuccess"));
onModelsChanged?.();
} catch (error) {
console.error("Error deleting model:", error);
notify.error(error instanceof Error ? error.message : t("failedDeleteModelTryAgain"));
@@ -2078,6 +2382,15 @@ function CompatibleModelsSection({
copied={copied}
onCopy={onCopy}
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 })
}
compatDisabled={compatSavingModelId === modelId}
/>
))}
</div>
@@ -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 }) {

View File

@@ -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<string, unknown>;
const updates: Record<string, unknown> = {};
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 }

View File

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

View File

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

View File

@@ -7,6 +7,82 @@ import { backupDbFile } from "./backup";
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 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<Pick<ModelCompatOverride, "normalizeToolCallId" | "preserveOpenAIDeveloperRole">>
) {
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<string, unknown> = {}
) {
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;
}

View File

@@ -41,6 +41,11 @@ export {
addCustomModel,
removeCustomModel,
updateCustomModel,
getModelCompatOverrides,
mergeModelCompatOverride,
removeModelCompatOverride,
getModelNormalizeToolCallId,
getModelPreserveOpenAIDeveloperRole,
} from "./db/models";
export {

View File

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