diff --git a/server/internal/api/admin_channel_models.go b/server/internal/api/admin_channel_models.go index 6fe720b..98ba153 100644 --- a/server/internal/api/admin_channel_models.go +++ b/server/internal/api/admin_channel_models.go @@ -1,14 +1,67 @@ package api import ( + "encoding/json" "net/http" "strconv" + "strings" + "time" "github.com/gin-gonic/gin" "github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/store" ) +// AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote +// 拉取渠道接口的模型列表(仅预览,不绑定)。 +func (h *Handler) AdminChannelRemoteModels(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid channel id") + return + } + var ch store.Channel + if err := h.a.DB.First(&ch, id).Error; err != nil { + resp.Fail(c, http.StatusNotFound, "channel not found") + return + } + key, err := h.a.Enc.Decrypt(ch.APIKeyEnc) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") + return + } + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest(http.MethodGet, ch.UpstreamURL("", "/models"), nil) + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Accept", "application/json") + resp2, err := client.Do(req) + if err != nil { + resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error()) + return + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode)) + return + } + var list struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil { + resp.Fail(c, http.StatusBadGateway, "failed to parse model list") + return + } + items := make([]string, 0, len(list.Data)) + for _, m := range list.Data { + if strings.TrimSpace(m.ID) != "" { + items = append(items, strings.TrimSpace(m.ID)) + } + } + resp.OK(c, gin.H{"items": items}) +} + // AdminChannelModels GET /api/v1/admin/channels/:id/models — 渠道的模型绑定列表(含上游映射名)。 func (h *Handler) AdminChannelModels(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) diff --git a/server/internal/api/admin_channels.go b/server/internal/api/admin_channels.go index f4912c5..b9d75a2 100644 --- a/server/internal/api/admin_channels.go +++ b/server/internal/api/admin_channels.go @@ -34,7 +34,7 @@ func (h *Handler) AdminChannels(c *gin.Context) { } out = append(out, gin.H{ "id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), - "base_url": ch.BaseURL, + "base_url": ch.BaseURL, "base_urls": ch.BaseURLs, "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, @@ -45,16 +45,34 @@ func (h *Handler) AdminChannels(c *gin.Context) { } 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(主配置) - 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"` - Enabled *bool `json:"enabled"` + Name string `json:"name" binding:"required,min=1,max=64"` + Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据) + Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置) + BaseURL string `json:"base_url"` // 可选:留空按供应商默认;支持前缀或完整端点 + BaseURLs map[string]string `json:"base_urls"` // 分协议 base_url 覆盖(chat/responses/messages) + APIKey string `json:"api_key"` + Weight *int `json:"weight"` + Priority *int `json:"priority"` + TimeoutMS *int `json:"timeout_ms"` + MaxConcurrency *int `json:"max_concurrency"` + Enabled *bool `json:"enabled"` +} + +// normalizeBaseURLs 校验并清理分协议 base_url。 +func normalizeBaseURLs(m map[string]string) map[string]string { + if len(m) == 0 { + return nil + } + out := map[string]string{} + for k, v := range m { + if validFormats[k] && strings.TrimSpace(v) != "" { + out[k] = strings.TrimRight(strings.TrimSpace(v), "/") + } + } + if len(out) == 0 { + return nil + } + return out } // resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。 @@ -166,6 +184,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) { } ch := store.Channel{ Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL, + BaseURLs: normalizeBaseURLs(req.BaseURLs), 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), @@ -185,17 +204,18 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) { return } var body struct { - Name *string `json:"name"` - Provider *string `json:"provider"` - Formats *[]string `json:"formats"` - 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"` + Name *string `json:"name"` + Provider *string `json:"provider"` + Formats *[]string `json:"formats"` + BaseURL *string `json:"base_url"` + BaseURLs *map[string]string `json:"base_urls"` + 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") @@ -229,6 +249,11 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) { } updates["base_url"] = b } + if body.BaseURLs != nil { + // base_urls 是 jsonb:手动序列化 + raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs)) + updates["base_urls"] = string(raw) + } if body.APIKey != nil && *body.APIKey != "" { enc, err := h.a.Enc.Encrypt(*body.APIKey) if err != nil { @@ -265,20 +290,15 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) { resp.Fail(c, http.StatusBadRequest, ferr.Error()) return } - updates["formats"] = formats + // formats 是 jsonb:手动序列化为 JSON 字符串(map 更新不走序列化) + raw, _ := json.Marshal(formats) + updates["formats"] = string(raw) } if len(updates) > 0 { if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to update channel") return } - // jsonb 序列化走模型字段更新 - if f, ok := updates["formats"]; ok { - if err := h.a.DB.Model(&ch).Update("formats", f).Error; err != nil { - resp.Fail(c, http.StatusInternalServerError, "failed to update formats") - return - } - } } resp.OK(c, gin.H{"ok": true}) } @@ -321,7 +341,7 @@ func (h *Handler) AdminTestChannel(c *gin.Context) { resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") return } - url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" + url := ch.UpstreamURL("", "/models") client := &http.Client{Timeout: 10 * time.Second} req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("Authorization", "Bearer "+key) @@ -369,7 +389,7 @@ func (h *Handler) AdminImportChannelModels(c *gin.Context) { resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") return } - url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" + url := ch.UpstreamURL("", "/models") client := &http.Client{Timeout: 15 * time.Second} req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("Authorization", "Bearer "+key) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 5ce0575..7623044 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -104,6 +104,7 @@ 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/models/remote", h.AdminChannelRemoteModels) admin.GET("/channels/:id/models", h.AdminChannelModels) admin.POST("/channels/:id/models", h.AdminChannelAddModel) admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel) diff --git a/server/internal/channel/health.go b/server/internal/channel/health.go index 9a7a45d..648b45c 100644 --- a/server/internal/channel/health.go +++ b/server/internal/channel/health.go @@ -4,7 +4,6 @@ import ( "context" "log" "net/http" - "strings" "sync" "time" @@ -83,7 +82,7 @@ func (h *HealthMonitor) probe(ch *store.Channel) { h.record(ch, false) return } - url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" + url := ch.UpstreamURL("", "/models") req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Accept", "application/json") diff --git a/server/internal/proxy/gateway.go b/server/internal/proxy/gateway.go index 276cfb1..4369158 100644 --- a/server/internal/proxy/gateway.go +++ b/server/internal/proxy/gateway.go @@ -251,16 +251,17 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool { func upstreamPath(proto string) string { switch proto { case convert.ProtoMessages: - return "/v1/messages" + return "/messages" case convert.ProtoResponses: - return "/v1/responses" + return "/responses" default: - return "/v1/chat/completions" + return "/chat/completions" } } // upstreamPlan 描述一次代理请求的上游访问方式。 type upstreamPlan struct { + proto string // 上游协议(用于分协议 base_url) path string // 上游路径 body []byte // 已转换的请求体 lineConv func([]byte) []byte // 流式逐行转换(nil=直通) @@ -292,7 +293,7 @@ func prepareUpstream(ch *store.Channel, clientProto string, body []byte, upstrea if target == "" { return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name) } - plan := &upstreamPlan{path: upstreamPath(target), body: body} + plan := &upstreamPlan{proto: target, path: upstreamPath(target), body: body} if target != clientProto { converted, err := convert.ConvertRequest(body, clientProto, target) if err != nil { diff --git a/server/internal/proxy/passthrough.go b/server/internal/proxy/passthrough.go index 4efd149..32dffd7 100644 --- a/server/internal/proxy/passthrough.go +++ b/server/internal/proxy/passthrough.go @@ -44,19 +44,9 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) { return br, body, nil } -// upstreamURL 组装上游地址,智能识别用户填写的 Base URL 形式: -// - 完整端点(以目标资源路径结尾) → 直接使用 -// - 已含版本前缀(如 /v1) → 只拼资源路径(/chat/completions 等) -// - 纯域名/地址前缀 → 拼完整路径(/v1/chat/completions 等) -func upstreamURL(ch *store.Channel, path string) string { - base := strings.TrimRight(ch.BaseURL, "/") - if strings.HasSuffix(base, path) { - return base - } - if strings.HasSuffix(base, "/v1") { - return base + strings.TrimPrefix(path, "/v1") - } - return base + path +// upstreamURL 组装上游地址:按协议选 base_url 再拼资源路径(见 store.Channel.UpstreamURL)。 +func upstreamURL(ch *store.Channel, proto, path string) string { + return ch.UpstreamURL(proto, path) } // doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。 @@ -103,7 +93,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan upBody := plan.body // 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为) - if stream && plan.path == "/v1/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) { + if stream && plan.path == "/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) { var m map[string]any if json.Unmarshal(upBody, &m) == nil { m["stream_options"] = map[string]any{"include_usage": true} @@ -115,7 +105,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody)) if err != nil { return false, false, http.StatusInternalServerError, []byte("failed to build upstream request") } @@ -125,7 +115,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan if ua := c.GetHeader("User-Agent"); ua != "" { req.Header.Set("User-Agent", ua) } - if plan.path == "/v1/messages" { + if plan.path == "/messages" { req.Header.Set("anthropic-version", "2023-06-01") } for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} { diff --git a/server/internal/store/models.go b/server/internal/store/models.go index bb5243f..c7fb4af 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -2,7 +2,11 @@ // 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。 package store -import "time" +import ( + "regexp" + "strings" + "time" +) // 角色 / 状态枚举(字符串存库,便于阅读与迁移) const ( @@ -82,9 +86,10 @@ type Channel struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` 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 - BaseURL string `gorm:"size:255;not null" json:"base_url"` - APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 + Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages + BaseURL string `gorm:"size:255;not null" json:"base_url"` + BaseURLs map[string]string `gorm:"type:jsonb;serializer:json" json:"base_urls,omitempty"` // 分协议 base_url 覆盖(chat/responses/messages) + APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 Weight int `gorm:"not null;default:1" json:"weight"` Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先 TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"` @@ -110,6 +115,32 @@ func (c *Channel) FormatsEffective() []string { } } +// versionSegRe 匹配末尾版本前缀,如 /v1、/v2、/v4。 +var versionSegRe = regexp.MustCompile(`/v[0-9]+/?$`) + +// UpstreamURL 按协议选 base_url(分协议覆盖优先),再按版本前缀拼资源路径(path 不含 /v1)。 +// - proto 有 BaseURLs 覆盖时用覆盖值,否则用主 BaseURL +// - base 已以资源路径结尾 → 原样 +// - base 含版本前缀(如 /v1、/v4) → base + path +// - 否则 → base + /v1 + path(默认补 OpenAI/Anthropic 的 /v1) +func (c *Channel) UpstreamURL(proto, path string) string { + base := c.BaseURL + if len(c.BaseURLs) > 0 && c.BaseURLs[proto] != "" { + base = c.BaseURLs[proto] + } + base = strings.TrimRight(base, "/") + if base == "" { + return path + } + if strings.HasSuffix(base, path) { + return base + } + if versionSegRe.MatchString(base) { + return base + path + } + return base + "/v1" + path +} + // Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD) type Model struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` diff --git a/server/internal/store/url_test.go b/server/internal/store/url_test.go new file mode 100644 index 0000000..38adcde --- /dev/null +++ b/server/internal/store/url_test.go @@ -0,0 +1,50 @@ +package store + +import "testing" + +func TestChannelUpstreamURL(t *testing.T) { + cases := []struct { + base, path, want string + }{ + // 无版本前缀 → 默认补 /v1(OpenAI 纯域名) + {"https://api.openai.com", "/chat/completions", "https://api.openai.com/v1/chat/completions"}, + // 已含 /v1 → 直接拼资源路径 + {"https://api.openai.com/v1", "/chat/completions", "https://api.openai.com/v1/chat/completions"}, + {"https://api.anthropic.com/v1", "/messages", "https://api.anthropic.com/v1/messages"}, + // 自定义版本前缀(如 BigModel 的 /v4) → 直接拼资源路径 + {"https://open.bigmodel.cn/api/paas/v4", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"}, + {"https://open.bigmodel.cn/api/paas/v4", "/models", "https://open.bigmodel.cn/api/paas/v4/models"}, + // 本地 mock:无版本前缀补 /v1 + {"http://localhost:9000", "/chat/completions", "http://localhost:9000/v1/chat/completions"}, + {"http://localhost:9000/v1", "/models", "http://localhost:9000/v1/models"}, + // 完整端点原样 + {"https://api.openai.com/v1/chat/completions", "/chat/completions", "https://api.openai.com/v1/chat/completions"}, + // 尾斜杠 + {"https://api.openai.com/v1/", "/chat/completions", "https://api.openai.com/v1/chat/completions"}, + } + for _, c := range cases { + ch := &Channel{BaseURL: c.base} + got := ch.UpstreamURL("", c.path) + if got != c.want { + t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.base, c.path, got, c.want) + } + } + + // 分协议 base_url 覆盖 + ch := &Channel{ + BaseURL: "https://open.bigmodel.cn/api/paas/v4", + BaseURLs: map[string]string{"responses": "https://resp.example.com/v4", "messages": "https://msg.example.com/v1"}, + } + cases2 := []struct{ proto, path, want string }{ + {"chat", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"}, // 用主 base + {"responses", "/responses", "https://resp.example.com/v4/responses"}, // 用覆盖 base + {"messages", "/messages", "https://msg.example.com/v1/messages"}, // 用覆盖 base + {"", "/models", "https://open.bigmodel.cn/api/paas/v4/models"}, // 空协议用主 base + } + for _, c := range cases2 { + got := ch.UpstreamURL(c.proto, c.path) + if got != c.want { + t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.proto, c.path, got, c.want) + } + } +} diff --git a/web/src/types.ts b/web/src/types.ts index d08af97..669a030 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -29,6 +29,7 @@ export interface Channel { provider: 'openai' | 'anthropic' | 'compatible' formats: string[] // chat | responses | messages base_url: string + base_urls?: Record | null // 分协议 base_url 覆盖 api_key_masked: string weight: number priority: number diff --git a/web/src/views/admin/ChannelModelsDrawer.vue b/web/src/views/admin/ChannelModelsDrawer.vue new file mode 100644 index 0000000..f08ac66 --- /dev/null +++ b/web/src/views/admin/ChannelModelsDrawer.vue @@ -0,0 +1,174 @@ + + + diff --git a/web/src/views/admin/ChannelsView.vue b/web/src/views/admin/ChannelsView.vue index 87bc766..16dc780 100644 --- a/web/src/views/admin/ChannelsView.vue +++ b/web/src/views/admin/ChannelsView.vue @@ -1,13 +1,15 @@ - - - -
- 客户端调用「客户端名称」,网关转发为「上游模型」。无 /models 接口的渠道可手工添加。 -
- - - - - - - - - - - - - - - - - - - -
客户端名称上游模型权重 -
{{ b.model_name }} - - {{ b.weight }} -
- - -
-
尚未添加模型,可在下方手工填写
-
- -
- -
- -
-
- -
-