fix(models): 系统渠道模型 - 上游 ID 重命名级联价格档并补管理端可见性 (#514)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-16 23:55:22 +08:00
8 changed files with 222 additions and 5 deletions

View File

@@ -10,7 +10,7 @@ import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type Mode
import type { ModelProtocolDefinition } from "@/lib/model-protocols";
import { createAdminChannelModel, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { defaultPriceTier, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form";
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierResolutionFromForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "./channel-model-price-tier-form";
import { PriceTierFields } from "./channel-model-price-tier-fields";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
@@ -50,6 +50,8 @@ export function ChannelModelEditor({
const modelEnabled = Form.useWatch("enabled", form) !== false;
const priceTiers = Form.useWatch("priceTiers", form) || [];
const hasDefaultPriceTier = priceTiers.some((tier) => tier.matchMode === "default");
const tiersWithOwnUpstream = priceTiers.filter((tier) => tier.providerModelKey?.trim());
const modelUpstream = normalizeUpstreamModelKey(providerModelKey || modelKey);
const busy = saving || testing;
const requestClose = () => {
@@ -252,7 +254,7 @@ export function ChannelModelEditor({
placeholder="例如:seedance-2-5"
/>
</Form.Item>
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。">
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="后台显示名称" tooltip="仅用于后台识别,不改变调用 ID。">
@@ -262,6 +264,14 @@ export function ChannelModelEditor({
<ModelIconPicker />
</Form.Item>
</div>
{tiersWithOwnUpstream.length ? (
<Alert
type="info"
showIcon
title={`${tiersWithOwnUpstream.length} 个价格档配置了独立上游模型 ID`}
description="命中的请求会优先使用价格档的上游 ID;修改上方上游模型 ID 时,只有与旧值相同的档位会自动跟随更新,其余保持不变。"
/>
) : null}
</section>
<section className="admin-model-editor-section">
<SectionHeading title="能力与协议" description="先选任务类型,再选择对应的调用协议;更换后请核对参数与价格。" />
@@ -376,6 +386,7 @@ export function ChannelModelEditor({
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
modelUpstream={modelUpstream}
onDirty={() => {
dirtyRef.current = true;
}}

View File

@@ -1,9 +1,10 @@
import { Button, Form, Input, InputNumber, Segmented, Select, Switch, type FormInstance } from "antd";
import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Switch, type FormInstance } from "antd";
import { Trash2 } from "lucide-react";
import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker";
import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form";
import { normalizeUpstreamModelKey } from "./channel-model-price-tier-form";
export function PriceTierFields({
index,
@@ -12,6 +13,7 @@ export function PriceTierFields({
capability,
protocol,
capabilityConfig,
modelUpstream,
onDirty,
onRemove,
}: {
@@ -21,6 +23,7 @@ export function PriceTierFields({
capability: EditableCapability | undefined;
protocol: ModelProtocol | undefined;
capabilityConfig?: ModelCapabilityConfig;
modelUpstream: string;
onDirty: () => void;
onRemove: () => void;
}) {
@@ -28,6 +31,10 @@ export function PriceTierFields({
const matchMode = Form.useWatch(["priceTiers", index, "matchMode"], form) || "default";
const priceConfigured = Form.useWatch(["priceTiers", index, "priceConfigured"], form) !== false;
const tierEnabled = Form.useWatch(["priceTiers", index, "enabled"], form) !== false;
const tierUpstream = normalizeUpstreamModelKey(Form.useWatch(["priceTiers", index, "providerModelKey"], form));
// 统一价格档不展示上游键输入,但历史数据可能固化了独立键;它与模型级不一致时会
// 静默改变实际发往供应商的模型,必须显式提示并允许一键恢复“跟随模型默认”。
const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : "";
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
@@ -155,6 +162,25 @@ export function PriceTierFields({
</Form.Item>
</div>
)}
{staleTierUpstream ? (
<Alert
type="warning"
showIcon
title="该统一价格档仍指向独立上游模型 ID"
description={`命中此档的请求会优先使用「${staleTierUpstream}」,而不是模型默认上游「${modelUpstream}」。`}
action={
<Button
size="small"
onClick={() => {
onDirty();
form.setFieldValue(["priceTiers", index, "providerModelKey"], "");
}}
>
清除并跟随模型
</Button>
}
/>
) : null}
</div>
</div>
);

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
// Node 原生 TypeScript 测试运行器要求保留扩展名,项目编译器不允许该写法。
// @ts-expect-error -- Node 原生 TypeScript 测试运行器需要保留扩展名。
import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm } from "./channel-model-price-tier-form.ts";
test("编辑无价格档的旧模型时不再把顶层上游键固化进统一价格档", () => {
const legacy: ChannelModel = {
id: "model-1",
modelKey: "deepseek-v4.1-flash",
providerModelKey: "deepseek-v4.1-flash",
billingMode: "fixed_request",
unitPriceMicrocredits: 10,
priceConfigured: true,
enabled: true,
} as unknown as ChannelModel;
const tier = legacyPriceTierToForm(legacy);
assert.equal(tier.providerModelKey, "");
assert.equal(tier.matchMode, "default");
});
test("已存档位键在回显时保持原值,清除与否交给管理端告警处理", () => {
const tier = priceTierToForm({
selector: {},
resolution: "*",
videoSeconds: 0,
providerModelKey: "sku-preview",
billingMode: "fixed_request",
unitPriceMicrocredits: 10,
inputTokenPriceMicrocredits: 0,
outputTokenPriceMicrocredits: 0,
cachedTokenPriceMicrocredits: 0,
priceConfigured: true,
enabled: true,
} as unknown as ChannelModelPriceTier);
assert.equal(tier.providerModelKey, "sku-preview");
assert.equal(tier.matchMode, "default");
});
test("新建默认价格档不携带上游键", () => {
assert.equal(defaultPriceTier().providerModelKey, "");
assert.equal(defaultPriceTier("advanced").providerModelKey, "");
});

View File

@@ -3,6 +3,11 @@ import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet"
export type PriceTierMatchMode = "default" | "advanced";
// 与后端保存口径一致:上游键比较前剥掉一次 models/ 前缀,避免极旧存量行误报差异。
export function normalizeUpstreamModelKey(value: unknown): string {
return String(value || "").trim().replace(/^models\//, "");
}
export type PriceTierFormValues = {
matchMode: PriceTierMatchMode;
operation: string;
@@ -66,7 +71,8 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue
export function legacyPriceTierToForm(item: ChannelModel): PriceTierFormValues {
return {
...defaultPriceTier(),
providerModelKey: item.providerModelKey || "",
// 不预填顶层上游键:统一价格档保持“跟随模型默认上游 ID”,
// 否则首次保存会把当时的上游键固化进档位,之后模型级重命名会被旧值遮蔽。
billingMode: item.billingMode,
unitPrice: item.unitPriceMicrocredits / 1_000_000,
inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,