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

@@ -694,6 +694,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User,
if accept := c.GetHeader("Accept"); accept != "" {
upstreamReq.Header.Set("Accept", accept)
}
service.ApplyDefaultOutboundHeaders(upstreamReq)
if protocol == model.ChannelInterfaceGeminiVeo {
upstreamReq.Header.Set("x-goog-api-key", channel.APIKey)
} else {

View File

@@ -97,7 +97,7 @@ func proxyCustomRelayRequest(c *gin.Context, policy service.RuntimeRequestPolicy
} else {
upstreamReq.Header.Set("Accept", "application/json")
}
upstreamReq.Header.Set("User-Agent", "InfiniteCanvas/custom-channel-relay")
service.ApplyDefaultOutboundHeaders(upstreamReq)
if apiFormat == "gemini" {
upstreamReq.Header.Set("x-goog-api-key", apiKey)
} else {

View File

@@ -22,6 +22,9 @@ func TestCustomRelayForwardsOpenAIRequestWithoutBrowserHeaders(t *testing.T) {
if r.Header.Get("Authorization") != "Bearer "+apiKey {
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
}
if r.Header.Get("User-Agent") != service.DefaultOutboundUserAgent {
t.Errorf("User-Agent = %q", r.Header.Get("User-Agent"))
}
for _, name := range []string{"Cookie", "Origin", "Referer", "X-Canvas-Upstream-URL", "X-Forwarded-For"} {
if value := r.Header.Get(name); value != "" {
t.Errorf("upstream received %s = %q", name, value)

View File

@@ -222,6 +222,28 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, result)
})
r.POST("/admin/channels/:id/models/test", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
if !enforceRateLimit(c, "admin-channel-model-test:"+user.ID+":"+c.Param("id"), 5, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var req service.ChannelModelRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
}
result, err := svc.TestAdminChannelModel(c.Request.Context(), user, c.Param("id"), req)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.POST("/admin/channels/:id/models", func(c *gin.Context) {
saveChannelModel(c, svc, "")
})

View File

@@ -80,7 +80,7 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont
return errors.New("自定义渠道不允许使用该请求方法")
}
if apiFormat == "openai" {
if len(query) != 0 || (!strings.HasSuffix(requestPath, "/responses") && !strings.HasSuffix(requestPath, "/chat/completions") && !strings.HasSuffix(requestPath, "/images/generations") && !strings.HasSuffix(requestPath, "/video/generations") && !strings.HasSuffix(requestPath, "/videos/generations") && !strings.HasSuffix(requestPath, "/contents/generations/tasks")) {
if len(query) != 0 || (!strings.HasSuffix(requestPath, "/responses") && !strings.HasSuffix(requestPath, "/chat/completions") && !strings.HasSuffix(requestPath, "/images/generations") && !strings.HasSuffix(requestPath, "/audio/speech") && !strings.HasSuffix(requestPath, "/video/generations") && !strings.HasSuffix(requestPath, "/videos/generations") && !strings.HasSuffix(requestPath, "/contents/generations/tasks")) {
return errors.New("自定义渠道不允许访问该上游接口")
}
return nil

View File

@@ -28,6 +28,7 @@ func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) {
{method: http.MethodGet, target: "https://api.example.com/v1/models", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.example.com/v1/responses", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/chat/completions", apiFormat: "openai", contentType: "application/json; charset=utf-8"},
{method: http.MethodPost, target: "https://api.example.com/v1/audio/speech", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodPost, target: "https://api.example.com/v1/video/generations", apiFormat: "openai", contentType: "application/json"},
{method: http.MethodGet, target: "https://api.example.com/v1/video/generations/task-1", apiFormat: "openai"},
{method: http.MethodPost, target: "https://api.x.ai/v1/videos/generations", apiFormat: "openai", contentType: "application/json"},

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
@@ -29,6 +30,10 @@ type AdminChannelModelFetchResult struct {
Added int64 `json:"added"`
}
type AdminChannelModelTestResult struct {
DurationMs int64 `json:"durationMs"`
}
func (s *Service) EnsureSystemChannelModels() error {
channels, err := s.repo.SystemChannels(true)
if err != nil {
@@ -107,23 +112,9 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
if err != nil {
return nil, err
}
modelKey := strings.TrimPrefix(strings.TrimSpace(req.ModelKey), "models/")
if modelKey == "" {
return nil, BadAuthRequest("请填写模型标识")
}
capability := normalizeCapability(req.Capability)
if capability == "" {
return nil, BadAuthRequest("请选择模型能力")
}
protocol := model.ChannelInterfaceType(strings.TrimSpace(req.Protocol))
if !validChannelInterfaceType(protocol) {
return nil, BadAuthRequest("请选择有效的模型请求协议")
}
if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") {
return nil, BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
}
if expected := capabilityForProtocol(protocol); expected != "" && expected != capability {
return nil, BadAuthRequest("模型能力与请求协议不匹配")
modelKey, capability, protocol, err := normalizeChannelModelContract(channel, req)
if err != nil {
return nil, err
}
billingMode := strings.TrimSpace(req.BillingMode)
if billingMode == "" {
@@ -168,6 +159,109 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
return item, nil
}
func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
channel, err := s.repo.AdminSystemChannel(channelID)
if err != nil {
return nil, err
}
modelKey, capability, protocol, err := normalizeChannelModelContract(channel, req)
if err != nil {
return nil, err
}
if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" {
return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key")
}
prompt := map[string]string{
"text": "Reply with OK.",
"image": "A simple gray circle on a white background.",
"video": "A static gray circle on a white background.",
"audio": "Model test.",
}[capability]
videoSeconds := "6"
videoSecondsValue := 6
if protocol == model.ChannelInterfaceVolcengineJiMengVideo {
videoSeconds = "5"
videoSecondsValue = 5
}
input := canvasGenerationInput{
Mode: capability,
Prompt: prompt,
Config: providerConfig{
ChannelID: channel.ID,
APIFormat: channel.APIFormat,
InterfaceType: string(protocol),
BaseURL: channel.BaseURL,
APIKey: channel.APIKey,
SecretKey: channel.SecretKey,
Model: modelKey,
Size: map[string]string{"image": "1024x1024", "video": "16:9"}[capability],
Quality: "auto",
Count: "1",
VideoSeconds: videoSeconds,
VQuality: "720",
VideoGenerateAudio: "false",
VideoWatermark: "false",
AudioVoice: "alloy",
AudioFormat: "mp3",
AudioSpeed: "1",
},
Metadata: map[string]interface{}{},
}
// 测试复用真实生成协议、运行时并发和熔断策略,但不创建用户任务或计费订单。
testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{
Service: s, UserID: actor.ID, ChannelID: channel.ID, Capability: capability,
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue,
})
startedAt := time.Now()
switch capability {
case "text":
_, err = runTextTask(testCtx, input)
case "image":
_, err = runImageTask(testCtx, input)
case "video":
_, err = runVideoTask(testCtx, input)
case "audio":
_, err = runAudioTask(testCtx, input)
}
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, context.DeadlineExceeded) {
status = http.StatusGatewayTimeout
}
return nil, &AuthError{Status: status, Message: "模型测试失败:" + truncateRunes(err.Error(), 1000)}
}
return &AdminChannelModelTestResult{DurationMs: time.Since(startedAt).Milliseconds()}, nil
}
func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, model.ChannelInterfaceType, error) {
modelKey := strings.TrimPrefix(strings.TrimSpace(req.ModelKey), "models/")
if modelKey == "" {
return "", "", "", BadAuthRequest("请填写模型标识")
}
capability := normalizeCapability(req.Capability)
if capability == "" {
return "", "", "", BadAuthRequest("请选择模型能力")
}
protocol := model.ChannelInterfaceType(strings.TrimSpace(req.Protocol))
if !validChannelInterfaceType(protocol) {
return "", "", "", BadAuthRequest("请选择有效的模型请求协议")
}
if expected := capabilityForProtocol(protocol); expected != "" && expected != capability {
return "", "", "", BadAuthRequest("模型能力与请求协议不匹配")
}
if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") {
return "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key")
}
return modelKey, capability, protocol, nil
}
func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error {
if err := s.RequireAdmin(actor); err != nil {
return err

View File

@@ -0,0 +1,40 @@
package service
import (
"testing"
"infinite-canvas/backend/internal/model"
)
func TestNormalizeChannelModelContract(t *testing.T) {
channel := &model.ModelChannel{APIKey: "test-key"}
modelKey, capability, protocol, err := normalizeChannelModelContract(channel, ChannelModelRequest{
ModelKey: "models/gpt-test", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
})
if err != nil {
t.Fatalf("normalizeChannelModelContract() error = %v", err)
}
if modelKey != "gpt-test" || capability != "text" || protocol != model.ChannelInterfaceChatCompletion {
t.Fatalf("contract = %q, %q, %q", modelKey, capability, protocol)
}
}
func TestNormalizeChannelModelContractRejectsCapabilityMismatch(t *testing.T) {
channel := &model.ModelChannel{APIKey: "test-key"}
_, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{
ModelKey: "image-test", Capability: "text", Protocol: string(model.ChannelInterfaceOpenAIImage),
})
if err == nil {
t.Fatal("normalizeChannelModelContract() should reject a mismatched capability")
}
}
func TestNormalizeChannelModelContractRequiresJiMengSecret(t *testing.T) {
channel := &model.ModelChannel{APIKey: "access-key"}
_, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{
ModelKey: "jimeng-test", Capability: "image", Protocol: string(model.ChannelInterfaceVolcengineJiMengImage),
})
if err == nil {
t.Fatal("normalizeChannelModelContract() should require JiMeng credentials")
}
}

View File

@@ -12,7 +12,10 @@ import (
"time"
)
const maxOutboundRedirects = 5
const (
maxOutboundRedirects = 5
DefaultOutboundUserAgent = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)"
)
var (
outboundTransport = newOutboundTransport(resolveOutboundHost)
@@ -97,6 +100,14 @@ func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
}
}
// ApplyDefaultOutboundHeaders 为所有服务端出网请求提供可识别的产品标识,
// 避免 Go 默认 User-Agent 被部分 WAF 或中转网关拦截。
func ApplyDefaultOutboundHeaders(req *http.Request) {
if req != nil && strings.TrimSpace(req.Header.Get("User-Agent")) == "" {
req.Header.Set("User-Agent", DefaultOutboundUserAgent)
}
}
func newOutboundTransport(resolveHost func(context.Context, string) ([]net.IP, error)) *http.Transport {
dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
return &http.Transport{

View File

@@ -8,6 +8,23 @@ import (
"time"
)
func TestApplyDefaultOutboundHeaders(t *testing.T) {
request, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatal(err)
}
ApplyDefaultOutboundHeaders(request)
if got := request.Header.Get("User-Agent"); got != DefaultOutboundUserAgent {
t.Fatalf("User-Agent = %q, want %q", got, DefaultOutboundUserAgent)
}
request.Header.Set("User-Agent", "custom-agent")
ApplyDefaultOutboundHeaders(request)
if got := request.Header.Get("User-Agent"); got != "custom-agent" {
t.Fatalf("custom User-Agent = %q", got)
}
}
func TestValidateOutboundURLRejectsPrivateHosts(t *testing.T) {
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "false")
t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "")

View File

@@ -1593,6 +1593,7 @@ func doBinary(req *http.Request) ([]byte, string, error) {
recordProviderRequest(req, startedAt, 0, nil, err)
return nil, "", err
}
ApplyDefaultOutboundHeaders(req)
client := OutboundHTTPClient(requestTimeout)
resp, err := client.Do(req)
if err != nil {

View File

@@ -197,6 +197,7 @@ func postJiMengJSON(ctx context.Context, config providerConfig, action string, b
return err
}
req.Header.Set("Content-Type", "application/json")
ApplyDefaultOutboundHeaders(req)
credentials := base.Credentials{
AccessKeyID: strings.TrimSpace(config.APIKey),
SecretAccessKey: strings.TrimSpace(config.SecretKey),

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));
}