渠道支持多 API 格式 + 协议全名展示

- Channel 新增 formats(jsonb: chat|responses|messages), 一个渠道可同时声明
  支持 OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages
- 路由改为按渠道声明的 formats 决定直通/转换: 客户端协议在 formats 内直通,
  否则转换为其首选支持格式(chat > messages > responses)
- 兼容旧数据: formats 为空时按 provider 推断(openai→chat+responses, anthropic→messages, compatible→chat)
- 前端: 渠道表/表单展示 API 格式(支持多选), usage 明细显示协议全名
  (OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 16:47:03 +08:00
co-authored by Claude
parent 6adadebaf0
commit 5eff32afd8
9 changed files with 200 additions and 57 deletions
+77 -21
View File
@@ -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, "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,
@@ -44,21 +44,52 @@ func (h *Handler) AdminChannels(c *gin.Context) {
}
type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Provider string `json:"provider" binding:"required"`
BaseURL string `json:"base_url" binding:"required"`
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" binding:"required"`
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages,空则按 provider 推断
BaseURL string `json:"base_url" binding:"required"`
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"`
}
func validateProvider(p string) bool {
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
}
var validFormats = map[string]bool{
store.FormatChat: true, store.FormatResponses: true, store.FormatMessages: true,
}
// resolveFormats 渠道协议格式:显式给出则校验去重;空则按 provider 推断默认。
func resolveFormats(provider string, formats []string) ([]string, error) {
if len(formats) == 0 {
switch provider {
case store.ChannelProviderAnthropic:
return []string{store.FormatMessages}, nil
case store.ChannelProviderOpenAI:
return []string{store.FormatChat, store.FormatResponses}, nil
default:
return []string{store.FormatChat}, nil
}
}
seen := map[string]bool{}
out := make([]string, 0, len(formats))
for _, f := range formats {
if !validFormats[f] {
return nil, fmt.Errorf("unsupported format %q", f)
}
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out, nil
}
// AdminCreateChannel POST /api/v1/admin/channels
func (h *Handler) AdminCreateChannel(c *gin.Context) {
var req channelBody
@@ -74,13 +105,18 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, "api_key required")
return
}
formats, err := resolveFormats(req.Provider, req.Formats)
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, BaseURL: strings.TrimRight(req.BaseURL, "/"),
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: strings.TrimRight(req.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),
@@ -100,16 +136,17 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
return
}
var body struct {
Name *string `json:"name"`
Provider *string `json:"provider"`
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"`
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")
@@ -160,11 +197,30 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
if body.Enabled != nil {
updates["enabled"] = *body.Enabled
}
if body.Formats != nil {
prov := ch.Provider
if body.Provider != nil {
prov = *body.Provider
}
formats, ferr := resolveFormats(prov, *body.Formats)
if ferr != nil {
resp.Fail(c, http.StatusBadRequest, ferr.Error())
return
}
updates["formats"] = formats
}
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})
}