diff --git a/scripts/mockupstream/main.go b/scripts/mockupstream/main.go index 0d0a8aa..618f1ce 100644 --- a/scripts/mockupstream/main.go +++ b/scripts/mockupstream/main.go @@ -49,12 +49,6 @@ func main() { fmt.Fprint(w, `{"object":"list","data":[{"id":"gpt-4o-mini","object":"model"},{"id":"gpt-4o","object":"model"},{"id":"claude-sonnet-5","object":"model"}]}`) }) - // favicon(演示渠道主页图标) - http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/svg+xml") - fmt.Fprint(w, ``) - }) - // Anthropic Messages 端点(provider=anthropic 的渠道走这里) http.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) diff --git a/server/internal/api/admin_channels.go b/server/internal/api/admin_channels.go index dfa2e78..ea6f747 100644 --- a/server/internal/api/admin_channels.go +++ b/server/internal/api/admin_channels.go @@ -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 { diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index 4c58a61..817a052 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -5,7 +5,6 @@ import ( "encoding/json" "net/http" "strings" - "sync" "time" "github.com/gin-gonic/gin" @@ -18,20 +17,9 @@ import ( // Handler 聚合所有管理 API。 type Handler struct { a *app.App - - favMu sync.Mutex - favCache map[uint64]favEntry // 渠道 favicon 内存缓存 } -type favEntry struct { - data []byte - ct string - at time.Time -} - -func NewHandler(a *app.App) *Handler { - return &Handler{a: a, favCache: map[uint64]favEntry{}} -} +func NewHandler(a *app.App) *Handler { return &Handler{a: a} } // --------------------------------------------------------------------------- // 认证 diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 3af88b1..626f5fa 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -93,7 +93,6 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine { admin.DELETE("/channels/:id", h.AdminDeleteChannel) admin.POST("/channels/:id/test", h.AdminTestChannel) admin.POST("/channels/:id/models/import", h.AdminImportChannelModels) - admin.GET("/channels/:id/favicon", h.AdminChannelFavicon) // 模型与定价 admin.GET("/models", h.AdminModels) admin.POST("/models", h.AdminCreateModel) diff --git a/server/internal/proxy/passthrough.go b/server/internal/proxy/passthrough.go index 20f65a6..2669469 100644 --- a/server/internal/proxy/passthrough.go +++ b/server/internal/proxy/passthrough.go @@ -44,8 +44,10 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) { } // upstreamURL 组装上游地址:base_url + 路径。 +// 兼容用户填完整 base(含 /v1):去掉尾部 /v1,避免与请求路径重复。 func upstreamURL(ch *store.Channel, path string) string { - return strings.TrimRight(ch.BaseURL, "/") + path + base := strings.TrimRight(ch.BaseURL, "/") + return strings.TrimSuffix(base, "/v1") + path } // doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。 diff --git a/server/internal/store/models.go b/server/internal/store/models.go index 6201a40..cf3fd9b 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -81,7 +81,6 @@ type Channel struct { Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式) Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages - Homepage string `gorm:"size:255" json:"homepage"` // 渠道主页,用于展示 favicon BaseURL string `gorm:"size:255;not null" json:"base_url"` APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 Weight int `gorm:"not null;default:1" json:"weight"` diff --git a/web/src/types.ts b/web/src/types.ts index 4cd821d..a654418 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -26,7 +26,6 @@ export interface Channel { name: string provider: 'openai' | 'anthropic' | 'compatible' formats: string[] // chat | responses | messages - homepage?: string base_url: string api_key_masked: string weight: number diff --git a/web/src/views/admin/ChannelsView.vue b/web/src/views/admin/ChannelsView.vue index 5fb9022..7067461 100644 --- a/web/src/views/admin/ChannelsView.vue +++ b/web/src/views/admin/ChannelsView.vue @@ -19,7 +19,6 @@ const busyId = ref(null) const form = reactive({ name: '', formats: ['chat'] as string[], - homepage: '', base_url: '', api_key: '', weight: 1, @@ -29,24 +28,6 @@ const form = reactive({ enabled: true, }) -// favicon 兜底:灰色地球 -const FAV_FALLBACK = - 'data:image/svg+xml;utf8,' + - encodeURIComponent( - '', - ) -function onFavError(e: Event) { - ;(e.target as HTMLImageElement).src = FAV_FALLBACK -} -function hostOf(u?: string): string { - if (!u) return '-' - try { - return new URL(u).host - } catch { - return u - } -} - async function load() { try { const { data } = await http.get('/admin/channels') @@ -59,7 +40,7 @@ async function load() { function openCreate() { editing.value = null Object.assign(form, { - name: '', formats: ['chat'], homepage: '', base_url: '', api_key: '', + name: '', formats: ['chat'], base_url: '', api_key: '', weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true, }) editOpen.value = true @@ -69,7 +50,6 @@ function openEdit(ch: Channel) { editing.value = ch Object.assign(form, { name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])], - homepage: ch.homepage || '', base_url: ch.base_url, api_key: '', weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms, max_concurrency: ch.max_concurrency, enabled: ch.enabled, @@ -163,7 +143,6 @@ onMounted(load) 名称 API 格式 - 主页 Base URL Key 健康 @@ -183,18 +162,6 @@ onMounted(load) >{{ protocolName(f) }} - -
- - {{ hostOf(ch.homepage) }} -
- {{ ch.base_url }} {{ ch.api_key_masked || '****' }} @@ -215,7 +182,7 @@ onMounted(load) - 还没有渠道,点击「添加渠道」 + 还没有渠道,点击「添加渠道」 @@ -245,8 +212,12 @@ onMounted(load)

客户端协议不在其中时,网关自动转换为其支持的格式

- - +