Files
ttstd-infinite-canvas/backend/internal/app/channel_models_test_call.go
dxiaom 9df34abd6b feat(admin): 渠道管理 - 模型供应商支持渠道级 SOCKS5/HTTP 代理
- ModelChannel 新增 proxy_url 字段,v48 迁移自动加列
- outbound 新增 OutboundHTTPClientWithProxy,socks5/socks5h/http/https 均支持;
  目标主机名不做本地解析,直接交给代理服务器远端解析,本地开启 TUN/VPN 时仍可用
- 系统代理、Provider 出站、管理员模型测试路径均使用渠道级代理
- 渠道管理表单新增"代理地址"输入框,留空回退全局代理
2026-10-10 00:59:37 +08:00

176 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 管理端「测试模型」:按能力构造最小测试请求并真实调用一次上游。
package app
import (
"context"
"errors"
"net/http"
"strings"
"time"
"yingce/backend/internal/model"
)
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.adminSystemChannel(channelID)
if err != nil {
return nil, err
}
modelKey, providerModelKey, capability, protocol, err := s.normalizeChannelModelContract(channel, req)
if err != nil {
return nil, err
}
if capability == "text" || capability == "image" || capability == "video" {
if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil {
return nil, err
}
}
if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" {
return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key")
}
if _, err := ValidateOutboundURL(channel.BaseURL); err != nil {
return nil, err
}
headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON)
if err != nil {
return nil, err
}
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
}
imageSize, imageQuality := "", ""
var imageProfile *ImageCapabilityConfig
videoRatio, videoResolution := videoTestDefaults(nil)
var videoProfile *VideoCapabilityConfig
switch capability {
case "image":
profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
if normalizeErr != nil {
return nil, normalizeErr
}
imageProfile = profile.Image
imageSize, imageQuality = imageTestDefaults(imageProfile)
case "video":
// 视频测试必须带上模型能力画像:声明式协议只按画像里的枚举回填分辨率名(如 480 -> 480p),
// 没有画像时会把裸数字发给上游,火山方舟等供应商会直接拒绝。
profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
if normalizeErr != nil {
return nil, normalizeErr
}
videoProfile = profile.Video
videoRatio, videoResolution = videoTestDefaults(videoProfile)
}
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,
Headers: headers,
Model: providerModelKey,
ChannelModelKey: modelKey,
Size: map[string]string{"image": imageSize, "video": videoRatio}[capability],
Quality: imageQuality,
Count: "1",
VideoSeconds: videoSeconds,
VQuality: videoResolution,
VideoGenerateAudio: "false",
VideoWatermark: "false",
AudioVoice: "alloy",
AudioFormat: "mp3",
AudioSpeed: "1",
},
Metadata: map[string]interface{}{},
}
if capability == "image" {
input.ImageCapability = imageProfile
}
if capability == "video" {
input.VideoCapability = videoProfile
}
// 测试复用真实生成协议、运行时并发和熔断策略,但不创建用户任务或计费订单。
testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{
Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability,
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, ProxyURL: channel.ProxyURL,
})
testCtx = withProtocolRegistry(testCtx, s.protocolRegistry())
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, WrapAppError(status, "模型测试失败:"+providerUserFacingErrorMessage(err), err)
}
return &AdminChannelModelTestResult{DurationMs: time.Since(startedAt).Milliseconds()}, nil
}
// 模型测试必须使用当前模型声明的默认参数,避免固定分辨率 SKU 被通用 1K 测试值误伤。
// videoTestDefaults 从模型能力画像取测试用的比例和分辨率;画像缺失时回退到最通用的 16:9 / 720。
func videoTestDefaults(profile *VideoCapabilityConfig) (string, string) {
if profile == nil {
return "16:9", "720"
}
ratio := strings.TrimSpace(profile.DefaultRatio)
if ratio == "" && len(profile.Ratios) > 0 {
ratio = strings.TrimSpace(profile.Ratios[0])
}
if ratio == "" {
ratio = "16:9"
}
resolution := strings.TrimSpace(profile.DefaultResolution)
if resolution == "" && len(profile.Resolutions) > 0 {
resolution = strings.TrimSpace(profile.Resolutions[0])
}
if resolution == "" {
resolution = "720"
}
return ratio, resolution
}
func imageTestDefaults(profile *ImageCapabilityConfig) (string, string) {
if profile == nil {
return "1024x1024", "auto"
}
size := ""
if profile.Size.Parameter != "none" {
size = strings.TrimSpace(profile.Size.Default)
}
quality := ""
if profile.Quality.Supported {
quality = strings.TrimSpace(profile.Quality.Default)
}
return size, quality
}