feat(models): 模型目录与计费 - 按展示名分组并隔离管理员积分成本

This commit is contained in:
ddCat
2026-09-19 09:54:27 +08:00
parent 5ad05efb58
commit 237f2e2a6e
49 changed files with 802 additions and 276 deletions

View File

@@ -245,7 +245,7 @@ export function ModelPicker({
theme={theme}
creationVariant
showConfiguredModelName={showConfiguredModelName}
label={group.kind === "product" || group.kind === "system-channel" ? modelGroup.label : undefined}
label={group.kind === "product" ? modelGroup.label : undefined}
requirements={requirements}
showPrice={showOptionPrices && creditsEnabled}
disabledReason={disabledReason}

View File

@@ -6,7 +6,7 @@ export type ModelPickerGroup = {
label: string;
icon: string;
scope: string;
kind: "product" | "channel" | "system-channel";
kind: "product" | "channel";
models: DisplayModelGroup[];
};
@@ -20,29 +20,24 @@ export function isDirectSystemModel(config: AiConfig, value: string) {
export function modelChannelLabel(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(value));
return cost?.channelLabel?.trim() || channel.publicAlias?.trim() || channel.name || "未命名渠道";
return cost?.channelLabel?.trim() || channel.name || "未命名渠道";
}
// 系统模型按渠道聚合展示,但每个模型仍保留独立的 channelId::modelKey 选择值。
// 一级按模型展示名跨渠道聚合;二级保留每条渠道模型的独立选择值、能力和售价。
export function groupModelsForPicker(config: AiConfig, options: string[]): ModelPickerGroup[] {
const groups = new Map<string, ModelPickerGroup>();
for (const channel of config.channels) {
const models = options.filter((value) => resolveModelChannel(config, value).id === channel.id);
const directModels = models.filter((value) => isDirectSystemModel(config, value));
if (directModels.length) {
const key = JSON.stringify(["system-channel", channel.id]);
groups.set(key, {
key,
label: channel.name || "未命名渠道",
icon: modelIcon(config, directModels[0]),
scope: "平台服务",
kind: "system-channel",
models: directModels.map((value) => ({
key: value,
label: configuredModelDisplayName(config, value),
models: [value],
})),
});
for (const value of directModels) {
const label = configuredModelDisplayName(config, value);
const key = JSON.stringify(["product", label]);
let group = groups.get(key);
if (!group) {
group = { key, label, icon: modelIcon(config, value), scope: "平台服务", kind: "product", models: [] };
groups.set(key, group);
}
group.models.push({ key: value, label: modelChannelLabel(config, value), models: [value] });
}
const otherModels = models.filter((value) => !isDirectSystemModel(config, value));
if (otherModels.length) {

View File

@@ -1,12 +1,12 @@
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog, type PublicLogicalModel } from "@/services/api/logical-models";
import { getModelCatalog, type CapabilitySpec, type ModelCatalogResponse, type OptionConstraint, type PublicChannelCatalog } from "@/services/api/logical-models";
import { localForageStorage } from "@/lib/localforage-storage";
import { appQueryClient } from "@/lib/query-client";
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { imageSizeConfigWithPresets } from "@/lib/image-size-presets";
@@ -90,12 +90,8 @@ export async function refreshSystemChannels() {
useConfigStore.getState().mergeSystemChannels(modelCatalogChannels(catalog));
}
// 模型目录来源决定数据形状;这里统一做运行时收口,避免畸形响应被当成“空目录”写入用户配置。
// 目录仅接受系统渠道模型,避免畸形响应被当成空目录写入配置。
function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] {
if (catalog.source === "frontend") {
if (!Array.isArray(catalog.models)) throw new Error("模型目录响应缺少前台模型列表");
return managedModelChannels(catalog.models);
}
if (catalog.source === "system") {
if (!Array.isArray(catalog.channels)) throw new Error("模型目录响应缺少系统渠道列表");
return systemChannelModelChannels(catalog.channels);
@@ -103,42 +99,6 @@ function modelCatalogChannels(catalog: ModelCatalogResponse): ModelChannel[] {
throw new Error("模型目录响应来源无效");
}
function managedModelChannels(models: PublicLogicalModel[]) {
const availableModels = models.filter((item) => item.available);
if (!availableModels.length) return [];
const managed: ModelChannel = {
id: PUBLIC_MODEL_CATALOG_ID,
name: "平台模型",
baseUrl: "/api",
apiKey: "system",
apiFormat: "openai",
scope: "system",
enabled: true,
models: availableModels.map((item) => item.id),
modelAliases: Object.fromEntries(availableModels.flatMap((item) => (item.legacyModelIds || []).map((legacyID) => [legacyID, item.id]))),
modelCosts: availableModels.map((item) => ({
model: item.id,
displayName: item.name,
description: item.description,
icon: item.icon,
capability: item.capability,
pricePolicy: item.pricePolicy,
billingMode: item.billingMode,
unitPriceMicrocredits: item.unitPriceMicrocredits,
inputTokenPriceMicrocredits: item.inputPriceMicrocredits,
outputTokenPriceMicrocredits: item.outputPriceMicrocredits,
cachedTokenPriceMicrocredits: item.cachedPriceMicrocredits,
capabilityConfig: projectLogicalCapability(item.capabilitySpec, item.defaultOptions),
logicalModelId: item.id,
logicalCapabilitySpec: item.capabilitySpec,
logicalCapabilityProfiles: item.capabilityProfiles,
logicalPriceTiers: item.priceTiers,
defaultOptions: item.defaultOptions,
})),
};
return [managed];
}
// 系统渠道模型转换为前端配置格式
export function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] {
return channels.map((channel) => {

View File

@@ -19,7 +19,6 @@ import { ChannelOrderDialog } from "../components/channel-order-dialog";
type ChannelFormValues = {
name: string;
publicAlias?: string;
baseUrl: string;
apiKey?: string;
secretKey?: string;
@@ -32,7 +31,6 @@ type ChannelFormValues = {
export function adminChannelSavePayload(values: ChannelFormValues) {
return {
name: values.name.trim(),
publicAlias: values.publicAlias?.trim() || "",
baseUrl: values.baseUrl.trim(),
apiKey: values.apiKey?.trim() || "",
secretKey: values.secretKey?.trim() || "",
@@ -122,7 +120,6 @@ export default function ChannelsPage() {
: { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true },
);
setDrawerOpen(true);
form.setFieldsValue({ publicAlias: channel?.publicAlias || "" });
};
const closeDrawer = () => {
@@ -208,7 +205,6 @@ export default function ChannelsPage() {
</div>
),
},
{ title: "前台名称", width: 160, render: (_, channel) => <span className={channel.publicAlias ? "" : "text-foreground/45"}>{channel.publicAlias || channel.name}</span> },
{ title: "模型", dataIndex: "models", width: 100, align: "center", render: (models: string[]) => `${models?.length || 0} 个` },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => (value > 0 ? value : <span className="text-foreground/45">跟随系统</span>) },
{ title: "凭证", width: 130, align: "center", render: (_, channel) => <AdminStatusBadge label={channel.hasApiKey ? (channel.hasSecretKey ? "AK/SK 已配置" : "API Key 已配置") : "未配置"} tone={channel.hasApiKey ? "success" : "neutral"} /> },
@@ -359,9 +355,6 @@ export default function ChannelsPage() {
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}>
<Input placeholder="例如:OpenAI 官方渠道" />
</Form.Item>
<Form.Item name="publicAlias" label="前台显示别名" extra="留空时显示渠道名称;填写后用户端只显示此别名,后台仍保留原渠道名称。" rules={[{ max: 80, message: "别名不能超过 80 个字符" }]}>
<Input maxLength={80} placeholder="可选,例如:精选图片" />
</Form.Item>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}>
<Input placeholder="填写云端渠道 Base URL" />
</Form.Item>

View File

@@ -103,7 +103,8 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
<span className="text-foreground/35">未返回</span>
),
],
["积分计费", billingText(log)],
["销售价格(积分)", billingText(log)],
["成本价格(积分)", log.creditCostMicrocredits !== undefined ? `${formatCredits(log.creditCostMicrocredits)} 积分` : log.creditCostConfigured ? "待核算" : "未配置"],
["上游成本", log.costAvailable ? <span className="font-mono tabular-nums">{log.currency || "USD"} {(log.estimatedCostMicros / 1_000_000).toFixed(6)}</span> : <span className="text-foreground/35">未配置成本</span>],
[
"错误信息",

View File

@@ -96,5 +96,9 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
}
const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice];
if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值");
if (tier.costConfigured) {
const costs = tier.billingMode !== "token" ? [tier.costUnitPrice] : capability === "video" ? [tier.costOutputTokenPrice] : [tier.costInputTokenPrice, tier.costOutputTokenPrice, tier.costCachedTokenPrice];
if (costs.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分成本价必须是 0 到 1000000 之间的有效数值");
}
});
}

View File

@@ -247,10 +247,10 @@ export function ChannelModelEditor({
<Form.Item name="providerModelKey" label="上游模型 ID" tooltip="实际发送给供应商;留空时使用产品模型标识。价格档可配置独立上游 ID,命中时优先于此处。">
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="模型展示名" tooltip="创作端一级菜单名称。同一产品模型标识请使用相同名称和 Logo,不改变调用 ID。">
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组,例如 MiniMax H3。同名模型归入同一组,不改变调用 ID。">
<Input placeholder="不填则使用模型标识" />
</Form.Item>
<Form.Item name="channelLabel" label="渠道展示名" tooltip="创作端二级菜单名称,仅作用于此渠道中的当前模型。留空使用渠道公开名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
<Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="此模型下的渠道选项,例如秘塔(满血渠道)。留空使用所属渠道名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">

View File

@@ -5,6 +5,7 @@ import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/mod
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";
import { CreditCostFields } from "./credit-cost-fields";
export function PriceTierFields({
index,
@@ -84,6 +85,7 @@ export function PriceTierFields({
</Form.Item>
</div>
<div className="admin-price-tier-block admin-price-tier-billing-block">
<div className="mb-2 text-sm font-medium">销售价格</div>
<div className="admin-price-tier-billing-grid">
<Form.Item className="admin-price-tier-billing-mode mb-0" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
<Segmented
@@ -125,6 +127,7 @@ export function PriceTierFields({
</p>
) : null}
</div>
<CreditCostFields index={index} form={form} billingMode={billingMode} isVideo={isVideo} />
{matchMode !== "default" && (
<div className="admin-price-tier-match-grid">
<Form.Item className="mb-0" name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>

View File

@@ -23,6 +23,11 @@ export type PriceTierFormValues = {
inputTokenPrice: number;
outputTokenPrice: number;
cachedTokenPrice: number;
costConfigured: boolean;
costUnitPrice: number;
costInputTokenPrice: number;
costOutputTokenPrice: number;
costCachedTokenPrice: number;
priceConfigured: boolean;
enabled: boolean;
};
@@ -43,6 +48,11 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
inputTokenPrice: 0,
outputTokenPrice: 0,
cachedTokenPrice: 0,
costConfigured: false,
costUnitPrice: 0,
costInputTokenPrice: 0,
costOutputTokenPrice: 0,
costCachedTokenPrice: 0,
priceConfigured: true,
enabled: true,
};
@@ -66,6 +76,11 @@ export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValue
inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000,
cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
costConfigured: tier.costPricing?.configured === true,
costUnitPrice: (tier.costPricing?.unitPriceMicrocredits ?? 0) / 1_000_000,
costInputTokenPrice: (tier.costPricing?.inputTokenPriceMicrocredits ?? 0) / 1_000_000,
costOutputTokenPrice: (tier.costPricing?.outputTokenPriceMicrocredits ?? 0) / 1_000_000,
costCachedTokenPrice: (tier.costPricing?.cachedTokenPriceMicrocredits ?? 0) / 1_000_000,
priceConfigured: tier.priceConfigured,
enabled: tier.enabled,
};
@@ -123,6 +138,13 @@ export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier
inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.inputTokenPrice || 0) * 1_000_000),
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
costPricing: {
configured: tier.costConfigured,
unitPriceMicrocredits: Math.round(tier.costUnitPrice * 1_000_000),
inputTokenPriceMicrocredits: videoTokens ? 0 : Math.round(tier.costInputTokenPrice * 1_000_000),
outputTokenPriceMicrocredits: Math.round(tier.costOutputTokenPrice * 1_000_000),
cachedTokenPriceMicrocredits: videoTokens ? 0 : Math.round(tier.costCachedTokenPrice * 1_000_000),
},
priceConfigured: tier.priceConfigured !== false,
enabled: tier.enabled !== false,
};

View File

@@ -0,0 +1,21 @@
import { Form, InputNumber, Switch, type FormInstance } from "antd";
import type { ChannelModelFormValues } from "./channel-model-editor-form";
export function CreditCostFields({ index, form, billingMode, isVideo }: { index: number; form: FormInstance<ChannelModelFormValues>; billingMode: string; isVideo: boolean }) {
const configured = Form.useWatch(["priceTiers", index, "costConfigured"], form) === true;
const fields = billingMode === "token"
? isVideo
? [["costOutputTokenPrice", "积分 / 百万视频 Token"]]
: [["costInputTokenPrice", "输入 / 百万 Token"], ["costOutputTokenPrice", "输出 / 百万 Token"], ["costCachedTokenPrice", "缓存 / 百万 Token"]]
: [["costUnitPrice", billingMode === "per_second" ? "积分 / 秒" : "积分 / 次"]];
return <div className="admin-price-tier-block">
<Form.Item name={[index, "costConfigured"]} label="积分成本价(仅管理员可见)" valuePropName="checked" extra="按当前价格档的计费方式核算成本,不影响用户售价;开启并填 0 表示零成本。">
<Switch aria-label="配置积分成本价" />
</Form.Item>
{configured ? <div className={billingMode === "token" && !isVideo ? "admin-price-tier-token-grid" : "admin-price-tier-billing-grid"}>
{fields.map(([field, label]) => <Form.Item key={field} className="mb-0" name={[index, field]} label={label} rules={[{ required: true, message: "请输入积分成本价" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>)}
</div> : null}
</div>;
}

View File

@@ -99,7 +99,7 @@ export default function LogsPage() {
),
},
{ title: "耗时", dataIndex: "durationMs", width: 112, render: (value) => <span className="tabular-nums">{formatDuration(value)}</span> },
{ title: "积分计费", width: 145, render: (_, log) => <BillingSummary log={log} /> },
{ title: "积分计算", width: 195, render: (_, log) => <BillingSummary log={log} /> },
{
title: "Tokens",
width: 166,
@@ -269,7 +269,8 @@ function BillingSummary({ log }: { log: ApiCallLog }) {
const statusLabel = ({ settled: "已结算", refunded: "已退回", uncertain: "待核对", running: "运行中", reserved: "已预授权" } as const)[status];
return (
<div>
<div className="tabular-nums">{formatCredits(log.billingAmountMicrocredits)} 积分</div>
<div className="tabular-nums">销售价格:{formatCredits(log.billingAmountMicrocredits)} 积分</div>
<div className="tabular-nums text-xs text-foreground/60">成本价格:{log.creditCostMicrocredits !== undefined ? `${formatCredits(log.creditCostMicrocredits)} 积分` : log.creditCostConfigured ? "待核算" : "未配置"}</div>
<div className="text-xs text-foreground/40">{statusLabel}</div>
</div>
);

View File

@@ -87,6 +87,8 @@ export type ApiCallLog = {
videoSeconds: number;
providerRequestId?: string;
estimatedCostMicros: number;
creditCostConfigured?: boolean;
creditCostMicrocredits?: number;
costAvailable: boolean;
currency?: string;
errorCode?: string;

View File

@@ -153,7 +153,7 @@ export type ModelQuoteRequest = {
intent: ModelRequestIntent;
};
export type ModelCatalogSource = "frontend" | "system";
export type ModelCatalogSource = "system";
export type PublicChannelCatalog = {
id: string;
@@ -199,7 +199,7 @@ export type ModelCatalogResponse = {
channels?: PublicChannelCatalog[];
};
// 统一模型目录接口 - 根据 frontendModelsEnabled 开关返回前台模型或系统渠道模型
// 创作目录直接读取系统渠道模型,不使用逻辑模型及其功能开关。
export function getModelCatalog() {
return http.get<ModelCatalogResponse>("/model-catalog");
}

View File

@@ -74,6 +74,8 @@ export type ChannelModel = {
};
export type ChannelModelPriceTier = {
/** 仅管理员模型编辑接口返回,不能复制到用户模型目录。 */
costPricing?: CreditCostPricing;
id: string;
channelModelId: string;
selector: Record<string, string>;
@@ -93,6 +95,14 @@ export type ChannelModelPriceTier = {
updatedAt: string;
};
export type CreditCostPricing = {
configured: boolean;
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
};
// 系统渠道模型的写入合同。标量价格只用于兼容旧管理请求;新的后台界面只提交 priceTiers。
export type ChannelModelMutation = {
modelKey: string;

View File

@@ -344,7 +344,6 @@ export const PUBLIC_MODEL_CATALOG_ID = "managed";
export type ModelChannel = {
id: string;
name: string;
publicAlias?: string;
sortOrder?: number;
baseUrl: string;
apiKey: string;