feat(compat): per-protocol model compatibility config (V5)

Add per-protocol compatibility options (compatByProtocol) allowing users to configure normalizeToolCallId and preserveOpenAIDeveloperRole per client request protocol (OpenAI Chat, Responses API, Anthropic Messages) instead of globally. Includes frontend Map lookup optimization, type safety improvements, and client-safe constant extraction.

Made-with: Cursor
This commit is contained in:
zhang-qiang
2026-03-21 15:23:42 +08:00
10 changed files with 873 additions and 142 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useNotificationStore } from "@/store/notificationStore";
import PropTypes from "prop-types";
import { useParams, useRouter } from "next/navigation";
@@ -31,6 +31,129 @@ import {
} from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import {
MODEL_COMPAT_PROTOCOL_KEYS,
type ModelCompatProtocolKey,
} from "@/shared/constants/modelCompat";
type CompatByProtocolMap = Partial<
Record<
ModelCompatProtocolKey,
{ normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean }
>
>;
type CompatModelRow = {
id?: string;
name?: string;
source?: string;
apiFormat?: string;
supportedEndpoints?: string[];
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
};
type CompatModelMap = Map<string, CompatModelRow>;
function buildCompatMap(rows: CompatModelRow[]): CompatModelMap {
const m = new Map<string, CompatModelRow>();
for (const r of rows) if (r.id) m.set(r.id, r);
return m;
}
function getProtoSlice(
c: CompatModelRow | undefined,
o: CompatModelRow | undefined,
protocol: string
) {
return c?.compatByProtocol?.[protocol] ?? o?.compatByProtocol?.[protocol];
}
function effectiveNormalizeForProtocol(
modelId: string,
protocol: string,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(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,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(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,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(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,
customMap: CompatModelMap,
overrideMap: CompatModelMap
): boolean {
const c = customMap.get(modelId);
const o = overrideMap.get(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;
}
interface ModelRowProps {
model: { id: string };
@@ -40,10 +163,16 @@ interface ModelRowProps {
onCopy: (text: string, key: string) => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
onNormalizeChange?: (v: boolean) => void;
onPreserveChange?: (v: boolean) => void;
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
saveModelCompatFlags: (
modelId: string,
patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
}
) => void;
compatDisabled?: boolean;
}
@@ -55,10 +184,16 @@ interface PassthroughModelRowProps {
onDeleteAlias: () => void;
t: (key: string, values?: Record<string, unknown>) => string;
showDeveloperToggle?: boolean;
normalizeToolCallId?: boolean;
preserveDeveloperRole?: boolean;
onNormalizeChange?: (v: boolean) => void;
onPreserveChange?: (v: boolean) => void;
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
saveModelCompatFlags: (
modelId: string,
patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
}
) => void;
compatDisabled?: boolean;
}
@@ -230,27 +365,43 @@ 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 panelRef = useRef<HTMLDivElement | null>(null);
const normalizeToolCallId = effectiveModelNormalize(protocol);
const preserveDeveloperRole = effectiveModelPreserveDeveloper(protocol);
const devToggle = showDeveloperToggle && protocol !== "claude";
// Click-outside: check both trigger and panel so that if the panel is ever rendered
// in a portal (outside this subtree), clicks inside the panel still do not close it.
useEffect(() => {
@@ -280,32 +431,47 @@ function ModelCompatPopover({
{open && (
<div
ref={panelRef}
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"
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-2">
<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 && (
<>
{/* Inversion: Toggle checked = "do not preserve" (UI). onPreserveChange(val) = value to store (true = preserve, false = do not preserve), so we pass !checked. */}
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) => onPreserveChange(!checked)}
disabled={disabled}
/>
</>
{devToggle && (
<Toggle
size="sm"
label={t("compatDoNotPreserveDeveloper")}
title={t("preserveDeveloperRoleLabel")}
checked={preserveDeveloperRole === false}
onChange={(checked) =>
onCompatPatch(protocol, { preserveOpenAIDeveloperRole: !checked })
}
disabled={disabled}
/>
)}
</div>
</div>
@@ -348,12 +514,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);
@@ -972,48 +1134,91 @@ export default function ProviderDetailPage() {
const canImportModels = connections.some((conn) => conn.isActive !== false);
const effectiveModelNormalize = (modelId: string) => {
const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as
| { normalizeToolCallId?: boolean }
| undefined;
if (c) return Boolean(c.normalizeToolCallId);
const o = modelMeta.modelCompatOverrides.find((e) => e.id === modelId);
return Boolean(o?.normalizeToolCallId);
};
const customMap = useMemo(() => buildCompatMap(modelMeta.customModels), [modelMeta.customModels]);
const overrideMap = useMemo(
() => buildCompatMap(modelMeta.modelCompatOverrides),
[modelMeta.modelCompatOverrides]
);
const effectiveModelPreserveDeveloper = (modelId: string) => {
const c = modelMeta.customModels.find((m: { id?: string }) => m.id === modelId) as
| Record<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 effectiveModelNormalize = (modelId: string, protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]) =>
effectiveNormalizeForProtocol(modelId, protocol, customMap, overrideMap);
const effectiveModelPreserveDeveloper = (
modelId: string,
protocol = MODEL_COMPAT_PROTOCOL_KEYS[0]
) => effectivePreserveForProtocol(modelId, protocol, customMap, overrideMap);
const saveModelCompatFlags = async (
modelId: string,
patch: { normalizeToolCallId?: boolean; preserveOpenAIDeveloperRole?: boolean }
patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
compatByProtocol?: CompatByProtocolMap;
}
) => {
setCompatSavingModelId(modelId);
try {
const body: Record<string, unknown> = { provider: providerId, modelId, ...patch };
const c = customMap.get(modelId) as Record<string, unknown> | undefined;
let body: Record<string, unknown>;
const onlyCompatByProtocol =
patch.compatByProtocol &&
patch.normalizeToolCallId === undefined &&
patch.preserveOpenAIDeveloperRole === undefined;
if (c) {
if (onlyCompatByProtocol) {
body = {
provider: providerId,
modelId,
compatByProtocol: patch.compatByProtocol,
};
} else {
body = {
provider: providerId,
modelId,
modelName: (c.name as string) || modelId,
source: (c.source as string) || "manual",
apiFormat: (c.apiFormat as string) || "chat-completions",
supportedEndpoints:
Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length
? c.supportedEndpoints
: ["chat"],
normalizeToolCallId:
patch.normalizeToolCallId !== undefined
? patch.normalizeToolCallId
: Boolean(c.normalizeToolCallId),
preserveOpenAIDeveloperRole:
patch.preserveOpenAIDeveloperRole !== undefined
? patch.preserveOpenAIDeveloperRole
: Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")
? Boolean(c.preserveOpenAIDeveloperRole)
: true,
};
if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol;
}
} else {
body = { provider: providerId, modelId, ...patch };
}
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) await fetchProviderModelMeta();
else notify.error(t("failedSaveCustomModel"));
if (!res.ok) {
notify.error(t("failedSaveCustomModel"));
return;
}
} catch {
notify.error(t("failedSaveCustomModel"));
return;
} finally {
setCompatSavingModelId(null);
}
try {
await fetchProviderModelMeta();
} catch {
/* refresh failure is non-critical — data was already saved */
}
};
const renderModelsSection = () => {
@@ -1118,14 +1323,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}
/>
);
@@ -1657,10 +1857,9 @@ function ModelRow({
onCopy,
t,
showDeveloperToggle = true,
normalizeToolCallId,
preserveDeveloperRole,
onNormalizeChange,
onPreserveChange,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
saveModelCompatFlags,
compatDisabled,
}: ModelRowProps) {
return (
@@ -1684,11 +1883,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>
@@ -1705,10 +1905,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,
};
@@ -1804,12 +2003,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}
/>
))}
@@ -1841,10 +2037,9 @@ function PassthroughModelRow({
onDeleteAlias,
t,
showDeveloperToggle = true,
normalizeToolCallId,
preserveDeveloperRole,
onNormalizeChange,
onPreserveChange,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
saveModelCompatFlags,
compatDisabled,
}: PassthroughModelRowProps) {
return (
@@ -1881,11 +2076,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>
@@ -1901,10 +2097,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,
};
@@ -1919,7 +2114,10 @@ function CustomModelsSection({
}: CustomModelsSectionProps) {
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");
@@ -1929,16 +2127,18 @@ function CustomModelsSection({
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 customMap = useMemo(() => buildCompatMap(customModels), [customModels]);
const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]);
const fetchCustomModels = useCallback(async () => {
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
if (res.ok) {
const data = await res.json();
setCustomModels(data.models || []);
setModelCompatOverrides(data.modelCompatOverrides || []);
}
} catch (e) {
console.error("Failed to fetch custom models:", e);
@@ -2004,23 +2204,44 @@ function CustomModelsSection({
? model.supportedEndpoints
: ["chat"]
);
setEditingNormalizeToolCallId(Boolean(model.normalizeToolCallId));
setEditingPreserveDeveloperRole(
Object.prototype.hasOwnProperty.call(model, "preserveOpenAIDeveloperRole")
? Boolean(model.preserveOpenAIDeveloperRole)
: true
);
};
const cancelEdit = () => {
setEditingModelId(null);
setEditingApiFormat("chat-completions");
setEditingEndpoints(["chat"]);
setEditingNormalizeToolCallId(false);
setEditingPreserveDeveloperRole(true);
setSavingModelId(null);
};
const saveCustomCompat = async (
modelId: string,
patch: { compatByProtocol?: CompatByProtocolMap }
) => {
setSavingModelId(modelId);
try {
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, modelId, ...patch }),
});
if (!res.ok) {
notify.error(t("failedSaveCustomModel"));
return;
}
} catch {
notify.error(t("failedSaveCustomModel"));
return;
} finally {
setSavingModelId(null);
}
try {
await fetchCustomModels();
onModelsChanged?.();
} catch {
/* refresh failure is non-critical — data was already saved */
}
};
const saveEdit = async (modelId) => {
if (!editingModelId || editingModelId !== modelId) return;
if (!editingEndpoints.length) {
@@ -2041,8 +2262,6 @@ function CustomModelsSection({
source: model?.source || "manual",
apiFormat: editingApiFormat,
supportedEndpoints: editingEndpoints,
normalizeToolCallId: editingNormalizeToolCallId,
preserveOpenAIDeveloperRole: editingPreserveDeveloperRole,
}),
});
@@ -2205,7 +2424,7 @@ function CustomModelsSection({
🔊 Audio
</span>
)}
{model.normalizeToolCallId && (
{anyNormalizeCompatBadge(model.id, customMap, overrideMap) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-500/15 text-slate-400 font-medium"
title={t("normalizeToolCallIdLabel")}
@@ -2213,7 +2432,7 @@ function CustomModelsSection({
ID×9
</span>
)}
{model.preserveOpenAIDeveloperRole === false && (
{anyNoPreserveCompatBadge(model.id, customMap, overrideMap) && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium"
title={t("compatDoNotPreserveDeveloper")}
@@ -2277,11 +2496,18 @@ function CustomModelsSection({
<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, customMap, overrideMap)
}
effectiveModelPreserveDeveloper={(p) =>
effectivePreserveForProtocol(model.id, p, customMap, overrideMap)
}
onCompatPatch={(protocol, payload) =>
saveCustomCompat(model.id, {
compatByProtocol: { [protocol]: payload },
})
}
showDeveloperToggle
onNormalizeChange={setEditingNormalizeToolCallId}
onPreserveChange={setEditingPreserveDeveloperRole}
disabled={savingModelId === model.id}
/>
</div>
@@ -2560,12 +2786,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

@@ -6,6 +6,7 @@ import {
updateCustomModel,
getModelCompatOverrides,
mergeModelCompatOverride,
type ModelCompatPatch,
} from "@/lib/localDb";
import {
AI_PROVIDERS,
@@ -129,6 +130,7 @@ export async function PUT(request) {
supportedEndpoints,
normalizeToolCallId,
preserveOpenAIDeveloperRole,
compatByProtocol,
} = validation.data;
const raw = rawBody as Record<string, unknown>;
@@ -139,6 +141,9 @@ export async function PUT(request) {
if ("normalizeToolCallId" in raw) updates.normalizeToolCallId = normalizeToolCallId;
if ("preserveOpenAIDeveloperRole" in raw)
updates.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
if ("compatByProtocol" in raw && compatByProtocol !== undefined) {
updates.compatByProtocol = compatByProtocol;
}
const model = await updateCustomModel(provider, modelId, updates);
@@ -147,9 +152,17 @@ export async function PUT(request) {
const compatOnly =
rawKeys.length > 0 &&
rawKeys.every((k) =>
["provider", "modelId", "normalizeToolCallId", "preserveOpenAIDeveloperRole"].includes(k)
[
"provider",
"modelId",
"normalizeToolCallId",
"preserveOpenAIDeveloperRole",
"compatByProtocol",
].includes(k)
) &&
("normalizeToolCallId" in raw || "preserveOpenAIDeveloperRole" in raw);
("normalizeToolCallId" in raw ||
"preserveOpenAIDeveloperRole" in raw ||
"compatByProtocol" in raw);
if (compatOnly) {
const knownProvider =
!!provider &&
@@ -165,10 +178,7 @@ export async function PUT(request) {
{ status: 400 }
);
}
const patch: {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean | null;
} = {};
const patch: ModelCompatPatch = {};
if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") {
patch.normalizeToolCallId = normalizeToolCallId;
}
@@ -178,6 +188,9 @@ export async function PUT(request) {
? preserveOpenAIDeveloperRole
: undefined;
}
if ("compatByProtocol" in raw && compatByProtocol && typeof compatByProtocol === "object") {
patch.compatByProtocol = compatByProtocol;
}
if (Object.keys(patch).length > 0) {
mergeModelCompatOverride(provider, modelId, patch);
}