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:
zhang-qiang
2026-03-20 23:03:52 +08:00
parent dfbb9d5fff
commit 1a099ea2f2
6 changed files with 306 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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