渠道: 分协议 Base URL + 模型抽屉/远程拉取/操作图标

- 渠道支持分协议 base_url(chat/responses/messages 各一), 网关按协议选 base 直通
  (如智谱三种格式不同 base, 一个渠道即可), UpstreamURL 按 proto 拼接
- 渠道模型改为下方抽屉: 当前绑定列表(内联改上游/解除)、从接口拉取(remote 预览+勾选添加)、手动添加
- 新增 /channels/:id/models/remote 预览接口; 操作按钮加 Phosphor 图标
- 修复 formats jsonb 更新未序列化问题; 手机端渠道卡片化

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-16 04:31:28 +08:00
co-authored by Claude
parent 7c4e80afac
commit db83972b6f
11 changed files with 484 additions and 246 deletions
@@ -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)
+52 -32
View File
@@ -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)
+1
View File
@@ -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)
+1 -2
View File
@@ -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")
+5 -4
View File
@@ -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 {
+6 -16
View File
@@ -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"} {
+35 -4
View File
@@ -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"`
+50
View File
@@ -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)
}
}
}