From 1a099ea2f2fb4701eb3c1567c24af03d31a87b8b Mon Sep 17 00:00:00 2001 From: zhang-qiang Date: Fri, 20 Mar 2026 23:03:52 +0800 Subject: [PATCH] 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 --- open-sse/services/roleNormalizer.ts | 18 +- open-sse/translator/index.ts | 10 +- .../dashboard/providers/[id]/page.tsx | 287 ++++++++++++++---- src/app/api/provider-models/route.ts | 39 ++- src/lib/db/models.ts | 23 +- src/shared/validation/schemas.ts | 2 +- 6 files changed, 306 insertions(+), 73 deletions(-) diff --git a/open-sse/services/roleNormalizer.ts b/open-sse/services/roleNormalizer.ts index 23cad54ec0..b3d7ebdc3d 100644 --- a/open-sse/services/roleNormalizer.ts +++ b/open-sse/services/roleNormalizer.ts @@ -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, diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 267e035a12..6b8ef94980 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -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 && diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 4d8fcf55e9..4394f76049 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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; + 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; + showDeveloperToggle?: boolean; + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + onNormalizeChange?: (v: boolean) => void; + onPreserveChange?: (v: boolean) => void; + compatDisabled?: boolean; +} + +interface PassthroughModelsSectionProps { + providerAlias: string; + modelAliases: Record; + copied?: string; + onCopy: (text: string, key: string) => void; + onSetAlias: (modelId: string, alias: string) => Promise; + onDeleteAlias: (alias: string) => void; + t: (key: string, values?: Record) => string; + effectiveModelNormalize: (alias: string) => boolean; + effectiveModelPreserveDeveloper: (alias: string) => boolean; + saveModelCompatFlags: ( + modelId: string, + flags: { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; + } + ) => Promise; + 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; + copied?: string; + onCopy: (text: string, key: string) => void; + onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise; + onDeleteAlias: (alias: string) => void; + connections: { id?: string; isActive?: boolean }[]; + isAnthropic?: boolean; + onImportWithProgress: ( + fetchModels: () => Promise<{ models: unknown[] }>, + processModel: (model: unknown) => Promise + ) => Promise; + t: (key: string, values?: Record) => string; + effectiveModelNormalize: (alias: string) => boolean; + effectiveModelPreserveDeveloper: (alias: string) => boolean; + saveModelCompatFlags: ( + modelId: string, + flags: { + normalizeToolCallId?: boolean; + preserveDeveloperRole?: boolean; + preserveOpenAIDeveloperRole?: boolean; + } + ) => Promise; + 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; + expiresAt?: string; +} + +interface ConnectionRowProps { + connection: ConnectionRowConnection; + isOAuth: boolean; + isCodex?: boolean; + isFirst: boolean; + isLast: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + onToggleActive: (isActive?: boolean) => void | Promise; + 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; + onClose: () => void; +} + +interface EditConnectionModalConnection { + id?: string; + name?: string; + email?: string; + priority?: number; + authType?: string; + provider?: string; + providerSpecificData?: Record; + healthCheckInterval?: number; +} + +interface EditConnectionModalProps { + isOpen: boolean; + connection: EditConnectionModalConnection | null; + onSave: (data: unknown) => Promise; + 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; + 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(null); + const panelRef = useRef(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")} {open && ( -
+

{t("compatAdjustmentsTitle")}

@@ -99,14 +295,17 @@ function ModelCompatPopover({ disabled={disabled} /> {showDeveloperToggle && ( - 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. */} + onPreserveChange(!checked)} + disabled={disabled} + /> + )}
@@ -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; - 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 = { 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 (
@@ -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 (
@@ -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: "", diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index e4cae2814b..647b68524e 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -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, + 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), diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index c0272fc577..82fe66a5c7 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -55,7 +55,10 @@ export function getModelCompatOverrides(providerId: string): ModelCompatOverride export function mergeModelCompatOverride( providerId: string, modelId: string, - patch: Partial> + 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; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 70d0bf0e92..1ac20b15e5 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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