fix(*): 模型请求协议 - 支持模型独立选协议并修正视频多参考请求

This commit is contained in:
ddCat
2026-07-28 23:05:15 +08:00
parent 7093b4e161
commit e3c501620d
20 changed files with 691 additions and 190 deletions

View File

@@ -1,56 +1,49 @@
import { InputNumber, Segmented } from "antd";
import { InputNumber, Segmented, Select } from "antd";
import { modelOptionName, type ModelChannel } from "@/stores/use-config-store";
import { MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
import { modelMatchesCapability, modelOptionName, type ModelChannel } from "@/stores/use-config-store";
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
export function ChannelVideoPricing({ channel, onChange }: { channel: ModelChannel; onChange: (costs: ModelCost[]) => void }) {
if (!isVideoChannel(channel) || !channel.models.length) return null;
export function ChannelModelSettings({ channel, onChange }: { channel: ModelChannel; onChange: (costs: ModelCost[]) => void }) {
if (!channel.models.length) return null;
const updateCost = (model: string, patch: Partial<ModelCost>) => {
const current = channel.modelCosts?.find((item) => item.model === model) || {
model,
capability: "video" as const,
capability: modelProtocolCapability(defaultProtocolForModel(channel, model)) || "text",
protocol: defaultProtocolForModel(channel, model),
billingMode: "fixed_request" as const,
unitPriceMicrocredits: 0,
};
const next = [...(channel.modelCosts || []).filter((item) => item.model !== model), { ...current, ...patch, model, capability: "video" as const }];
const next = [...(channel.modelCosts || []).filter((item) => item.model !== model), { ...current, ...patch, model }];
onChange(next.filter((item) => channel.models.includes(item.model)));
};
return (
<div className="mt-3 border-t border-border/70 pt-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div><div className="text-xs font-medium">视频模型价格</div><div className="mt-0.5 text-[10px] text-foreground/42">仅用于记录自定义渠道成本,不扣平台积分</div></div>
<div><div className="text-xs font-medium">模型请求协议</div><div className="mt-0.5 text-[10px] text-foreground/42">每个模型独立决定请求路径和报文;视频价格仅用于成本记录</div></div>
<span className="text-[10px] text-foreground/35">{channel.models.length} 个模型</span>
</div>
<div className="divide-y divide-border/60 rounded-md border border-border/70">
{channel.models.map((rawModel) => {
const model = modelOptionName(rawModel);
const cost = channel.modelCosts?.find((item) => item.model === model);
const protocol = cost?.protocol || defaultProtocolForModel(channel, model);
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
const billingMode = cost?.billingMode || "fixed_request";
return (
<div key={model} className="grid gap-2 px-2.5 py-2 sm:grid-cols-[minmax(0,1fr)_176px_160px] sm:items-center">
<span className="truncate text-xs font-medium" title={model}>{model}</span>
<Segmented
<div key={model} className="grid gap-2 px-2.5 py-2.5 lg:grid-cols-[minmax(140px,1fr)_minmax(280px,1.6fr)_176px_160px] lg:items-center">
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[10px] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
<Select<ModelProtocol>
size="small"
block
value={billingMode}
options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]}
onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })}
/>
<InputNumber
size="small"
min={0}
max={1_000_000}
precision={6}
step={0.1}
className="w-full"
placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"}
addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`}
value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null}
onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })}
value={protocol}
options={MODEL_PROTOCOL_OPTIONS}
onChange={(value) => updateCost(model, { protocol: value, capability: modelProtocolCapability(value) || capability, billingMode: modelProtocolCapability(value) === "video" ? billingMode : "fixed_request" })}
/>
{capability === "video" ? <Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /> : <span className="text-center text-[10px] text-foreground/35">{modelProtocolLabel(protocol)}</span>}
{capability === "video" ? <InputNumber size="small" min={0} max={1_000_000} precision={6} step={0.1} className="w-full" placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"} addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`} value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null} onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /> : <span />}
</div>
);
})}
@@ -59,6 +52,10 @@ export function ChannelVideoPricing({ channel, onChange }: { channel: ModelChann
);
}
function isVideoChannel(channel: ModelChannel) {
return channel.interfaceType === "newapi" || channel.interfaceType === "newapi-channel-1" || channel.interfaceType === "newapi-channel-2" || channel.interfaceType === "xai-video";
function defaultProtocolForModel(channel: ModelChannel, model: string): ModelProtocol {
if (channel.interfaceType) return channel.interfaceType;
if (channel.apiFormat === "gemini" && modelMatchesCapability(model, "video")) return "gemini-veo";
if (modelMatchesCapability(model, "video")) return "newapi";
if (modelMatchesCapability(model, "image")) return "openai-image";
return "chat-completion";
}

View File

@@ -7,6 +7,7 @@ import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form"
import { WorkspaceState } from "@/components/layout/workspace-state";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { refreshSystemChannels } from "@/lib/user-session";
import { MODEL_PROTOCOL_OPTIONS, modelProtocolLabel } from "@/lib/model-protocols";
import { fetchChannelModels } from "@/services/api/image";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import {
@@ -22,7 +23,7 @@ import {
type ModelChannel,
} from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { ChannelVideoPricing } from "./channel-video-pricing";
import { ChannelModelSettings } from "./channel-video-pricing";
import { ModelDefaultGrid } from "./model-default-grid";
type ConfigSectionKey = "channels" | "models" | "preferences" | "storage";
@@ -38,24 +39,8 @@ type UserChannelProtocol = ChannelInterfaceType | "auto" | "gemini";
const channelProtocolOptions = [
{ label: "OpenAI 自动兼容", value: "auto" },
{ label: "Gemini 原生", value: "gemini" },
{
label: "文本",
options: [
{ label: "Chat Completions", value: "chat-completion" },
{ label: "OpenAI Responses", value: "openai-response" },
],
},
{ label: "图片", options: [{ label: "OpenAI Images", value: "openai-image" }] },
{
label: "视频",
options: [
{ label: "NewAPI 视频", value: "newapi" },
{ label: "NewAPI 渠道 1", value: "newapi-channel-1" },
{ label: "NewAPI 渠道 2", value: "newapi-channel-2" },
{ label: "xAI / Sub2API 视频", value: "xai-video" },
],
},
{ label: "Google Gemini(模型级选择)", value: "gemini" },
...MODEL_PROTOCOL_OPTIONS,
];
function isConfigSection(value: string | null): value is ConfigSectionKey {
@@ -134,12 +119,11 @@ export default function SettingsPage() {
};
const updateChannelProtocol = (channel: ModelChannel, protocol: UserChannelProtocol) => {
const apiFormat = protocol === "gemini" ? "gemini" : "openai";
const apiFormat = protocol === "gemini" || protocol === "gemini-veo" ? "gemini" : "openai";
const interfaceType = protocol === "auto" || protocol === "gemini" ? undefined : protocol;
const defaultBaseUrl = protocol === "gemini" ? defaultBaseUrlForApiFormat("gemini") : defaultBaseUrlForChannelInterface(interfaceType);
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
const videoProtocol = interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2" || interfaceType === "xai-video";
updateChannel(channel.id, { apiFormat, interfaceType, baseUrl, modelCosts: videoProtocol ? channel.modelCosts : [] });
updateChannel(channel.id, { apiFormat, interfaceType, baseUrl });
};
const addChannel = () => {
@@ -296,7 +280,7 @@ export default function SettingsPage() {
<div className="min-w-0 flex-1">
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 text-xs text-foreground/65">
<Info className="size-3.5 shrink-0" />
<span>模型会按渠道协议和后台能力自动归类,拉取完成后可直接选择默认模型。</span>
<span>渠道保存连接和默认协议;拉取模型后,请为需要特殊路径的模型单独选择请求协议。</span>
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>
打开模型选择
</Button>
@@ -366,7 +350,7 @@ export default function SettingsPage() {
onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })}
/>
</Form.Item>
<Form.Item label="接口协议" htmlFor={`channel-${channel.id}-protocol`} className="mb-0 lg:col-span-3">
<Form.Item label="默认模型协议" htmlFor={`channel-${channel.id}-protocol`} className="mb-0 lg:col-span-3" extra="用于新模型预填,单个模型可在下方覆盖。">
<Select<UserChannelProtocol>
id={`channel-${channel.id}-protocol`}
value={channelProtocolValue(channel)}
@@ -408,7 +392,7 @@ export default function SettingsPage() {
/>
</Form.Item>
</div>
<ChannelVideoPricing channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
</section>
))}
</div>
@@ -620,16 +604,8 @@ function channelProtocolLabel(channel: ModelChannel) {
return "OpenAI Responses";
case "openai-image":
return "OpenAI Images";
case "newapi":
return "NewAPI 视频";
case "newapi-channel-1":
return "NewAPI 渠道 1";
case "newapi-channel-2":
return "NewAPI 渠道 2";
case "xai-video":
return "xAI / Sub2API 视频";
default:
return "OpenAI 自动兼容";
return protocol === "auto" ? "OpenAI 自动兼容" : modelProtocolLabel(protocol);
}
}