fix(*): 模型请求协议 - 支持模型独立选协议并修正视频多参考请求
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user