mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
feat(zws-v2): model compat, provider-models hardening, provider page types
- roleNormalizer/translator: ZWS v2 role handling and comments - models + schemas: compat overrides, nullable preserveOpenAIDeveloperRole - provider-models API: generic GET 500; compatOnly validates known provider - providers [id] page: typed props; minimal saveModelCompatFlags PATCH Made-with: Cursor
This commit is contained in:
@@ -80,9 +80,17 @@ function supportsSystemRole(provider: string, model: string): boolean {
|
||||
* OpenAI Responses API sends `developer`; MiniMax and most OpenAI-compatible gateways
|
||||
* only accept system/user/assistant/tool and return "role param error" otherwise.
|
||||
*
|
||||
* Logic:
|
||||
* - When targetFormat !== "openai": always convert developer → system (Claude, Gemini, etc.).
|
||||
* - When targetFormat === "openai": convert only when preserveDeveloperRole === false.
|
||||
* This covers OpenAI-compatible providers (MiniMax, etc.) that use targetFormat "openai"
|
||||
* but do not accept the developer role; the per-model preserveDeveloperRole flag is set
|
||||
* via the dashboard "Compatibility" toggle ("Do not preserve developer role").
|
||||
* - When targetFormat === "openai" && preserveDeveloperRole !== false: keep developer (e.g. official OpenAI).
|
||||
*
|
||||
* @param messages - Array of messages
|
||||
* @param targetFormat - The target format (e.g., "openai", "claude", "gemini")
|
||||
* @param preserveDeveloperRole - For targetFormat openai: undefined/true = keep developer (legacy default); false = map to system (MiniMax etc.)
|
||||
* @param preserveDeveloperRole - For targetFormat openai: undefined/true = keep developer (legacy default); false = map to system (MiniMax and other OpenAI-compatible gateways that reject developer)
|
||||
*/
|
||||
export function normalizeDeveloperRole(
|
||||
messages: NormalizedMessage[] | unknown,
|
||||
@@ -170,8 +178,14 @@ export function normalizeSystemRole(
|
||||
/**
|
||||
* Full role normalization pipeline.
|
||||
* Call this before sending the request to the provider.
|
||||
* Applies developer→system (when needed) then system→user for providers/models that do not support system role.
|
||||
*
|
||||
* @param preserveDeveloperRole - See {@link normalizeDeveloperRole}
|
||||
* @param messages - Array of messages to normalize (or non-array, returned as-is)
|
||||
* @param provider - Provider id for capability lookup (e.g. system role support)
|
||||
* @param model - Model id for capability lookup
|
||||
* @param targetFormat - Target request format (e.g. "openai", "claude", "gemini"); see {@link normalizeDeveloperRole}
|
||||
* @param preserveDeveloperRole - Optional; see {@link normalizeDeveloperRole}. When false, developer role is mapped to system.
|
||||
* @returns Normalized messages array, or the original value if messages is not an array
|
||||
*/
|
||||
export function normalizeRoles(
|
||||
messages: NormalizedMessage[] | unknown,
|
||||
|
||||
@@ -96,7 +96,9 @@ export function translateRequest(
|
||||
// Fix missing tool responses (insert empty tool_result if needed)
|
||||
fixMissingToolResponses(result);
|
||||
|
||||
// Normalize roles: developer→system unless preserved, system→user for incompatible models
|
||||
// Normalize roles: developer→system unless preserved, system→user for incompatible models.
|
||||
// This handles (1) sourceFormat openai with messages containing developer → non-openai target
|
||||
// or preserveDeveloperRole=false, and (2) any other path where result.messages already exists.
|
||||
if (result.messages && Array.isArray(result.messages)) {
|
||||
result.messages = normalizeRoles(
|
||||
result.messages,
|
||||
@@ -151,8 +153,10 @@ 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).
|
||||
// Second role normalization: only for OPENAI_RESPONSES. Here messages are built from input
|
||||
// after the translation step, so the first normalizeRoles (above) did not see them. For
|
||||
// sourceFormat openai with messages already on the body, the first block handles developer
|
||||
// → system (non-openai target or preserveDeveloperRole=false); no second pass needed.
|
||||
if (
|
||||
sourceFormat === FORMATS.OPENAI_RESPONSES &&
|
||||
result.messages &&
|
||||
|
||||
@@ -32,6 +32,193 @@ import {
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
interface ModelRowProps {
|
||||
model: { id: string };
|
||||
fullModel: string;
|
||||
alias?: string;
|
||||
copied?: string;
|
||||
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;
|
||||
compatDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface PassthroughModelRowProps {
|
||||
modelId: string;
|
||||
fullModel: string;
|
||||
copied?: string;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onDeleteAlias: () => void;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
showDeveloperToggle?: boolean;
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveDeveloperRole?: boolean;
|
||||
onNormalizeChange?: (v: boolean) => void;
|
||||
onPreserveChange?: (v: boolean) => void;
|
||||
compatDisabled?: boolean;
|
||||
}
|
||||
|
||||
interface PassthroughModelsSectionProps {
|
||||
providerAlias: string;
|
||||
modelAliases: Record<string, string>;
|
||||
copied?: string;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onSetAlias: (modelId: string, alias: string) => Promise<void>;
|
||||
onDeleteAlias: (alias: string) => void;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
effectiveModelNormalize: (alias: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (alias: string) => boolean;
|
||||
saveModelCompatFlags: (
|
||||
modelId: string,
|
||||
flags: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveDeveloperRole?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
}
|
||||
) => Promise<void>;
|
||||
compatSavingModelId?: string;
|
||||
}
|
||||
|
||||
interface CustomModelsSectionProps {
|
||||
providerId: string;
|
||||
providerAlias: string;
|
||||
copied?: string;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onModelsChanged?: () => void;
|
||||
}
|
||||
|
||||
interface CompatibleModelsSectionProps {
|
||||
providerStorageAlias: string;
|
||||
providerDisplayAlias: string;
|
||||
modelAliases: Record<string, string>;
|
||||
copied?: string;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise<void>;
|
||||
onDeleteAlias: (alias: string) => void;
|
||||
connections: { id?: string; isActive?: boolean }[];
|
||||
isAnthropic?: boolean;
|
||||
onImportWithProgress: (
|
||||
fetchModels: () => Promise<{ models: unknown[] }>,
|
||||
processModel: (model: unknown) => Promise<boolean>
|
||||
) => Promise<void>;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
effectiveModelNormalize: (alias: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (alias: string) => boolean;
|
||||
saveModelCompatFlags: (
|
||||
modelId: string,
|
||||
flags: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveDeveloperRole?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
}
|
||||
) => Promise<void>;
|
||||
compatSavingModelId?: string;
|
||||
onModelsChanged?: () => void;
|
||||
}
|
||||
|
||||
interface CooldownTimerProps {
|
||||
until: string | number | Date;
|
||||
}
|
||||
|
||||
interface ConnectionRowConnection {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
rateLimitedUntil?: string;
|
||||
rateLimitProtection?: boolean;
|
||||
testStatus?: string;
|
||||
isActive?: boolean;
|
||||
priority?: number;
|
||||
lastError?: string;
|
||||
lastErrorType?: string;
|
||||
lastErrorSource?: string;
|
||||
errorCode?: string | number;
|
||||
globalPriority?: number;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
interface ConnectionRowProps {
|
||||
connection: ConnectionRowConnection;
|
||||
isOAuth: boolean;
|
||||
isCodex?: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onToggleActive: (isActive?: boolean) => void | Promise<void>;
|
||||
onToggleRateLimit: (enabled?: boolean) => void;
|
||||
onToggleCodex5h?: (enabled?: boolean) => void;
|
||||
onToggleCodexWeekly?: (enabled?: boolean) => void;
|
||||
onRetest: () => void;
|
||||
isRetesting?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onReauth?: () => void;
|
||||
onProxy?: () => void;
|
||||
hasProxy?: boolean;
|
||||
proxySource?: string;
|
||||
proxyHost?: string;
|
||||
onRefreshToken?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
interface AddApiKeyModalProps {
|
||||
isOpen: boolean;
|
||||
provider?: string;
|
||||
providerName?: string;
|
||||
isCompatible?: boolean;
|
||||
isAnthropic?: boolean;
|
||||
onSave: (data: {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
priority: number;
|
||||
baseUrl?: string;
|
||||
}) => Promise<void | unknown>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface EditConnectionModalConnection {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
priority?: number;
|
||||
authType?: string;
|
||||
provider?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
healthCheckInterval?: number;
|
||||
}
|
||||
|
||||
interface EditConnectionModalProps {
|
||||
isOpen: boolean;
|
||||
connection: EditConnectionModalConnection | null;
|
||||
onSave: (data: unknown) => Promise<void | unknown>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface EditCompatibleNodeModalNode {
|
||||
id?: string;
|
||||
name?: string;
|
||||
prefix?: string;
|
||||
apiType?: string;
|
||||
baseUrl?: string;
|
||||
chatPath?: string;
|
||||
modelsPath?: string;
|
||||
}
|
||||
|
||||
interface EditCompatibleNodeModalProps {
|
||||
isOpen: boolean;
|
||||
node: EditCompatibleNodeModalNode | null;
|
||||
onSave: (data: unknown) => Promise<void>;
|
||||
onClose: () => void;
|
||||
isAnthropic?: boolean;
|
||||
}
|
||||
|
||||
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
|
||||
const record =
|
||||
policy && typeof policy === "object" && !Array.isArray(policy)
|
||||
@@ -62,11 +249,17 @@ function ModelCompatPopover({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 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(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const target = e.target as Node;
|
||||
const insideTrigger = ref.current?.contains(target);
|
||||
const insidePanel = panelRef.current?.contains(target);
|
||||
if (!insideTrigger && !insidePanel) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
return () => document.removeEventListener("mousedown", onDocClick);
|
||||
@@ -85,7 +278,10 @@ function ModelCompatPopover({
|
||||
{t("compatButtonLabel")}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 min-w-[200px] p-3 rounded-lg border border-border bg-white dark:bg-zinc-900 shadow-xl ring-1 ring-black/5 dark:ring-white/10">
|
||||
<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"
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-muted mb-2">
|
||||
{t("compatAdjustmentsTitle")}
|
||||
</p>
|
||||
@@ -99,14 +295,17 @@ function ModelCompatPopover({
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showDeveloperToggle && (
|
||||
<Toggle
|
||||
size="sm"
|
||||
label={t("compatDoNotPreserveDeveloper")}
|
||||
title={t("preserveDeveloperRoleLabel")}
|
||||
checked={preserveDeveloperRole === false}
|
||||
onChange={(checked) => onPreserveChange(!checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<>
|
||||
{/* 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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -802,36 +1001,7 @@ export default function ProviderDetailPage() {
|
||||
) => {
|
||||
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 body: Record<string, unknown> = { provider: providerId, modelId, ...patch };
|
||||
const res = await fetch("/api/provider-models", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1492,7 +1662,7 @@ function ModelRow({
|
||||
onNormalizeChange,
|
||||
onPreserveChange,
|
||||
compatDisabled,
|
||||
}: any) {
|
||||
}: ModelRowProps) {
|
||||
return (
|
||||
<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">
|
||||
@@ -1554,7 +1724,7 @@ function PassthroughModelsSection({
|
||||
effectiveModelPreserveDeveloper,
|
||||
saveModelCompatFlags,
|
||||
compatSavingModelId,
|
||||
}) {
|
||||
}: PassthroughModelsSectionProps) {
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
@@ -1676,7 +1846,7 @@ function PassthroughModelRow({
|
||||
onNormalizeChange,
|
||||
onPreserveChange,
|
||||
compatDisabled,
|
||||
}: any) {
|
||||
}: PassthroughModelRowProps) {
|
||||
return (
|
||||
<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">
|
||||
@@ -1740,7 +1910,13 @@ PassthroughModelRow.propTypes = {
|
||||
|
||||
// ============ Custom Models Section (for ALL providers) ============
|
||||
|
||||
function CustomModelsSection({ providerId, providerAlias, copied, onCopy, onModelsChanged }) {
|
||||
function CustomModelsSection({
|
||||
providerId,
|
||||
providerAlias,
|
||||
copied,
|
||||
onCopy,
|
||||
onModelsChanged,
|
||||
}: CustomModelsSectionProps) {
|
||||
const t = useTranslations("providers");
|
||||
const notify = useNotificationStore();
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
@@ -2176,7 +2352,7 @@ function CompatibleModelsSection({
|
||||
saveModelCompatFlags,
|
||||
compatSavingModelId,
|
||||
onModelsChanged,
|
||||
}) {
|
||||
}: CompatibleModelsSectionProps) {
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -2423,7 +2599,7 @@ CompatibleModelsSection.propTypes = {
|
||||
onModelsChanged: PropTypes.func,
|
||||
};
|
||||
|
||||
function CooldownTimer({ until }) {
|
||||
function CooldownTimer({ until }: CooldownTimerProps) {
|
||||
const [remaining, setRemaining] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2636,7 +2812,7 @@ function ConnectionRow({
|
||||
proxyHost,
|
||||
onRefreshToken,
|
||||
isRefreshing,
|
||||
}) {
|
||||
}: ConnectionRowProps) {
|
||||
const t = useTranslations("providers");
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || t("oauthAccount")
|
||||
@@ -2961,7 +3137,7 @@ function AddApiKeyModal({
|
||||
isAnthropic,
|
||||
onSave,
|
||||
onClose,
|
||||
}) {
|
||||
}: AddApiKeyModalProps) {
|
||||
const t = useTranslations("providers");
|
||||
const isBailian = provider === "bailian-coding-plan";
|
||||
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
|
||||
@@ -3169,7 +3345,7 @@ function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnectionModalProps) {
|
||||
const t = useTranslations("providers");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
@@ -3192,7 +3368,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
const existingBaseUrl = connection.providerSpecificData?.baseUrl;
|
||||
const rawBaseUrl = connection.providerSpecificData?.baseUrl;
|
||||
const existingBaseUrl = typeof rawBaseUrl === "string" ? rawBaseUrl : "";
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
@@ -3314,7 +3491,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
|
||||
}
|
||||
}
|
||||
const error = await onSave(updates);
|
||||
const error = (await onSave(updates)) as void | unknown;
|
||||
if (error) {
|
||||
setSaveError(typeof error === "string" ? error : t("failedSaveConnection"));
|
||||
}
|
||||
@@ -3524,7 +3701,13 @@ EditConnectionModal.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) {
|
||||
function EditCompatibleNodeModal({
|
||||
isOpen,
|
||||
node,
|
||||
onSave,
|
||||
onClose,
|
||||
isAnthropic,
|
||||
}: EditCompatibleNodeModalProps) {
|
||||
const t = useTranslations("providers");
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
getModelCompatOverrides,
|
||||
mergeModelCompatOverride,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
isOpenAICompatibleProvider,
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { providerModelMutationSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -32,9 +37,9 @@ export async function GET(request) {
|
||||
const modelCompatOverrides = provider ? getModelCompatOverrides(provider) : [];
|
||||
|
||||
return Response.json({ models, modelCompatOverrides });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: { message: error.message, type: "server_error" } },
|
||||
{ error: { message: "Failed to fetch provider models", type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
@@ -146,20 +151,36 @@ export async function PUT(request) {
|
||||
) &&
|
||||
("normalizeToolCallId" in raw || "preserveOpenAIDeveloperRole" in raw);
|
||||
if (compatOnly) {
|
||||
const knownProvider =
|
||||
!!provider &&
|
||||
(Object.prototype.hasOwnProperty.call(
|
||||
AI_PROVIDERS as Record<string, unknown>,
|
||||
provider
|
||||
) ||
|
||||
isOpenAICompatibleProvider(provider) ||
|
||||
isAnthropicCompatibleProvider(provider));
|
||||
if (!knownProvider) {
|
||||
return Response.json(
|
||||
{ error: { message: "Unknown provider", type: "validation_error" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const patch: {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean | null;
|
||||
} = {};
|
||||
if ("normalizeToolCallId" in raw && typeof normalizeToolCallId === "boolean") {
|
||||
patch.normalizeToolCallId = normalizeToolCallId;
|
||||
}
|
||||
if (
|
||||
"preserveOpenAIDeveloperRole" in raw &&
|
||||
typeof preserveOpenAIDeveloperRole === "boolean"
|
||||
) {
|
||||
patch.preserveOpenAIDeveloperRole = preserveOpenAIDeveloperRole;
|
||||
if ("preserveOpenAIDeveloperRole" in raw) {
|
||||
patch.preserveOpenAIDeveloperRole =
|
||||
preserveOpenAIDeveloperRole === null || typeof preserveOpenAIDeveloperRole === "boolean"
|
||||
? preserveOpenAIDeveloperRole
|
||||
: undefined;
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
mergeModelCompatOverride(provider, modelId, patch);
|
||||
}
|
||||
mergeModelCompatOverride(provider, modelId, patch);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
modelCompatOverrides: getModelCompatOverrides(provider),
|
||||
|
||||
@@ -55,7 +55,10 @@ export function getModelCompatOverrides(providerId: string): ModelCompatOverride
|
||||
export function mergeModelCompatOverride(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
patch: Partial<Pick<ModelCompatOverride, "normalizeToolCallId" | "preserveOpenAIDeveloperRole">>
|
||||
patch: Partial<{
|
||||
normalizeToolCallId: boolean;
|
||||
preserveOpenAIDeveloperRole: boolean | null;
|
||||
}>
|
||||
) {
|
||||
const list = readCompatList(providerId);
|
||||
const idx = list.findIndex((e) => e.id === modelId);
|
||||
@@ -66,7 +69,11 @@ export function mergeModelCompatOverride(
|
||||
else delete next.normalizeToolCallId;
|
||||
}
|
||||
if ("preserveOpenAIDeveloperRole" in patch) {
|
||||
next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole);
|
||||
if (patch.preserveOpenAIDeveloperRole === null) {
|
||||
delete next.preserveOpenAIDeveloperRole; // unset: revert to default (undefined at read time)
|
||||
} else {
|
||||
next.preserveOpenAIDeveloperRole = Boolean(patch.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
const filtered = list.filter((e) => e.id !== modelId);
|
||||
const hasPreserveFlag = Object.prototype.hasOwnProperty.call(next, "preserveOpenAIDeveloperRole");
|
||||
@@ -274,7 +281,7 @@ export async function updateCustomModel(
|
||||
if (index === -1) return null;
|
||||
|
||||
const current = models[index];
|
||||
const next = {
|
||||
const next: JsonRecord = {
|
||||
...current,
|
||||
...(updates.modelName !== undefined ? { name: updates.modelName || current.name } : {}),
|
||||
...(updates.apiFormat !== undefined ? { apiFormat: updates.apiFormat } : {}),
|
||||
@@ -284,10 +291,14 @@ export async function updateCustomModel(
|
||||
...(updates.normalizeToolCallId !== undefined
|
||||
? { normalizeToolCallId: Boolean(updates.normalizeToolCallId) }
|
||||
: {}),
|
||||
...(updates.preserveOpenAIDeveloperRole !== undefined
|
||||
? { preserveOpenAIDeveloperRole: Boolean(updates.preserveOpenAIDeveloperRole) }
|
||||
: {}),
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "preserveOpenAIDeveloperRole")) {
|
||||
if (updates.preserveOpenAIDeveloperRole === null) {
|
||||
delete next.preserveOpenAIDeveloperRole;
|
||||
} else {
|
||||
next.preserveOpenAIDeveloperRole = Boolean(updates.preserveOpenAIDeveloperRole);
|
||||
}
|
||||
}
|
||||
|
||||
models[index] = next;
|
||||
|
||||
|
||||
@@ -348,7 +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(),
|
||||
preserveOpenAIDeveloperRole: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
const pricingFieldsSchema = z
|
||||
|
||||
Reference in New Issue
Block a user