diff --git a/server/internal/api/admin_channels.go b/server/internal/api/admin_channels.go index e96c9b6..ea192d4 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, "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}) } diff --git a/server/internal/app/app.go b/server/internal/app/app.go index d0994d7..6aced75 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -100,6 +100,7 @@ func (a *App) Seed() error { ch := store.Channel{ Name: a.Cfg.Proxy.DefaultChannelName, Provider: store.ChannelProviderOpenAI, + Formats: []string{store.FormatChat, store.FormatResponses}, BaseURL: a.Cfg.Proxy.UpstreamBaseURL, APIKeyEnc: enc, Weight: 1, diff --git a/server/internal/proxy/gateway.go b/server/internal/proxy/gateway.go index 4199cad..537ce70 100644 --- a/server/internal/proxy/gateway.go +++ b/server/internal/proxy/gateway.go @@ -4,6 +4,7 @@ package proxy import ( "errors" + "fmt" "net/http" "strings" "time" @@ -175,21 +176,6 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool { // --------------------------------------------------------------------------- // 协议分派 -// upstreamProtoFor 根据渠道 provider 与客户端协议确定上游协议与路径。 -func upstreamProtoFor(provider, clientProto string) string { - switch provider { - case store.ChannelProviderAnthropic: - return convert.ProtoMessages - case store.ChannelProviderOpenAI: - if clientProto == convert.ProtoMessages { - return convert.ProtoChat - } - return clientProto - default: // compatible:假定 OpenAI Chat 形状 - return convert.ProtoChat - } -} - func upstreamPath(proto string) string { switch proto { case convert.ProtoMessages: @@ -209,18 +195,39 @@ type upstreamPlan struct { bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通) } -// prepareUpstream 计算上游访问计划:协议匹配直通,否则转换。 -func prepareUpstream(provider, clientProto string, body []byte) (*upstreamPlan, error) { - up := upstreamProtoFor(provider, clientProto) - plan := &upstreamPlan{path: upstreamPath(up), body: body} - if up != clientProto { - converted, err := convert.ConvertRequest(body, clientProto, up) +// conversionTarget 决定客户端协议在渠道上的处理方式: +// 渠道声明支持该协议则直通(返回原协议);否则转为其首选支持协议(chat > messages > responses)。 +func conversionTarget(formats []string, clientProto string) string { + for _, f := range formats { + if f == clientProto { + return clientProto + } + } + for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} { + for _, f := range formats { + if f == p { + return p + } + } + } + return "" +} + +// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换。 +func prepareUpstream(ch *store.Channel, clientProto string, body []byte) (*upstreamPlan, error) { + target := conversionTarget(ch.FormatsEffective(), clientProto) + if target == "" { + return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name) + } + plan := &upstreamPlan{path: upstreamPath(target), body: body} + if target != clientProto { + converted, err := convert.ConvertRequest(body, clientProto, target) if err != nil { return nil, err } plan.body = converted - plan.lineConv = convert.NewStreamTransformer(up, clientProto) - plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, up, clientProto) } + plan.lineConv = convert.NewStreamTransformer(target, clientProto) + plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) } } return plan, nil } diff --git a/server/internal/proxy/passthrough.go b/server/internal/proxy/passthrough.go index 1db7503..20f65a6 100644 --- a/server/internal/proxy/passthrough.go +++ b/server/internal/proxy/passthrough.go @@ -53,7 +53,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto st var lastStatus = http.StatusBadGateway var lastBody = []byte("all upstream channels failed") for _, ch := range cands { - plan, err := prepareUpstream(ch.Provider, clientProto, body) + plan, err := prepareUpstream(ch, clientProto, body) if err != nil { lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error()) continue diff --git a/server/internal/store/models.go b/server/internal/store/models.go index a89c457..cf3fd9b 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -22,6 +22,11 @@ const ( ChannelHealthDegraded = "degraded" ChannelHealthCooldown = "cooldown" + // 渠道原生支持的协议格式 + FormatChat = "chat" // OpenAI Chat Completions + FormatResponses = "responses" // OpenAI Responses API + FormatMessages = "messages" // Anthropic Messages + UsageStatusSuccess = "success" UsageStatusError = "error" UsageStatusCanceled = "canceled" @@ -74,7 +79,8 @@ type APIKey struct { 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 + 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 密文 Weight int `gorm:"not null;default:1" json:"weight"` @@ -87,6 +93,21 @@ type Channel struct { UpdatedAt time.Time `json:"updated_at"` } +// FormatsEffective 返回渠道实际支持的原生协议;未显式配置时按 provider 推断。 +func (c *Channel) FormatsEffective() []string { + if len(c.Formats) > 0 { + return c.Formats + } + switch c.Provider { + case ChannelProviderAnthropic: + return []string{FormatMessages} + case ChannelProviderOpenAI: + return []string{FormatChat, FormatResponses} + default: // compatible + return []string{FormatChat} + } +} + // Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD) type Model struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` diff --git a/web/src/lib/protocol.ts b/web/src/lib/protocol.ts new file mode 100644 index 0000000..47c3953 --- /dev/null +++ b/web/src/lib/protocol.ts @@ -0,0 +1,16 @@ +// 协议格式显示名与选项 +export const PROTOCOL_NAMES: Record = { + chat: 'OpenAI Chat Completions', + responses: 'OpenAI Responses API', + messages: 'Anthropic Messages', +} + +export const PROTOCOL_OPTIONS: { value: string; label: string }[] = [ + { value: 'chat', label: 'OpenAI Chat Completions' }, + { value: 'responses', label: 'OpenAI Responses API' }, + { value: 'messages', label: 'Anthropic Messages' }, +] + +export function protocolName(p: string): string { + return PROTOCOL_NAMES[p] ?? p +} diff --git a/web/src/types.ts b/web/src/types.ts index e5f2dc3..a654418 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -25,6 +25,7 @@ export interface Channel { id: number name: string provider: 'openai' | 'anthropic' | 'compatible' + formats: string[] // chat | responses | messages 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 fcf3e83..ad71e08 100644 --- a/web/src/views/admin/ChannelsView.vue +++ b/web/src/views/admin/ChannelsView.vue @@ -2,6 +2,7 @@ import { onMounted, reactive, ref } from 'vue' import { http, errMsg } from '@/api/client' import { useToastStore } from '@/stores/toast' +import { PROTOCOL_OPTIONS, protocolName } from '@/lib/protocol' import Button from '@/components/ui/Button.vue' import Input from '@/components/ui/Input.vue' import Modal from '@/components/ui/Modal.vue' @@ -18,6 +19,7 @@ const busyId = ref(null) const form = reactive({ name: '', provider: 'openai' as 'openai' | 'anthropic' | 'compatible', + formats: [] as string[], base_url: '', api_key: '', weight: 1, @@ -27,10 +29,15 @@ const form = reactive({ enabled: true, }) -const providerMap: Record = { - openai: 'OpenAI', - anthropic: 'Anthropic', - compatible: '兼容', +// 按供应商推断默认支持的协议格式 +function defaultFormats(p: string): string[] { + if (p === 'anthropic') return ['messages'] + if (p === 'openai') return ['chat', 'responses'] + return ['chat'] +} + +function onProviderChange() { + form.formats = defaultFormats(form.provider) } async function load() { @@ -45,7 +52,7 @@ async function load() { function openCreate() { editing.value = null Object.assign(form, { - name: '', provider: 'openai', base_url: '', api_key: '', + name: '', provider: 'openai', formats: defaultFormats('openai'), base_url: '', api_key: '', weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true, }) editOpen.value = true @@ -54,7 +61,8 @@ function openCreate() { function openEdit(ch: Channel) { editing.value = ch Object.assign(form, { - name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '', + name: ch.name, provider: ch.provider, formats: [...(ch.formats || defaultFormats(ch.provider))], + 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, }) @@ -142,7 +150,7 @@ onMounted(load) 名称 - 类型 + API 格式 Base URL Key 健康 @@ -153,7 +161,15 @@ onMounted(load) {{ ch.name }} - {{ providerMap[ch.provider] }} + +
+ {{ protocolName(f) }} +
+ {{ ch.base_url }} {{ ch.api_key_masked || '****' }} @@ -186,14 +202,38 @@ onMounted(load)
+
+ 支持的 API 格式 +
+ +
+

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

+
{{ l.model }} - {{ l.protocol }} + {{ protocolName(l.protocol) }} {{ l.input_tokens }}/{{ l.output_tokens }} {{ fmtCost(l.cost) }} {{ l.latency_ms }}ms