渠道支持多 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})
}
+1
View File
@@ -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,
+30 -23
View File
@@ -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
}
+1 -1
View File
@@ -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
+22 -1
View File
@@ -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"`
+16
View File
@@ -0,0 +1,16 @@
// 协议格式显示名与选项
export const PROTOCOL_NAMES: Record<string, string> = {
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
}
+1
View File
@@ -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
+50 -10
View File
@@ -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<number | null>(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<string, string> = {
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)
<thead>
<tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">类型</th>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
@@ -153,7 +161,15 @@ onMounted(load)
<tbody>
<tr v-for="ch in channels" :key="ch.id" class="table-row">
<td class="px-4 py-2.5 text-ink">{{ ch.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ providerMap[ch.provider] }}</td>
<td class="px-4 py-2.5">
<div class="flex flex-wrap gap-1">
<span
v-for="f in ch.formats || []"
:key="f"
class="rounded-full bg-surface2 px-2 py-0.5 text-[10px] leading-4 text-muted"
>{{ protocolName(f) }}</span>
</div>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5">
@@ -186,14 +202,38 @@ onMounted(load)
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.name" label="名称" placeholder="openai" />
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-muted">API 类型</span>
<select v-model="form.provider" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
<span class="mb-1.5 block text-xs font-medium text-muted">供应商</span>
<select
v-model="form.provider"
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent"
@change="onProviderChange"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="compatible">兼容</option>
</select>
</label>
</div>
<div>
<span class="mb-1.5 block text-xs font-medium text-muted">支持的 API 格式</span>
<div class="flex flex-wrap gap-2">
<label
v-for="opt in PROTOCOL_OPTIONS"
:key="opt.value"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="form.formats.includes(opt.value) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
>
<input
v-model="form.formats"
type="checkbox"
:value="opt.value"
class="size-3.5 rounded accent-[var(--color-accent)]"
/>
{{ opt.label }}
</label>
</div>
<p class="mt-1.5 text-xs text-muted">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div>
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" />
<Input
v-model="form.api_key"
+2 -1
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import { protocolName } from '@/lib/protocol'
import Badge from '@/components/ui/Badge.vue'
import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types'
@@ -122,7 +123,7 @@ onMounted(load)
<tbody>
<tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.protocol }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>