feat(api): 模型接入 - 统一出网 User-Agent 并支持添加时测试模型

Fixes #39
This commit is contained in:
ddCat
2026-07-29 18:31:01 +08:00
parent 09f5ff03cc
commit 55875207c9
19 changed files with 348 additions and 36 deletions

View File

@@ -0,0 +1,67 @@
import { requestAudioGeneration } from "@/services/api/audio";
import { requestGeneration, requestImageQuestion } from "@/services/api/image";
import { createVideoGenerationTask } from "@/services/api/video";
import { defaultConfig, encodeChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import type { ModelProtocol } from "@/lib/model-protocols";
export async function testChannelModelConnection(channel: ModelChannel, model: string, capability: ModelCapability, protocol: ModelProtocol) {
if (!channel.baseUrl.trim()) throw new Error("请先填写 Base URL");
if (!channel.apiKey.trim()) throw new Error("请先填写 API Key");
const selectedModel = encodeChannelModel(channel.id, model);
const modelCost = channel.modelCosts?.find((item) => item.model === model);
const testProtocol = channel.apiFormat === "gemini" && !modelCost?.protocol ? undefined : protocol;
const testChannel: ModelChannel = {
...channel,
models: channel.models.includes(model) ? channel.models : [...channel.models, model],
modelCosts: [
{
model,
displayName: modelCost?.displayName,
capability,
protocol: testProtocol,
billingMode: modelCost?.billingMode || "fixed_request",
unitPriceMicrocredits: modelCost?.unitPriceMicrocredits || 0,
},
...(channel.modelCosts || []).filter((item) => item.model !== model),
],
};
const config = {
...defaultConfig,
channelMode: "local" as const,
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
apiFormat: channel.apiFormat,
channels: [testChannel],
model: selectedModel,
imageModel: selectedModel,
videoModel: selectedModel,
textModel: selectedModel,
audioModel: selectedModel,
models: [selectedModel],
imageModels: capability === "image" ? [selectedModel] : [],
videoModels: capability === "video" ? [selectedModel] : [],
textModels: capability === "text" ? [selectedModel] : [],
audioModels: capability === "audio" ? [selectedModel] : [],
count: "1",
size: capability === "image" ? "1024x1024" : "16:9",
videoSeconds: "6",
vquality: "720",
videoGenerateAudio: "false",
};
switch (capability) {
case "text":
await requestImageQuestion(config, [{ role: "user", content: "Reply with OK." }], () => undefined);
return "文本响应正常";
case "image":
await requestGeneration(config, "A simple gray circle on a white background.");
return "图片生成正常";
case "audio":
await requestAudioGeneration(config, "Model test.");
return "音频生成正常";
case "video": {
const task = await createVideoGenerationTask(config, "A static gray circle on a white background.");
return `视频任务已创建(${task.id})`;
}
}
}

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
import { SYSTEM_MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSummary, type ModelProtocol } from "@/lib/model-protocols";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
import type { ModelChannel } from "@/stores/use-config-store";
import { AdminPageFrame } from "./admin-shell";
@@ -26,6 +26,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const [capability, setCapability] = useState<ChannelModel["capability"] | "all">("all");
@@ -116,6 +117,23 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
}
};
const testModel = async () => {
const values = await form.validateFields(["modelKey", "capability", "protocol"]);
setTesting(true);
try {
const result = await testAdminChannelModel(channel.id, {
modelKey: values.modelKey.trim(),
capability: values.capability,
protocol: values.protocol,
});
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
} catch (error) {
message.error(error instanceof Error ? error.message : "模型测试失败");
} finally {
setTesting(false);
}
};
const remove = async (item: ChannelModel) => {
try {
await deleteAdminChannelModel(channel.id, item.id);
@@ -216,7 +234,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" block loading={saving} onClick={() => void save()}>{editing ? "保存修改" : "添加模型"}</Button>
<div className="mb-2 text-xs text-foreground/45">
测试会向上游发起真实请求并可能产生供应商费用{modelCapability === "video" ? ",视频测试可能需要数分钟" : ""}。
</div>
<div className="grid grid-cols-2 gap-2">
<Button icon={<FlaskConical className="size-4" />} loading={testing} disabled={saving} onClick={() => void testModel()}>测试模型</Button>
<Button type="primary" loading={saving} disabled={testing} onClick={() => void save()}>{editing ? "保存修改" : "添加模型"}</Button>
</div>
</Form>
</Drawer>
</AdminPageFrame>

View File

@@ -1,11 +1,16 @@
import { InputNumber, Segmented, Select } from "antd";
import { useState } from "react";
import { App, Button, InputNumber, Segmented, Select } from "antd";
import { FlaskConical } from "lucide-react";
import { testChannelModelConnection } from "@/lib/model-connection-test";
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 ChannelModelSettings({ channel, onChange }: { channel: ModelChannel; onChange: (costs: ModelCost[]) => void }) {
const { message } = App.useApp();
const [testingModel, setTestingModel] = useState("");
if (!channel.models.length) return null;
const updateCost = (model: string, patch: Partial<ModelCost>) => {
@@ -20,10 +25,22 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
onChange(next.filter((item) => channel.models.includes(item.model)));
};
const testModel = async (model: string, capability: ModelCost["capability"], protocol: ModelProtocol) => {
setTestingModel(model);
try {
const detail = await testChannelModelConnection(channel, model, capability, protocol);
message.success(`模型测试通过:${detail}`);
} catch (error) {
message.error(error instanceof Error ? error.message : "模型测试失败");
} finally {
setTestingModel("");
}
};
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">
@@ -34,7 +51,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
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.5 lg:grid-cols-[minmax(140px,1fr)_minmax(280px,1.6fr)_176px_160px] lg:items-center">
<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_96px] 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"
@@ -44,6 +61,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
/>
{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 />}
<Button size="small" icon={<FlaskConical className="size-3.5" />} loading={testingModel === model} disabled={Boolean(testingModel && testingModel !== model)} onClick={() => void testModel(model, capability, protocol)}>测试</Button>
</div>
);
})}

View File

@@ -153,7 +153,7 @@ export default function SettingsPage() {
}
setChannelLoading(channel.id, true);
try {
const models = await fetchChannelModels(channel);
const models = await fetchChannelModels(channel, true);
if (!models.length) {
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
return;
@@ -190,7 +190,7 @@ export default function SettingsPage() {
const results = await Promise.all(
runnable.map(async (channel) => {
try {
const models = await fetchChannelModels(channel);
const models = await fetchChannelModels(channel, true);
return { channel, models, error: "" };
} catch (error) {
return { channel, models: [] as string[], error: error instanceof Error ? error.message : "读取失败" };

View File

@@ -1,6 +1,7 @@
import axios from "axios";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, isSystemProxyBaseUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
@@ -26,8 +27,9 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
const instructions = config.audioInstructions.trim();
try {
const request = channelRequest(requestConfig, aiApiUrl(requestConfig, "/audio/speech"), aiHeaders(requestConfig));
const response = await axios.post<Blob>(
aiApiUrl(requestConfig, "/audio/speech"),
request.url,
{
model,
input: prompt,
@@ -36,7 +38,7 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
...(instructions ? { instructions } : {}),
},
{ headers: aiHeaders(requestConfig), responseType: "blob", signal: options?.signal },
{ headers: request.headers, withCredentials: request.credentials === "include", responseType: "blob", signal: options?.signal },
);
await assertAudioBlob(response.data);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });

View File

@@ -817,12 +817,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
};
const responseData = isVolcengineArk
? await postVolcengineArkImage(requestConfig, payload, options)
: (
await axios.post<ImageApiResponse>(aiApiUrl(requestConfig, "/images/generations"), payload, {
headers: aiHeaders(requestConfig, "application/json"),
signal: options?.signal,
})
).data;
: await postChannelJSON<ImageApiResponse>(requestConfig, aiApiUrl(requestConfig, "/images/generations"), payload, options);
const images = parseImagePayload(responseData);
return images;
} catch (error) {
@@ -830,6 +825,17 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
}
}
async function postChannelJSON<T>(config: ReturnType<typeof resolveModelRequestConfig>, upstreamUrl: string, body: unknown, options?: RequestOptions) {
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
return (
await axios.post<T>(request.url, body, {
headers: request.headers,
withCredentials: request.credentials === "include",
signal: options?.signal,
})
).data;
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage, options?: RequestOptions) {
const selectedModel = config.model || config.imageModel;
const requestConfig = resolveModelRequestConfig(config, selectedModel);

View File

@@ -228,6 +228,10 @@ export function fetchAdminChannelModels(channelId: string) {
return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`));
}
export function testAdminChannelModel(channelId: string, input: Pick<ChannelModel, "modelKey" | "capability" | "protocol">) {
return request<{ durationMs: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/test`, input, { timeout: 10 * 60 * 1000 }));
}
export function createAdminChannelModel(channelId: string, input: Omit<ChannelModel, "id" | "channelId" | "priceVersion" | "createdAt" | "updatedAt">) {
return request<{ model: ChannelModel }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models`, input));
}