diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index ae0159a00..406f0c721 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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 { diff --git a/backend/internal/handler/custom_proxy.go b/backend/internal/handler/custom_proxy.go index 6055d6e47..5523caeae 100644 --- a/backend/internal/handler/custom_proxy.go +++ b/backend/internal/handler/custom_proxy.go @@ -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 { diff --git a/backend/internal/handler/custom_proxy_test.go b/backend/internal/handler/custom_proxy_test.go index d856f7dc6..1573dcfcc 100644 --- a/backend/internal/handler/custom_proxy_test.go +++ b/backend/internal/handler/custom_proxy_test.go @@ -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) diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index 672a3b23d..4d185846b 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -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, "") }) diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index 741404d60..ecb0d0c23 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -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 diff --git a/backend/internal/handler/security_test.go b/backend/internal/handler/security_test.go index 7eb6c40bc..3e97ad347 100644 --- a/backend/internal/handler/security_test.go +++ b/backend/internal/handler/security_test.go @@ -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"}, diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index aa292023b..671c3ec76 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -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 diff --git a/backend/internal/service/channel_models_test.go b/backend/internal/service/channel_models_test.go new file mode 100644 index 000000000..6693d3231 --- /dev/null +++ b/backend/internal/service/channel_models_test.go @@ -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") + } +} diff --git a/backend/internal/service/outbound.go b/backend/internal/service/outbound.go index d2a39ba75..b6f5846ac 100644 --- a/backend/internal/service/outbound.go +++ b/backend/internal/service/outbound.go @@ -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{ diff --git a/backend/internal/service/outbound_test.go b/backend/internal/service/outbound_test.go index a8a8caebf..8c3d5de0f 100644 --- a/backend/internal/service/outbound_test.go +++ b/backend/internal/service/outbound_test.go @@ -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", "") diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 363c386df..8a86ccf6f 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -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 { diff --git a/backend/internal/service/provider_jimeng.go b/backend/internal/service/provider_jimeng.go index 9fdfe10a4..613d1da04 100644 --- a/backend/internal/service/provider_jimeng.go +++ b/backend/internal/service/provider_jimeng.go @@ -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), diff --git a/web/src/lib/model-connection-test.ts b/web/src/lib/model-connection-test.ts new file mode 100644 index 000000000..9910c34ad --- /dev/null +++ b/web/src/lib/model-connection-test.ts @@ -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})`; + } + } +} diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index d709b3954..744ae68c1 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -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("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: - +
+ 测试会向上游发起真实请求并可能产生供应商费用{modelCapability === "video" ? ",视频测试可能需要数分钟" : ""}。 +
+
+ + +
diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index eb70f9ee7..19362ef24 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -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[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) => { @@ -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 (
-
模型请求协议
每个模型独立决定请求路径和报文;视频价格仅用于成本记录
+
模型请求协议
测试会发起真实请求并可能产生供应商费用;视频价格仅用于成本记录
{channel.models.length} 个模型
@@ -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 ( -
+
{model}
{modelProtocolDefinition(protocol)?.create}
size="small" @@ -44,6 +61,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan /> {capability === "video" ? updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /> : {modelProtocolLabel(protocol)}} {capability === "video" ? updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /> : } +
); })} diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index b827acd96..09be5005f 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -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 : "读取失败" }; diff --git a/web/src/services/api/audio.ts b/web/src/services/api/audio.ts index 84a92bfa6..a51e19f89 100644 --- a/web/src/services/api/audio.ts +++ b/web/src/services/api/audio.ts @@ -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( - 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) }); diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index bb263e809..e7af206d7 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -817,12 +817,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option }; const responseData = isVolcengineArk ? await postVolcengineArkImage(requestConfig, payload, options) - : ( - await axios.post(aiApiUrl(requestConfig, "/images/generations"), payload, { - headers: aiHeaders(requestConfig, "application/json"), - signal: options?.signal, - }) - ).data; + : await postChannelJSON(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(config: ReturnType, upstreamUrl: string, body: unknown, options?: RequestOptions) { + const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json")); + return ( + await axios.post(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); diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index ffe0ed384..ff0ba6183 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -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) { + return request<{ durationMs: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/test`, input, { timeout: 10 * 60 * 1000 })); +} + export function createAdminChannelModel(channelId: string, input: Omit) { return request<{ model: ChannelModel }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models`, input)); }