渠道: 移除主页/favicon 特性, Base URL 可选+完整地址
- 回退渠道 homepage/favicon 字段与代理接口(未采用) - Base URL 改为可选: 留空按供应商默认(openai/anthropic), 兼容兼容型渠道必须填; 填完整地址(含 /v1)时归一化去尾 - 网关 upstreamURL 兜底去 /v1, 避免路径重复 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
masked = "****"
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "homepage": ch.Homepage, "base_url": ch.BaseURL,
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "base_url": ch.BaseURL,
|
||||
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
|
||||
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
|
||||
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
|
||||
@@ -47,8 +47,7 @@ type channelBody struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
|
||||
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
|
||||
Homepage string `json:"homepage"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
BaseURL string `json:"base_url"` // 可选:为空时按供应商默认(openai/anthropic)
|
||||
APIKey string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
@@ -57,6 +56,23 @@ type channelBody struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// resolveBaseURL 渠道 base_url:留空按供应商默认;兼容用户填完整地址(含 /v1)。
|
||||
func resolveBaseURL(provider, raw string) (string, error) {
|
||||
base := strings.TrimRight(raw, "/")
|
||||
if base == "" {
|
||||
switch provider {
|
||||
case store.ChannelProviderOpenAI:
|
||||
base = "https://api.openai.com"
|
||||
case store.ChannelProviderAnthropic:
|
||||
base = "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
return "", errors.New("base_url required for compatible channels")
|
||||
}
|
||||
return strings.TrimSuffix(base, "/v1"), nil
|
||||
}
|
||||
|
||||
func validateProvider(p string) bool {
|
||||
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
|
||||
}
|
||||
@@ -137,13 +153,18 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
baseURL, err := resolveBaseURL(req.Provider, req.BaseURL)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
enc, err := h.a.Enc.Encrypt(req.APIKey)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key")
|
||||
return
|
||||
}
|
||||
ch := store.Channel{
|
||||
Name: req.Name, Provider: req.Provider, Formats: formats, Homepage: req.Homepage, BaseURL: strings.TrimRight(req.BaseURL, "/"),
|
||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||
@@ -163,18 +184,17 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Formats *[]string `json:"formats"`
|
||||
Homepage *string `json:"homepage"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
@@ -197,7 +217,16 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
updates["provider"] = *body.Provider
|
||||
}
|
||||
if body.BaseURL != nil {
|
||||
updates["base_url"] = strings.TrimRight(*body.BaseURL, "/")
|
||||
prov := ch.Provider
|
||||
if body.Provider != nil {
|
||||
prov = *body.Provider
|
||||
}
|
||||
b, berr := resolveBaseURL(prov, *body.BaseURL)
|
||||
if berr != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, berr.Error())
|
||||
return
|
||||
}
|
||||
updates["base_url"] = b
|
||||
}
|
||||
if body.APIKey != nil && *body.APIKey != "" {
|
||||
enc, err := h.a.Enc.Encrypt(*body.APIKey)
|
||||
@@ -219,9 +248,6 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
if body.MaxConcurrency != nil {
|
||||
updates["max_concurrency"] = *body.MaxConcurrency
|
||||
}
|
||||
if body.Homepage != nil {
|
||||
updates["homepage"] = *body.Homepage
|
||||
}
|
||||
if body.HealthStatus != nil {
|
||||
updates["health_status"] = *body.HealthStatus
|
||||
}
|
||||
@@ -403,53 +429,6 @@ func (h *Handler) AdminImportChannelModels(c *gin.Context) {
|
||||
resp.OK(c, gin.H{"imported": imported})
|
||||
}
|
||||
|
||||
// AdminChannelFavicon GET /api/v1/admin/channels/:id/favicon — 代理获取渠道主页 favicon(内存缓存 1h)。
|
||||
func (h *Handler) AdminChannelFavicon(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.a.DB.First(&ch, id).Error; err != nil || ch.Homepage == "" {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.favMu.Lock()
|
||||
if f, ok := h.favCache[id]; ok && time.Since(f.at) < time.Hour {
|
||||
h.favMu.Unlock()
|
||||
c.Data(http.StatusOK, f.ct, f.data)
|
||||
return
|
||||
}
|
||||
h.favMu.Unlock()
|
||||
|
||||
url := strings.TrimRight(ch.Homepage, "/") + "/favicon.ico"
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||
if err != nil || len(data) == 0 {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "image/x-icon"
|
||||
}
|
||||
h.favMu.Lock()
|
||||
h.favCache[id] = favEntry{data: data, ct: ct, at: time.Now()}
|
||||
h.favMu.Unlock()
|
||||
c.Data(http.StatusOK, ct, data)
|
||||
}
|
||||
|
||||
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
|
||||
|
||||
func intOr(p *int, def int) int {
|
||||
|
||||
Reference in New Issue
Block a user