mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
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:
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1432,6 +1432,11 @@
|
||||
"compatDeveloperShort": "Developer role",
|
||||
"compatDoNotPreserveDeveloper": "Do not preserve developer role",
|
||||
"compatBadgeNoPreserve": "No preserve",
|
||||
"compatProtocolLabel": "Client request protocol",
|
||||
"compatProtocolHint": "These options apply when OmniRoute detects this request shape (OpenAI Chat, Responses API, or Anthropic Messages).",
|
||||
"compatProtocolOpenAI": "OpenAI Chat Completions",
|
||||
"compatProtocolOpenAIResponses": "OpenAI Responses API",
|
||||
"compatProtocolClaude": "Anthropic Messages",
|
||||
"modelId": "Model ID",
|
||||
"customModelPlaceholder": "e.g. gpt-4.5-turbo",
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -1432,6 +1432,11 @@
|
||||
"compatDeveloperShort": "Developer 角色",
|
||||
"compatDoNotPreserveDeveloper": "不保留 developer 角色",
|
||||
"compatBadgeNoPreserve": "不保留",
|
||||
"compatProtocolLabel": "客户端请求协议",
|
||||
"compatProtocolHint": "以下选项在 OmniRoute 识别到该请求形态(OpenAI Chat、Responses API 或 Anthropic Messages)时生效。",
|
||||
"compatProtocolOpenAI": "OpenAI Chat Completions",
|
||||
"compatProtocolOpenAIResponses": "OpenAI Responses API",
|
||||
"compatProtocolClaude": "Anthropic Messages",
|
||||
"modelId": "模型 ID",
|
||||
"customModelPlaceholder": "例如:gpt-4.5-turbo",
|
||||
"loading": "正在加载...",
|
||||
|
||||
@@ -4,16 +4,61 @@
|
||||
|
||||
import { getDbInstance } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import {
|
||||
MODEL_COMPAT_PROTOCOL_KEYS,
|
||||
type ModelCompatProtocolKey,
|
||||
} from "@/shared/constants/modelCompat";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/** Built-in / alias models: tool-call + developer-role flags without a full custom row */
|
||||
const MODEL_COMPAT_NAMESPACE = "modelCompatOverrides";
|
||||
|
||||
export { MODEL_COMPAT_PROTOCOL_KEYS, type ModelCompatProtocolKey };
|
||||
|
||||
export type ModelCompatPerProtocol = {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
};
|
||||
|
||||
type CompatByProtocolMap = Partial<Record<ModelCompatProtocolKey, ModelCompatPerProtocol>>;
|
||||
|
||||
function isCompatProtocolKey(p: string): p is ModelCompatProtocolKey {
|
||||
return (MODEL_COMPAT_PROTOCOL_KEYS as readonly string[]).includes(p);
|
||||
}
|
||||
|
||||
function deepMergeCompatByProtocol(
|
||||
prev: CompatByProtocolMap | undefined,
|
||||
patch: Partial<Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>>
|
||||
): CompatByProtocolMap {
|
||||
const out: CompatByProtocolMap = { ...(prev || {}) };
|
||||
for (const key of Object.keys(patch) as ModelCompatProtocolKey[]) {
|
||||
if (!isCompatProtocolKey(key)) continue;
|
||||
const deltas = patch[key];
|
||||
if (!deltas || typeof deltas !== "object") continue;
|
||||
const hasDelta =
|
||||
Object.prototype.hasOwnProperty.call(deltas, "normalizeToolCallId") ||
|
||||
Object.prototype.hasOwnProperty.call(deltas, "preserveOpenAIDeveloperRole");
|
||||
if (!hasDelta) continue;
|
||||
const cur: ModelCompatPerProtocol = { ...(out[key] || {}) };
|
||||
if ("normalizeToolCallId" in deltas) {
|
||||
if (deltas.normalizeToolCallId) cur.normalizeToolCallId = true;
|
||||
else delete cur.normalizeToolCallId;
|
||||
}
|
||||
if ("preserveOpenAIDeveloperRole" in deltas) {
|
||||
cur.preserveOpenAIDeveloperRole = Boolean(deltas.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
if (Object.keys(cur).length === 0) delete out[key];
|
||||
else out[key] = cur;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type ModelCompatOverride = {
|
||||
id: string;
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
};
|
||||
|
||||
function readCompatList(providerId: string): ModelCompatOverride[] {
|
||||
@@ -52,13 +97,24 @@ export function getModelCompatOverrides(providerId: string): ModelCompatOverride
|
||||
return readCompatList(providerId);
|
||||
}
|
||||
|
||||
export type ModelCompatPatch = {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean | null;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
};
|
||||
|
||||
function compatByProtocolHasEntries(map: CompatByProtocolMap | undefined): boolean {
|
||||
if (!map || typeof map !== "object") return false;
|
||||
return Object.keys(map).some((k) => {
|
||||
const v = map[k as ModelCompatProtocolKey];
|
||||
return v && typeof v === "object" && Object.keys(v).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeModelCompatOverride(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
patch: Partial<{
|
||||
normalizeToolCallId: boolean;
|
||||
preserveOpenAIDeveloperRole: boolean | null;
|
||||
}>
|
||||
patch: ModelCompatPatch
|
||||
) {
|
||||
const list = readCompatList(providerId);
|
||||
const idx = list.findIndex((e) => e.id === modelId);
|
||||
@@ -75,9 +131,18 @@ export function mergeModelCompatOverride(
|
||||
next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (patch.compatByProtocol && Object.keys(patch.compatByProtocol).length > 0) {
|
||||
const merged = deepMergeCompatByProtocol(next.compatByProtocol, patch.compatByProtocol);
|
||||
if (compatByProtocolHasEntries(merged)) next.compatByProtocol = merged;
|
||||
else delete next.compatByProtocol;
|
||||
}
|
||||
const filtered = list.filter((e) => e.id !== modelId);
|
||||
const hasPreserveFlag = Object.prototype.hasOwnProperty.call(next, "preserveOpenAIDeveloperRole");
|
||||
if (next.normalizeToolCallId || hasPreserveFlag) {
|
||||
if (
|
||||
next.normalizeToolCallId ||
|
||||
hasPreserveFlag ||
|
||||
compatByProtocolHasEntries(next.compatByProtocol)
|
||||
) {
|
||||
filtered.push(next);
|
||||
}
|
||||
writeCompatList(providerId, filtered);
|
||||
@@ -281,6 +346,23 @@ export async function updateCustomModel(
|
||||
if (index === -1) return null;
|
||||
|
||||
const current = models[index];
|
||||
const currentCompat = (current as JsonRecord).compatByProtocol as CompatByProtocolMap | undefined;
|
||||
let mergedCompat: CompatByProtocolMap | undefined = currentCompat;
|
||||
if (
|
||||
updates.compatByProtocol !== undefined &&
|
||||
typeof updates.compatByProtocol === "object" &&
|
||||
updates.compatByProtocol !== null &&
|
||||
!Array.isArray(updates.compatByProtocol)
|
||||
) {
|
||||
mergedCompat = deepMergeCompatByProtocol(
|
||||
currentCompat,
|
||||
updates.compatByProtocol as Partial<
|
||||
Record<ModelCompatProtocolKey, Partial<ModelCompatPerProtocol>>
|
||||
>
|
||||
);
|
||||
if (!compatByProtocolHasEntries(mergedCompat)) mergedCompat = undefined;
|
||||
}
|
||||
|
||||
const next: JsonRecord = {
|
||||
...current,
|
||||
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
|
||||
@@ -299,6 +381,13 @@ export async function updateCustomModel(
|
||||
next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (updates.compatByProtocol !== undefined) {
|
||||
if (mergedCompat && compatByProtocolHasEntries(mergedCompat)) {
|
||||
next.compatByProtocol = mergedCompat;
|
||||
} else {
|
||||
delete next.compatByProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
models[index] = next;
|
||||
|
||||
@@ -335,11 +424,33 @@ function getCustomModelRow(providerId: string, modelId: string): JsonRecord | nu
|
||||
/**
|
||||
* Whether the given provider/model has "normalize tool call id" (9-char Mistral-style) enabled.
|
||||
* Custom model row wins; otherwise {@link getModelCompatOverrides}.
|
||||
* When `sourceFormat` is one of `openai` | `openai-responses` | `claude`, per-protocol
|
||||
* `compatByProtocol[sourceFormat].normalizeToolCallId` overrides the legacy top-level flag.
|
||||
*/
|
||||
export function getModelNormalizeToolCallId(providerId: string, modelId: string): boolean {
|
||||
export function getModelNormalizeToolCallId(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
sourceFormat?: string | null
|
||||
): boolean {
|
||||
const m = getCustomModelRow(providerId, modelId);
|
||||
if (m) return Boolean(m.normalizeToolCallId);
|
||||
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
||||
|
||||
if (m) {
|
||||
if (protocol) {
|
||||
const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol];
|
||||
if (pc && Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) {
|
||||
return Boolean(pc.normalizeToolCallId);
|
||||
}
|
||||
}
|
||||
return Boolean(m.normalizeToolCallId);
|
||||
}
|
||||
const co = readCompatList(providerId).find((e) => e.id === modelId);
|
||||
if (protocol && co?.compatByProtocol?.[protocol]) {
|
||||
const pc = co.compatByProtocol[protocol]!;
|
||||
if (Object.prototype.hasOwnProperty.call(pc, "normalizeToolCallId")) {
|
||||
return Boolean(pc.normalizeToolCallId);
|
||||
}
|
||||
}
|
||||
return Boolean(co?.normalizeToolCallId);
|
||||
}
|
||||
|
||||
@@ -347,19 +458,35 @@ export function getModelNormalizeToolCallId(providerId: string, modelId: string)
|
||||
* Explicit preserve-openai-developer preference for this provider/model.
|
||||
* `undefined` = unset → routing keeps legacy default (preserve developer for OpenAI format).
|
||||
* `false` = map developer → system (e.g. MiniMax). `true` = keep developer.
|
||||
* Per-protocol overrides live under `compatByProtocol[sourceFormat]` when `sourceFormat` matches.
|
||||
*/
|
||||
export function getModelPreserveOpenAIDeveloperRole(
|
||||
providerId: string,
|
||||
modelId: string
|
||||
modelId: string,
|
||||
sourceFormat?: string | null
|
||||
): boolean | undefined {
|
||||
const m = getCustomModelRow(providerId, modelId);
|
||||
const protocol = sourceFormat && isCompatProtocolKey(sourceFormat) ? sourceFormat : null;
|
||||
|
||||
if (m) {
|
||||
if (protocol) {
|
||||
const pc = (m.compatByProtocol as CompatByProtocolMap | undefined)?.[protocol];
|
||||
if (pc && Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(pc.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(m, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(m.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const co = readCompatList(providerId).find((e) => e.id === modelId);
|
||||
if (protocol && co?.compatByProtocol?.[protocol]) {
|
||||
const pc = co.compatByProtocol[protocol]!;
|
||||
if (Object.prototype.hasOwnProperty.call(pc, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(pc.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
if (co && Object.prototype.hasOwnProperty.call(co, "preserveOpenAIDeveloperRole")) {
|
||||
return Boolean(co.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export {
|
||||
getModelPreserveOpenAIDeveloperRole,
|
||||
} from "./db/models";
|
||||
|
||||
export type { ModelCompatPerProtocol, ModelCompatPatch } from "./db/models";
|
||||
|
||||
export {
|
||||
// Combos
|
||||
getCombos,
|
||||
|
||||
9
src/shared/constants/modelCompat.ts
Normal file
9
src/shared/constants/modelCompat.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Model compatibility protocol keys — shared between client UI and server.
|
||||
* Must not import Node or DB code so client components can import safely.
|
||||
*/
|
||||
|
||||
/** Client request shapes from detectFormat — compat options apply when the client uses this protocol */
|
||||
export const MODEL_COMPAT_PROTOCOL_KEYS = ["openai", "openai-responses", "claude"] as const;
|
||||
|
||||
export type ModelCompatProtocolKey = (typeof MODEL_COMPAT_PROTOCOL_KEYS)[number];
|
||||
@@ -340,6 +340,13 @@ export const clearModelAvailabilitySchema = z.object({
|
||||
model: modelIdSchema,
|
||||
});
|
||||
|
||||
const modelCompatPerProtocolSchema = z
|
||||
.object({
|
||||
normalizeToolCallId: z.boolean().optional(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerModelMutationSchema = z.object({
|
||||
provider: z.string().trim().min(1, "provider is required").max(120),
|
||||
modelId: z.string().trim().min(1, "modelId is required").max(240),
|
||||
@@ -349,6 +356,9 @@ export const providerModelMutationSchema = z.object({
|
||||
supportedEndpoints: z.array(z.enum(["chat", "embeddings", "images", "audio"])).default(["chat"]),
|
||||
normalizeToolCallId: z.boolean().optional(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
|
||||
compatByProtocol: z
|
||||
.record(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const pricingFieldsSchema = z
|
||||
|
||||
Reference in New Issue
Block a user