@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "")
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
40
backend/internal/service/channel_models_test.go
Normal file
40
backend/internal/service/channel_models_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
67
web/src/lib/model-connection-test.ts
Normal file
67
web/src/lib/model-connection-test.ts
Normal 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})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 : "读取失败" };
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user