feat: 三协议互转网关 + 鉴权修复 + 管理端增强

后端
- 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转,
  以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测)
- gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式),
  streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀
- gateway: 新增 SetUsageRecorder 注入异步用量记录器
- auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401;
  修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应
- usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段
- channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数
- api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容)

前端
- 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer
- 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts
- 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
This commit is contained in:
Sakurasan
2026-08-31 22:29:09 +08:00
parent e472ed93d5
commit f81b364436
34 changed files with 5171 additions and 179 deletions
+588
View File
@@ -0,0 +1,588 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"opencatd-open/internal/pkg/crypto"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
)
// AdminChannels GET /api/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
func (h *Handler) AdminChannels(c *gin.Context) {
var chs []store.Channel
if err := h.db.Order("id ASC").Find(&chs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load channels"})
return
}
out := make([]gin.H, 0, len(chs))
for _, ch := range chs {
masked := ""
if key, err := crypto.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
masked = maskAPIKey(key)
} else if err == nil {
masked = "****"
}
out = append(out, gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(),
"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,
"created_at": ch.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"`
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"`
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:留空按供应商默认;网关按内容智能识别前缀/完整端点。
func resolveBaseURL(provider, raw string) (string, error) {
base := strings.TrimRight(raw, "/")
if base == "" {
switch provider {
case store.ChannelProviderOpenAI:
base = "https://api.openai.com"
case store.ChannelProviderAnthropic:
base = "https://api.anthropic.com"
}
}
if base == "" {
return "", errors.New("base_url required for compatible channels")
}
return base, nil
}
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,
}
// deriveProvider 按格式推断供应商(仅作内部字段/兼容用途,不参与路由)。
func deriveProvider(formats []string) string {
if len(formats) == 0 {
return store.ChannelProviderCompatible
}
messagesOnly, hasResponses := true, false
for _, f := range formats {
if f != store.FormatMessages {
messagesOnly = false
}
if f == store.FormatResponses {
hasResponses = true
}
}
if messagesOnly {
return store.ChannelProviderAnthropic
}
if hasResponses {
return store.ChannelProviderOpenAI
}
return store.ChannelProviderCompatible
}
// 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/admin/channels
func (h *Handler) AdminCreateChannel(c *gin.Context) {
var req channelBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
return
}
if req.Provider == "" {
req.Provider = deriveProvider(req.Formats)
}
if !validateProvider(req.Provider) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"})
return
}
if req.APIKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"})
return
}
formats, err := resolveFormats(req.Provider, req.Formats)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
baseURL, err := resolveBaseURL(req.Provider, req.BaseURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
enc, err := crypto.Encrypt(req.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt api key"})
return
}
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),
}
if err := h.db.Create(&ch).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "failed to create channel (name may already exist)"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": ch.ID, "name": ch.Name})
}
// AdminUpdateChannel PUT /api/admin/channels/:id
func (h *Handler) AdminUpdateChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var body struct {
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
updates := map[string]any{}
if body.Name != nil {
updates["name"] = *body.Name
}
if body.Provider != nil {
if !validateProvider(*body.Provider) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"})
return
}
updates["provider"] = *body.Provider
}
if body.BaseURL != nil {
prov := ch.Provider
if body.Provider != nil {
prov = *body.Provider
}
b, berr := resolveBaseURL(prov, *body.BaseURL)
if berr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": berr.Error()})
return
}
updates["base_url"] = b
}
if body.BaseURLs != nil {
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
updates["base_urls"] = string(raw)
}
if body.APIKey != nil && *body.APIKey != "" {
enc, err := crypto.Encrypt(*body.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt api key"})
return
}
updates["api_key_enc"] = enc
}
if body.Weight != nil {
updates["weight"] = *body.Weight
}
if body.Priority != nil {
updates["priority"] = *body.Priority
}
if body.TimeoutMS != nil {
updates["timeout_ms"] = *body.TimeoutMS
}
if body.MaxConcurrency != nil {
updates["max_concurrency"] = *body.MaxConcurrency
}
if body.HealthStatus != nil {
updates["health_status"] = *body.HealthStatus
}
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 {
c.JSON(http.StatusBadRequest, gin.H{"error": ferr.Error()})
return
}
raw, _ := json.Marshal(formats)
updates["formats"] = string(raw)
}
if len(updates) > 0 {
if err := h.db.Model(&ch).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteChannel DELETE /api/admin/channels/:id
func (h *Handler) AdminDeleteChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
res := h.db.Delete(&store.Channel{}, id)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
h.db.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminTestChannel POST /api/admin/channels/:id/test — 请求渠道 /v1/models 测连通性。
func (h *Handler) AdminTestChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
key, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"})
return
}
url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
start := time.Now()
resp, err := client.Do(req)
status := store.ChannelHealthHealthy
msg := "ok"
latency := 0
if err != nil {
status = store.ChannelHealthCooldown
msg = err.Error()
} else {
latency = int(time.Since(start).Milliseconds())
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
status = store.ChannelHealthCooldown
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
msg = fmt.Sprintf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
resp.Body.Close()
}
h.db.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status)
if status != store.ChannelHealthHealthy {
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency, "message": msg})
}
// AdminChannelRemoteModels GET /api/admin/channels/:id/models/remote — 拉取远端模型列表。
// 返回本渠道尚未允许的模型(新增候选),排除已绑定的模型。
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
key, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"})
return
}
url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("http %d: %s", resp.StatusCode, string(body))})
return
}
// 解析 OpenAI 格式的模型列表
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse response: " + err.Error()})
return
}
// 本渠道已允许的上游模型名:不作为新增候选
var boundNames []string
h.db.Model(&store.ChannelModelBinding{}).Where("channel_id = ?", id).Pluck("upstream_model", &boundNames)
boundSet := make(map[string]bool, len(boundNames))
for _, n := range boundNames {
boundSet[strings.TrimSpace(n)] = true
}
models := make([]string, 0, len(result.Data))
for _, m := range result.Data {
name := strings.TrimSpace(m.ID)
if name != "" && !boundSet[name] {
models = append(models, name)
}
}
c.JSON(http.StatusOK, gin.H{"data": models})
}
// AdminChannelModels GET /api/admin/channels/:id/models — 渠道绑定列表。
func (h *Handler) AdminChannelModels(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var bindings []store.ChannelModelBinding
if err := h.db.Preload("Model").Where("channel_id = ?", channelID).Find(&bindings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load bindings"})
return
}
out := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
out = append(out, gin.H{
"id": b.ID, "model_id": b.ModelID, "model_name": b.Model.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// AdminChannelAddModel POST /api/admin/channels/:id/models — 手工添加模型绑定。
// 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var req struct {
UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名
CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: upstream_model required"})
return
}
globalName := req.CustomName
if globalName == "" {
globalName = req.UpstreamModel
}
var ch store.Channel
if err := h.db.First(&ch, channelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// 查找或创建全局模型
var m store.Model
if err := h.db.Where("name = ?", globalName).First(&m).Error; err != nil {
m = store.Model{Name: globalName, Enabled: true}
if err := h.db.Create(&m).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create model"})
return
}
}
// 查找已存在的绑定,如果存在则更新
var existing store.ChannelModelBinding
if err := h.db.Where("channel_id = ? AND model_id = ?", channelID, m.ID).First(&existing).Error; err == nil {
// 已存在,更新
existing.UpstreamModel = req.UpstreamModel
existing.Weight = intOr(req.Weight, 1)
if err := h.db.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
return
}
c.JSON(http.StatusOK, gin.H{"id": existing.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": existing.UpstreamModel, "weight": existing.Weight})
return
}
// 不存在,创建新的
b := store.ChannelModelBinding{
ChannelID: channelID, ModelID: m.ID,
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.db.Create(&b).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight})
}
// AdminChannelUpdateModel PATCH /api/admin/channels/:id/models/:bid — 改映射名/权重。
func (h *Handler) AdminChannelUpdateModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
var req struct {
UpstreamModel *string `json:"upstream_model"`
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var b store.ChannelModelBinding
if err := h.db.First(&b, bid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
updates := map[string]any{}
if req.UpstreamModel != nil {
updates["upstream_model"] = *req.UpstreamModel
}
if req.Weight != nil {
updates["weight"] = *req.Weight
}
if len(updates) > 0 {
if err := h.db.Model(&b).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminChannelDeleteModel DELETE /api/admin/channels/:id/models/:bid — 解除绑定。
func (h *Handler) AdminChannelDeleteModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
func maskAPIKey(key string) string {
if len(key) <= 11 {
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
}
return key[:7] + "******" + key[len(key)-4:]
}
func intOr(p *int, def int) int {
if p == nil {
return def
}
return *p
}
func boolOr(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"net/http"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
)
// AdminGetConfig GET /api/admin/config — 获取系统配置。
func (h *Handler) AdminGetConfig(c *gin.Context) {
configs := map[string]string{}
var rows []store.SystemConfig
h.db.Find(&rows)
for _, r := range rows {
configs[r.Key] = r.Value
}
c.JSON(http.StatusOK, gin.H{"data": configs})
}
// AdminUpdateConfig PUT /api/admin/config — 更新系统配置。
func (h *Handler) AdminUpdateConfig(c *gin.Context) {
var req map[string]string
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
for key, value := range req {
var sc store.SystemConfig
result := h.db.Where("key = ?", key).First(&sc)
if result.Error == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: key, Value: value}
h.db.Create(&sc)
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminGetRegistration GET /api/admin/config/registration — 获取注册配置。
func (h *Handler) AdminGetRegistration(c *gin.Context) {
var sc store.SystemConfig
enabled := "true"
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
enabled = sc.Value
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
}
// AdminUpdateRegistration PUT /api/admin/config/registration — 更新注册配置。
func (h *Handler) AdminUpdateRegistration(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
value := "false"
if req.Enabled {
value = "true"
}
var sc store.SystemConfig
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: "registration_enabled", Value: value}
h.db.Create(&sc)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminGetPasswordLogin GET /api/admin/config/password-login — 获取密码登录配置。
func (h *Handler) AdminGetPasswordLogin(c *gin.Context) {
var sc store.SystemConfig
enabled := "true"
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
enabled = sc.Value
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
}
// AdminUpdatePasswordLogin PUT /api/admin/config/password-login — 更新密码登录配置。
func (h *Handler) AdminUpdatePasswordLogin(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
value := "false"
if req.Enabled {
value = "true"
}
var sc store.SystemConfig
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: "password_login_enabled", Value: value}
h.db.Create(&sc)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+291
View File
@@ -0,0 +1,291 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AdminModels GET /api/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
func (h *Handler) AdminModels(c *gin.Context) {
var ms []store.Model
if err := h.db.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
return
}
allow, deny := h.modelPolicyConfig()
out := make([]gin.H, 0, len(ms))
for _, m := range ms {
var bindings []store.ChannelModelBinding
h.db.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
chs := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
if !b.Channel.Enabled {
continue
}
chs = append(chs, gin.H{
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight,
})
}
used := len(chs) > 0
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
out = append(out, gin.H{
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
"used": used, "needs_pricing": needsPricing, "denied": denied,
})
}
var orphans []struct {
ChannelName string
UpstreamModel string
ModelID uint64
}
h.db.Raw(`SELECT c.name as channel_name, b.model_id, b.upstream_model
FROM channel_model_bindings b
LEFT JOIN models m ON m.id = b.model_id
LEFT JOIN channels c ON c.id = b.channel_id
WHERE m.id IS NULL`).Scan(&orphans)
missing := make([]gin.H, 0, len(orphans))
for _, o := range orphans {
missing = append(missing, gin.H{
"channel": o.ChannelName, "model_id": o.ModelID, "upstream_model": o.UpstreamModel,
})
}
unpriced := 0
{
var usedBindings []struct {
ModelID uint64
}
h.db.Model(&store.ChannelModelBinding{}).Distinct("model_id").Scan(&usedBindings)
usedIDs := map[uint64]bool{}
for _, u := range usedBindings {
usedIDs[u.ModelID] = true
}
for _, m := range ms {
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
unpriced++
}
}
}
c.JSON(http.StatusOK, gin.H{
"data": out,
"summary": gin.H{
"total": len(ms),
"unpriced": unpriced,
"missing": missing,
"denied_count": len(deny),
},
})
}
// modelPolicyConfig 读取全局模型允许/禁止列表。
func (h *Handler) modelPolicyConfig() (allow, deny []string) {
var raw string
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &deny)
return
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// AdminCreateModel POST /api/admin/models
func (h *Handler) AdminCreateModel(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required,min=1,max=128"`
DisplayName string `json:"display_name"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
return
}
m := store.Model{
Name: req.Name, DisplayName: req.DisplayName,
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
Sort: req.Sort, Enabled: boolOr(req.Enabled, true),
}
if err := h.db.Create(&m).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "failed to create model (name may already exist)"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": m.ID, "name": m.Name})
}
// AdminUpdateModel PUT /api/admin/models/:id — 价格/启停/排序。
func (h *Handler) AdminUpdateModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
var req struct {
DisplayName *string `json:"display_name"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
Enabled *bool `json:"enabled"`
Sort *int `json:"sort"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var m store.Model
if err := h.db.First(&m, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
updates := map[string]any{}
if req.DisplayName != nil {
updates["display_name"] = *req.DisplayName
}
if req.InputPrice != nil {
updates["input_price"] = *req.InputPrice
}
if req.OutputPrice != nil {
updates["output_price"] = *req.OutputPrice
}
if req.CacheReadPrice != nil {
updates["cache_read_price"] = *req.CacheReadPrice
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.Sort != nil {
updates["sort"] = *req.Sort
}
if len(updates) > 0 {
if err := h.db.Model(&m).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteModel DELETE /api/admin/models/:id
func (h *Handler) AdminDeleteModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
res := h.db.Delete(&store.Model{}, id)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
h.db.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteUnusedModels DELETE /api/admin/models/unused — 一键清除未绑定任何渠道的模型。
func (h *Handler) AdminDeleteUnusedModels(c *gin.Context) {
var orphans []store.Model
if err := h.db.Where("id NOT IN (SELECT DISTINCT model_id FROM channel_model_bindings)").Find(&orphans).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
return
}
names := make([]string, 0, len(orphans))
ids := make([]uint64, 0, len(orphans))
for _, m := range orphans {
names = append(names, m.Name)
ids = append(ids, m.ID)
}
if len(ids) > 0 {
if err := h.db.Delete(&store.Model{}, ids).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete models"})
return
}
}
c.JSON(http.StatusOK, gin.H{"deleted": names, "count": len(names)})
}
// AdminCreateModelBinding POST /api/admin/models/:id/bindings
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
var req struct {
ChannelID uint64 `json:"channel_id" binding:"required"`
UpstreamModel string `json:"upstream_model" binding:"required"`
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: channel_id and upstream_model required"})
return
}
var m store.Model
if err := h.db.First(&m, modelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
var ch store.Channel
if err := h.db.First(&ch, req.ChannelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
b := store.ChannelModelBinding{
ChannelID: req.ChannelID, ModelID: modelID,
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.db.Create(&b).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": b.ID})
}
// AdminDeleteModelBinding DELETE /api/admin/models/:id/bindings/:bid
func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
var _ = gorm.ErrRecordNotFound
+3 -3
View File
@@ -556,7 +556,7 @@ func (h *Handler) DeleteApiKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "deleted"}) c.JSON(http.StatusOK, gin.H{"message": "deleted"})
} }
// --- Channels --- // --- Legacy Channel endpoints (kept for backward compatibility) ---
func (h *Handler) ListChannels(c *gin.Context) { func (h *Handler) ListChannels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters // Support both limit/offset and pageSize/page parameters
@@ -702,7 +702,7 @@ func (h *Handler) DeleteChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "deleted"}) c.JSON(http.StatusOK, gin.H{"message": "deleted"})
} }
// --- Models --- // --- Legacy Model endpoints (kept for backward compatibility) ---
func (h *Handler) ListModels(c *gin.Context) { func (h *Handler) ListModels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters // Support both limit/offset and pageSize/page parameters
@@ -827,7 +827,7 @@ func (h *Handler) DeleteModel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "deleted"}) c.JSON(http.StatusOK, gin.H{"message": "deleted"})
} }
// --- Channel-Model Bindings --- // --- Legacy Channel-Model Bindings (kept for backward compatibility) ---
func (h *Handler) BindChannelModels(c *gin.Context) { func (h *Handler) BindChannelModels(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64) channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+25
View File
@@ -19,6 +19,9 @@ type Service struct {
// Health tracking // Health tracking
mu sync.RWMutex mu sync.RWMutex
healthStatus map[uint64]*channelHealth healthStatus map[uint64]*channelHealth
// Concurrency control per channel
sems map[uint64]chan struct{}
} }
type channelHealth struct { type channelHealth struct {
@@ -33,6 +36,7 @@ func NewService(channelDAO *dao.ChannelDAO, modelDAO *dao.ModelDAO) *Service {
channelDAO: channelDAO, channelDAO: channelDAO,
modelDAO: modelDAO, modelDAO: modelDAO,
healthStatus: make(map[uint64]*channelHealth), healthStatus: make(map[uint64]*channelHealth),
sems: make(map[uint64]chan struct{}),
} }
} }
@@ -219,3 +223,24 @@ func (s *Service) SelectCandidates(ctx context.Context, modelName string, prefer
return candidates, nil return candidates, nil
} }
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
// MaxConcurrency<=0 视为不限制。
func (s *Service) TryAcquire(ch *store.Channel) (func(), bool) {
if ch.MaxConcurrency <= 0 {
return func() {}, true
}
s.mu.Lock()
sem, ok := s.sems[ch.ID]
if !ok {
sem = make(chan struct{}, ch.MaxConcurrency)
s.sems[ch.ID] = sem
}
s.mu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
default:
return nil, false
}
}
+34 -4
View File
@@ -10,19 +10,45 @@ import (
"time" "time"
) )
// HealthConfig 健康检查配置
type HealthConfig struct {
Interval time.Duration // 检查间隔
Timeout time.Duration // 请求超时
FailureThreshold int // 连续失败次数阈值
DegradedCooldown time.Duration // degraded 冷却时间
CooldownCooldown time.Duration // cooldown 冷却时间
}
// DefaultHealthConfig 返回默认健康检查配置
func DefaultHealthConfig() HealthConfig {
return HealthConfig{
Interval: 5 * time.Minute,
Timeout: 10 * time.Second,
FailureThreshold: 3,
DegradedCooldown: 5 * time.Minute,
CooldownCooldown: 15 * time.Minute,
}
}
type HealthChecker struct { type HealthChecker struct {
channelDAO *dao.ChannelDAO channelDAO *dao.ChannelDAO
service *Service service *Service
client *http.Client client *http.Client
config HealthConfig
} }
func NewHealthChecker(channelDAO *dao.ChannelDAO, service *Service) *HealthChecker { func NewHealthChecker(channelDAO *dao.ChannelDAO, service *Service, config ...HealthConfig) *HealthChecker {
cfg := DefaultHealthConfig()
if len(config) > 0 {
cfg = config[0]
}
return &HealthChecker{ return &HealthChecker{
channelDAO: channelDAO, channelDAO: channelDAO,
service: service, service: service,
client: &http.Client{ client: &http.Client{
Timeout: 10 * time.Second, Timeout: cfg.Timeout,
}, },
config: cfg,
} }
} }
@@ -91,8 +117,12 @@ func (hc *HealthChecker) CheckAllChannels(ctx context.Context) error {
} }
// StartPeriodicCheck starts periodic health checks // StartPeriodicCheck starts periodic health checks
func (hc *HealthChecker) StartPeriodicCheck(ctx context.Context, interval time.Duration) { func (hc *HealthChecker) StartPeriodicCheck(ctx context.Context, interval ...time.Duration) {
ticker := time.NewTicker(interval) interval_ := hc.config.Interval
if len(interval) > 0 {
interval_ = interval[0]
}
ticker := time.NewTicker(interval_)
defer ticker.Stop() defer ticker.Stop()
for { for {
+10 -1
View File
@@ -82,10 +82,19 @@ func (d *DailyUsageDAO) GetByDate(ctx context.Context, userID uint64, date strin
return &log, nil return &log, nil
} }
// UpsertDailyUsage 按 (user_id, model_id, date) 累加式 upsert:
// 行不存在则插入;存在则在原值基础上增量累加(不能用 AssignmentColumns 覆盖,
// 否则多次 flush 会互相清零)。非限定列名在 SQLite/MySQL/PG 的 upsert 语义下都指向目标行。
func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, log *store.UsageDaily) error { func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, log *store.UsageDaily) error {
return d.db.WithContext(ctx).Clauses(clause.OnConflict{ return d.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}}, Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
DoUpdates: clause.AssignmentColumns([]string{"requests", "input_tokens", "output_tokens", "cache_read_tokens", "cost"}), DoUpdates: clause.Assignments(map[string]interface{}{
"requests": gorm.Expr("requests + ?", log.Requests),
"input_tokens": gorm.Expr("input_tokens + ?", log.InputTokens),
"output_tokens": gorm.Expr("output_tokens + ?", log.OutputTokens),
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", log.CacheReadTokens),
"cost": gorm.Expr("cost + ?", log.Cost),
}),
}).Create(log).Error }).Create(log).Error
} }
+203
View File
@@ -0,0 +1,203 @@
// 三协议互转注册表:OpenAI Chat / OpenAI Responses / Anthropic Messages。
// 网关以 Chat 形状作为标准中间模型:非跨 chat 的转换经 chat 中转。
// 请求/响应(非流式)走 JSON 转换;流式走逐行 SSE 转换(stream_transform.go)。
package convert
import (
"bytes"
"encoding/json"
"fmt"
)
// 协议标识。
const (
ProtoChat = "chat"
ProtoMessages = "messages"
ProtoResponses = "responses"
)
// trimBody 去掉首尾空白。部分上游(如 OpenRouter)会在 JSON 前输出空白或
// SSE 注释行再跟正文,直接 Unmarshal 会失败。
func trimBody(body []byte) []byte {
return bytes.TrimSpace(body)
}
// CleanJSON 剥离非 JSON 前缀(空白、SSE 注释、`data:` 行)并压缩为标准 JSON。
// 部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释;
// 原样透传会让客户端解析失败。找不到 JSON 对象时原样返回。
func CleanJSON(body []byte) []byte {
i := bytes.IndexByte(body, '{')
if i < 0 {
return body
}
var v any
if err := json.Unmarshal(bytes.TrimSpace(body[i:]), &v); err != nil {
return body
}
out, err := json.Marshal(v)
if err != nil {
return body
}
return out
}
// ConvertRequest 转换请求体。from==to 时原样返回。
func ConvertRequest(body []byte, from, to string) ([]byte, error) {
if from == to {
return body, nil
}
body = trimBody(body)
switch {
case from == ProtoMessages && to == ProtoChat:
return messagesToChatReq(body)
case from == ProtoChat && to == ProtoMessages:
return chatToMessagesReq(body)
case from == ProtoResponses && to == ProtoChat:
return responsesToChatReq(body)
case from == ProtoChat && to == ProtoResponses:
return chatToResponsesReq(body)
case from == ProtoResponses && to == ProtoMessages:
mid, err := responsesToChatReq(body)
if err != nil {
return nil, err
}
return chatToMessagesReq(mid)
case from == ProtoMessages && to == ProtoResponses:
mid, err := messagesToChatReq(body)
if err != nil {
return nil, err
}
return chatToResponsesReq(mid)
}
return nil, fmt.Errorf("unsupported request conversion %s->%s", from, to)
}
// ConvertResponse 转换响应体(非流式)。from==to 时原样返回。
func ConvertResponse(body []byte, from, to string) ([]byte, error) {
if from == to {
return body, nil
}
body = trimBody(body)
switch {
case from == ProtoMessages && to == ProtoChat:
return messagesToChatResp(body)
case from == ProtoChat && to == ProtoMessages:
return chatToMessagesResp(body)
case from == ProtoResponses && to == ProtoChat:
return responsesToChatResp(body)
case from == ProtoChat && to == ProtoResponses:
return chatToResponsesResp(body)
case from == ProtoResponses && to == ProtoMessages:
mid, err := responsesToChatResp(body)
if err != nil {
return nil, err
}
return chatToMessagesResp(mid)
case from == ProtoMessages && to == ProtoResponses:
mid, err := messagesToChatResp(body)
if err != nil {
return nil, err
}
return chatToResponsesResp(mid)
}
return nil, fmt.Errorf("unsupported response conversion %s->%s", from, to)
}
// NewStreamTransformer 构造流式逐行转换器:输入上游 SSE 一行,返回客户端 SSE 行。
// 返回 nil 表示丢弃该行或无需转换(from==to)。
func NewStreamTransformer(from, to string) func([]byte) []byte {
switch {
case from == ProtoMessages && to == ProtoChat:
return newMessagesToChat().line
case from == ProtoChat && to == ProtoMessages:
return newChatToMessages().line
case from == ProtoResponses && to == ProtoChat:
return newResponsesToChat().line
case from == ProtoChat && to == ProtoResponses:
return newChatToResponses().line
case from == ProtoResponses && to == ProtoMessages:
return newResponsesToMessages().line
case from == ProtoMessages && to == ProtoResponses:
return newMessagesToResponses().line
}
return nil
}
// ---------------------------------------------------------------------------
// 工具函数
// str 返回字符串字段;json.RawMessage 为字符串字面量时去引号。
func str(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
// 数组/对象:尝试取 type=text 的 text
var arr []map[string]any
if json.Unmarshal(raw, &arr) == nil {
var parts []string
for _, b := range arr {
if t, _ := b["type"].(string); t == "text" || t == "input_text" || t == "output_text" {
if txt, _ := b["text"].(string); txt != "" {
parts = append(parts, txt)
}
}
}
return joinNonEmpty(parts, "\n")
}
return ""
}
func joinNonEmpty(parts []string, sep string) string {
out := ""
for _, p := range parts {
if p == "" {
continue
}
if out != "" {
out += sep
}
out += p
}
return out
}
// rawJSON 安全取字段;不存在或 null 返回 nil。
func rawJSON(m map[string]json.RawMessage, key string) json.RawMessage {
raw, ok := m[key]
if !ok || string(raw) == "null" {
return nil
}
return raw
}
// rawOrObject 把 RawMessage 解为 map;非对象返回空对象。
func rawOrObject(raw json.RawMessage) any {
if len(raw) == 0 || string(raw) == "null" {
return map[string]any{}
}
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
return m
}
return map[string]any{}
}
// intOrNil 取指针值,nil 时返回默认值。
func intOrNil(p *int, def int) any {
if p == nil {
return def
}
return *p
}
// strField 取 any 中的字符串字段。
func strField(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
+504
View File
@@ -0,0 +1,504 @@
package convert
import (
"encoding/json"
"strings"
)
// ---------------------------------------------------------------------------
// 请求:Chat → Messages
type chatTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type chatMsg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolCallID string `json:"tool_call_id"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Tools []chatTool `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
Stop []string `json:"stop"`
Stream bool `json:"stream"`
}
// chatToMessagesReq 将 OpenAI Chat 请求转为 Anthropic Messages 请求。
func chatToMessagesReq(body []byte) ([]byte, error) {
var req chatReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{
"model": req.Model,
"max_tokens": intOrNil(req.MaxTokens, 1024), // Anthropic 必填
}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if len(req.Stop) > 0 {
out["stop_sequences"] = req.Stop
}
var system []string
msgs := make([]any, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == "system" {
if s := str(m.Content); s != "" {
system = append(system, s)
}
continue
}
msgs = append(msgs, chatMsgToAnthropic(m))
}
if len(system) > 0 {
out["system"] = strings.Join(system, "\n")
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
var params any
if len(t.Function.Parameters) > 0 && string(t.Function.Parameters) != "null" {
_ = json.Unmarshal(t.Function.Parameters, &params)
}
tools = append(tools, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": params,
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// chatMsgToAnthropic 单条消息转 Anthropic 内容。
func chatMsgToAnthropic(m chatMsg) any {
switch m.Role {
case "assistant":
content := make([]any, 0, 2)
if s := str(m.Content); s != "" {
content = append(content, map[string]any{"type": "text", "text": s})
}
for _, tc := range m.ToolCalls {
var input any
if tc.Function.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
}
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
return map[string]any{"role": "assistant", "content": content}
case "tool":
return map[string]any{"role": "user", "content": []any{
map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": str(m.Content)},
}}
default: // user
var arr []map[string]any
if json.Unmarshal(m.Content, &arr) == nil && arr != nil {
blocks := make([]any, 0, len(arr))
for _, b := range arr {
switch b["type"] {
case "text", "input_text":
if t, _ := b["text"].(string); t != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": t})
}
case "image_url":
var url string
if iu, ok := b["image_url"].(map[string]any); ok {
url, _ = iu["url"].(string)
} else if s, ok := b["image_url"].(string); ok {
url = s
}
if url != "" {
blocks = append(blocks, anthropicImageBlock(url))
}
}
}
if len(blocks) > 0 {
return map[string]any{"role": "user", "content": blocks}
}
}
return map[string]any{"role": "user", "content": str(m.Content)}
}
}
// ---------------------------------------------------------------------------
// 请求:Messages → Chat
type messagesReq struct {
Model string `json:"model"`
System json.RawMessage `json:"system"`
Messages []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
} `json:"messages"`
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
} `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
StopSequence []string `json:"stop_sequences"`
Stream bool `json:"stream"`
}
// messagesToChatReq 将 Anthropic Messages 请求转为 OpenAI Chat 请求。
func messagesToChatReq(body []byte) ([]byte, error) {
var req messagesReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{"model": req.Model}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if req.MaxTokens != nil {
out["max_tokens"] = *req.MaxTokens
}
if len(req.StopSequence) > 0 {
out["stop"] = req.StopSequence
}
msgs := make([]any, 0, len(req.Messages)+1)
if s := str(req.System); s != "" {
msgs = append(msgs, map[string]any{"role": "system", "content": s})
}
for _, m := range req.Messages {
msgs = append(msgs, anthropicMsgToChat(m.Role, m.Content)...)
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": rawOrObject(t.InputSchema),
},
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// anthropicMsgToChat 将一条 Anthropic 消息拆成 0..N 条 Chat 消息。
func anthropicMsgToChat(role string, content json.RawMessage) []any {
// 块数组优先(tool_use / tool_result 需要分块解析)
var blocks []map[string]any
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
var out []any
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
var textParts []string
var contentBlocks []any // text / image_url 块,保留原始顺序
var toolCalls []any
for _, b := range blocks {
switch b["type"] {
case "text":
if t, _ := b["text"].(string); t != "" {
textParts = append(textParts, t)
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
}
case "image":
if cb := chatImageBlock(b); cb != nil {
contentBlocks = append(contentBlocks, cb)
}
case "tool_use":
id, _ := b["id"].(string)
name, _ := b["name"].(string)
args, _ := json.Marshal(b["input"])
toolCalls = append(toolCalls, map[string]any{
"id": id,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": string(args),
},
})
case "tool_result":
callID, _ := b["tool_use_id"].(string)
res := strField(b["content"])
toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
}
}
hasImage := false
for _, cb := range contentBlocks {
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
hasImage = true
break
}
}
if hasImage || len(textParts) > 0 || len(toolCalls) > 0 {
msg := map[string]any{"role": role}
switch {
case hasImage:
msg["content"] = contentBlocks
case len(textParts) > 0:
msg["content"] = strings.Join(textParts, "")
}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
out = append(out, msg)
}
out = append(out, toolMsgs...)
if len(out) > 0 {
return out
}
}
// 纯文本
if s := str(content); s != "" {
return []any{map[string]any{"role": role, "content": s}}
}
return nil
}
// ---------------------------------------------------------------------------
// 响应:Messages → Chat
type messagesResp struct {
ID string `json:"id"`
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
}
// messagesToChatResp 将 Anthropic Messages 响应(非流式)转为 Chat 响应。
func messagesToChatResp(body []byte) ([]byte, error) {
var r messagesResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var text string
var toolCalls []any
for _, c := range r.Content {
switch c.Type {
case "text":
text += c.Text
case "tool_use":
args, _ := json.Marshal(c.Input)
toolCalls = append(toolCalls, map[string]any{
"id": c.ID,
"type": "function",
"function": map[string]any{
"name": c.Name,
"arguments": string(args),
},
})
}
}
msg := map[string]any{"role": "assistant", "content": text}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
return json.Marshal(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(r.ID, "msg_"),
"object": "chat.completion",
"model": r.Model,
"created": 0,
"choices": []any{map[string]any{
"index": 0,
"message": msg,
"finish_reason": messagesStopToChat(r.StopReason),
}},
"usage": map[string]any{
"prompt_tokens": r.Usage.InputTokens,
"completion_tokens": r.Usage.OutputTokens,
"total_tokens": r.Usage.InputTokens + r.Usage.OutputTokens,
},
})
}
// ---------------------------------------------------------------------------
// 响应:Chat → Messages
type chatResp struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
}
// chatToMessagesResp 将 Chat 响应(非流式)转为 Messages 响应。
func chatToMessagesResp(body []byte) ([]byte, error) {
var r chatResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
content := make([]any, 0, 2)
var finish = "end_turn"
if len(r.Choices) > 0 {
msg := r.Choices[0].Message
if msg.Content != "" {
content = append(content, map[string]any{"type": "text", "text": msg.Content})
}
for _, tc := range msg.ToolCalls {
var input any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
finish = chatStopToMessages(r.Choices[0].FinishReason)
}
return json.Marshal(map[string]any{
"id": "msg_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
"type": "message",
"role": "assistant",
"model": r.Model,
"content": content,
"stop_reason": finish,
"usage": map[string]any{
"input_tokens": r.Usage.PromptTokens,
"output_tokens": r.Usage.CompletionTokens,
},
})
}
// ---------------------------------------------------------------------------
// 辅助
// splitDataURL 解析 data:media_type;base64,data 形式的 URL;非该形式返回 ok=false。
func splitDataURL(url string) (media, data string, ok bool) {
if !strings.HasPrefix(url, "data:") {
return "", "", false
}
i := strings.Index(url, ";base64,")
if i < 0 {
return "", "", false
}
return url[len("data:"):i], url[i+len(";base64,"):], true
}
// chatImageBlock 把 Anthropic image 块转 OpenAI image_url 块。
// 仅支持 base64 与 url source;其他类型(如 Files API 的 file_id)不支持,跳过。
func chatImageBlock(b map[string]any) any {
src, ok := b["source"].(map[string]any)
if !ok {
return nil
}
switch src["type"] {
case "base64":
media, _ := src["media_type"].(string)
data, _ := src["data"].(string)
if data == "" {
return nil
}
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + media + ";base64," + data}}
case "url":
url, _ := src["url"].(string)
if url == "" {
return nil
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}
}
return nil
}
// anthropicImageBlock 把 OpenAI image_url 的 url 转 Anthropic image 块。
// data URL → base64 source;http(s) URL → url source。
func anthropicImageBlock(url string) any {
if media, data, ok := splitDataURL(url); ok {
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}}
}
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
}
func messagesStopToChat(s string) string {
switch s {
case "tool_use":
return "tool_calls"
case "max_tokens":
return "length"
default:
return "stop"
}
}
func chatStopToMessages(s string) string {
switch s {
case "tool_calls":
return "tool_use"
case "length":
return "max_tokens"
default:
return "end_turn"
}
}
@@ -0,0 +1,376 @@
package convert
import (
"encoding/json"
"strings"
)
// ---------------------------------------------------------------------------
// 请求:Responses → Chat
// responsesToChatReq 将 OpenAI Responses 请求转为 Chat 请求。
func responsesToChatReq(body []byte) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
out := map[string]any{"model": str(rawJSON(m, "model"))}
if v, ok := m["stream"]; ok && string(v) == "true" {
out["stream"] = true
}
if v, ok := m["temperature"]; ok {
out["temperature"] = v
}
if v, ok := m["top_p"]; ok {
out["top_p"] = v
}
if v, ok := m["max_output_tokens"]; ok {
out["max_tokens"] = v
}
var msgs []any
if ins := str(rawJSON(m, "instructions")); ins != "" {
msgs = append(msgs, map[string]any{"role": "system", "content": ins})
}
msgs = append(msgs, responsesInputToChat(rawJSON(m, "input"))...)
out["messages"] = msgs
if raw := rawJSON(m, "tools"); raw != nil {
var tools []map[string]any
if json.Unmarshal(raw, &tools) == nil {
chatTools := make([]any, 0, len(tools))
for _, t := range tools {
chatTools = append(chatTools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t["name"],
"description": t["description"],
"parameters": t["parameters"],
},
})
}
out["tools"] = chatTools
}
}
return json.Marshal(out)
}
// responsesInputToChat 把 Responses input 转成 Chat messages。
// input 支持字符串或条目数组(message / function_call / function_call_output)。
func responsesInputToChat(raw json.RawMessage) []any {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
// 字符串输入
if s := str(raw); s != "" {
return []any{map[string]any{"role": "user", "content": s}}
}
var items []map[string]any
if err := json.Unmarshal(raw, &items); err != nil || items == nil {
return nil
}
var out []any
for _, item := range items {
switch item["type"] {
case "function_call":
out = append(out, map[string]any{
"role": "assistant",
"content": "",
"tool_calls": []any{map[string]any{
"id": strField(item["call_id"]),
"type": "function",
"function": map[string]any{
"name": strField(item["name"]),
"arguments": strField(item["arguments"]),
},
}},
})
case "function_call_output":
out = append(out, map[string]any{
"role": "tool",
"tool_call_id": strField(item["call_id"]),
"content": strField(item["output"]),
})
default: // message 条目
role, _ := item["role"].(string)
if role == "" {
role = "user"
}
if content, ok := item["content"].(string); ok {
out = append(out, map[string]any{"role": role, "content": content})
} else if blocks, ok := item["content"].([]any); ok {
var text []string
var contentBlocks []any
for _, b := range blocks {
bm, ok := b.(map[string]any)
if !ok {
continue
}
switch bm["type"] {
case "input_text", "text":
if t, _ := bm["text"].(string); t != "" {
text = append(text, t)
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
}
case "input_image":
var url string
if s, ok := bm["image_url"].(string); ok {
url = s
} else if m, ok := bm["image_url"].(map[string]any); ok {
url, _ = m["url"].(string)
}
if url != "" {
contentBlocks = append(contentBlocks, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
}
}
}
hasImage := false
for _, cb := range contentBlocks {
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
hasImage = true
break
}
}
if hasImage {
out = append(out, map[string]any{"role": role, "content": contentBlocks})
} else {
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
}
}
}
}
return out
}
// chatContentToResponsesBlocks 把 Chat 用户消息 content 转 Responses input 块数组(input_text / input_image)。
func chatContentToResponsesBlocks(content json.RawMessage) []any {
// 纯字符串 → 单个 input_text
var s string
if json.Unmarshal(content, &s) == nil && s != "" {
return []any{map[string]any{"type": "input_text", "text": s}}
}
// 数组 → 按块转换(text / image_url)
var arr []map[string]any
if json.Unmarshal(content, &arr) == nil && arr != nil {
var out []any
for _, b := range arr {
switch b["type"] {
case "text", "input_text":
if t, _ := b["text"].(string); t != "" {
out = append(out, map[string]any{"type": "input_text", "text": t})
}
case "image_url":
var url string
if iu, ok := b["image_url"].(map[string]any); ok {
url, _ = iu["url"].(string)
} else if s, ok := b["image_url"].(string); ok {
url = s
}
if url != "" {
out = append(out, map[string]any{"type": "input_image", "image_url": url})
}
}
}
return out
}
return nil
}
// ---------------------------------------------------------------------------
// 请求:Chat → Responses
// chatToResponsesReq 将 Chat 请求转为 Responses 请求。
func chatToResponsesReq(body []byte) ([]byte, error) {
var req chatReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{"model": req.Model}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if req.MaxTokens != nil {
out["max_output_tokens"] = *req.MaxTokens
}
var system []string
var input []any
for _, m := range req.Messages {
if m.Role == "system" {
if s := str(m.Content); s != "" {
system = append(system, s)
}
continue
}
switch m.Role {
case "tool":
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": m.ToolCallID,
"output": str(m.Content),
})
case "assistant":
if len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
input = append(input, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
})
}
} else if s := str(m.Content); s != "" {
input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{
map[string]any{"type": "input_text", "text": s},
}})
}
default:
if blocks := chatContentToResponsesBlocks(m.Content); len(blocks) > 0 {
input = append(input, map[string]any{"type": "message", "role": "user", "content": blocks})
}
}
}
if len(system) > 0 {
out["instructions"] = strings.Join(system, "\n")
}
// input 必须是数组:部分上游只接受数组,单对象会被拒(400 Mismatch type)。
out["input"] = input
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"name": t.Function.Name,
"description": t.Function.Description,
"parameters": rawOrObject(t.Function.Parameters),
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// ---------------------------------------------------------------------------
// 响应:Responses → Chat
// responsesToChatResp 将 Responses 响应(非流式)转为 Chat 响应。
func responsesToChatResp(body []byte) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
var text string
var toolCalls []any
if raw := rawJSON(m, "output"); raw != nil {
var outputs []map[string]any
if json.Unmarshal(raw, &outputs) == nil {
for _, o := range outputs {
switch o["type"] {
case "message":
if content, ok := o["content"].([]any); ok {
for _, c := range content {
if cm, ok := c.(map[string]any); ok {
if t, _ := cm["text"].(string); t != "" {
text += t
}
}
}
}
case "function_call":
toolCalls = append(toolCalls, map[string]any{
"id": strField(o["call_id"]),
"type": "function",
"function": map[string]any{
"name": strField(o["name"]),
"arguments": strField(o["arguments"]),
},
})
}
}
}
}
msg := map[string]any{"role": "assistant", "content": text}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
finish := "stop"
switch {
case string(rawJSON(m, "status")) == `"incomplete"`:
finish = "length" // 截断优先,客户端可据此区分
case len(toolCalls) > 0:
finish = "tool_calls"
}
var prompt, completion int64
if u := rawJSON(m, "usage"); u != nil {
var us struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
_ = json.Unmarshal(u, &us)
prompt, completion = us.InputTokens, us.OutputTokens
}
return json.Marshal(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(str(rawJSON(m, "id")), "resp_"),
"object": "chat.completion",
"model": str(rawJSON(m, "model")),
"choices": []any{map[string]any{"index": 0, "message": msg, "finish_reason": finish}},
"usage": map[string]any{
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
},
})
}
// ---------------------------------------------------------------------------
// 响应:Chat → Responses
// chatToResponsesResp 将 Chat 响应(非流式)转为 Responses 响应。
func chatToResponsesResp(body []byte) ([]byte, error) {
var r chatResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
output := make([]any, 0, 2)
var finish = "completed"
if len(r.Choices) > 0 {
msg := r.Choices[0].Message
if msg.Content != "" {
output = append(output, map[string]any{
"type": "message",
"role": "assistant",
"content": []any{map[string]any{"type": "output_text", "text": msg.Content}},
})
}
for _, tc := range msg.ToolCalls {
output = append(output, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
})
}
if r.Choices[0].FinishReason == "length" {
finish = "incomplete"
}
}
return json.Marshal(map[string]any{
"id": "resp_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
"object": "response",
"model": r.Model,
"status": finish,
"output": output,
"usage": map[string]any{
"input_tokens": r.Usage.PromptTokens,
"output_tokens": r.Usage.CompletionTokens,
"total_tokens": r.Usage.PromptTokens + r.Usage.CompletionTokens,
},
})
}
@@ -0,0 +1,642 @@
package convert
import (
"encoding/json"
"strings"
)
// sseState 记录上一行 event 名。
type sseState struct {
event string
}
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
// data: 后可跟空格(标准)或紧贴 JSON(部分上游会省略空格)。
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
strLine := strings.TrimRight(string(line), "\r\n")
switch {
case strings.HasPrefix(strLine, "event: "):
s.event = strings.TrimSpace(strings.TrimPrefix(strLine, "event: "))
return false, "", false
case strLine == "data: [DONE]" || strLine == "data:[DONE]":
return true, "[DONE]", true
case strings.HasPrefix(strLine, "data:"):
return true, strings.TrimLeft(strings.TrimPrefix(strLine, "data:"), " "), false
default:
return false, "", false
}
}
func eventData(line string) map[string]any {
var m map[string]any
_ = json.Unmarshal([]byte(line), &m)
return m
}
func dataLine(obj any) []byte {
b, _ := json.Marshal(obj)
return append(append([]byte("data: "), b...), '\n', '\n')
}
func eventLine(name string, obj any) []byte {
b, _ := json.Marshal(obj)
out := append([]byte("event: "+name+"\ndata: "), b...)
return append(out, '\n', '\n')
}
// joinLines 拼接多条 SSE 行。
func joinLines(lines [][]byte) []byte {
var s []string
for _, l := range lines {
s = append(s, string(l))
}
return []byte(strings.Join(s, ""))
}
// ---------------------------------------------------------------------------
// Messages → Chat
type messagesToChat struct {
sseState
id, model string
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
nextTool int
}
func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
func (t *messagesToChat) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
return []byte("data: [DONE]\n\n")
}
m := eventData(data)
evt, _ := m["type"].(string)
switch evt {
case "message_start":
msg, _ := m["message"].(map[string]any)
t.id, _ = msg["id"].(string)
t.model, _ = msg["model"].(string)
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
})
case "content_block_start":
cb, _ := m["content_block"].(map[string]any)
if cb == nil || cb["type"] != "tool_use" {
return nil
}
blockIdx, _ := m["index"].(float64)
tool := t.nextTool
t.nextTool++
t.toolIdx[int(blockIdx)] = tool
toolID, _ := cb["id"].(string)
name, _ := cb["name"].(string)
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
"tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}},
}, "finish_reason": nil}},
})
case "content_block_delta":
delta, _ := m["delta"].(map[string]any)
deltaType, _ := delta["type"].(string)
if deltaType == "input_json_delta" {
blockIdx, _ := m["index"].(float64)
tool, ok := t.toolIdx[int(blockIdx)]
if !ok {
return nil
}
partial, _ := delta["partial_json"].(string)
if partial == "" {
return nil
}
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
"tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}},
}, "finish_reason": nil}},
})
}
text, _ := delta["text"].(string)
if text == "" {
return nil
}
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": text}, "finish_reason": nil}},
})
case "message_delta":
delta, _ := m["delta"].(map[string]any)
stop, _ := delta["stop_reason"].(string)
var out [][]byte
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": messagesStopToChat(stop)}},
}))
if u, ok := m["usage"]; ok {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{}, "usage": u,
}))
}
return joinLines(out)
case "message_stop":
return []byte("data: [DONE]\n\n")
}
return nil
}
// ---------------------------------------------------------------------------
// Chat → Messages
type chatToMessages struct {
sseState
started bool // message_start 已发出
nextIndex int // 下一个 content block index(顺序分配)
textIndex int // 文本块 index;-1 = 未开始
toolIdx map[int]int // chat delta.tool_calls[].index → messages block index
openBlocks []int // 已开始未停止的 block index,按开始顺序
model string
stopReason string
usage any
}
func newChatToMessages() *chatToMessages {
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
}
func (t *chatToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
// 汇聚最终:先对每个已开始未停止的块发 content_block_stop,再 message_delta + message_stop
var out [][]byte
for _, idx := range t.openBlocks {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
}
md := map[string]any{"type": "message_delta", "delta": map[string]any{
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
}}
if t.usage != nil {
md["usage"] = t.usage
}
out = append(out, eventLine("message_delta", md))
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
return joinLines(out)
}
m := eventData(data)
// chat 块:delta / finish_reason 在 choices[0] 内
delta := map[string]any{}
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
if c0, ok := choices[0].(map[string]any); ok {
if d, ok := c0["delta"].(map[string]any); ok {
delta = d
}
if fr, _ := c0["finish_reason"].(string); fr != "" {
t.stopReason = fr
}
}
}
if t.model == "" {
t.model, _ = m["model"].(string)
}
id, _ := m["id"].(string)
var out [][]byte
// message_start 只在实际有内容(文本或工具)时发出,避免 reasoning_content 块
//(带 role 无 content)提前开出一个空文本块。
ensureStarted := func() {
if t.started {
return
}
t.started = true
out = append(out, eventLine("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
},
}))
}
// 文本:delta.content(string;兼容 {type:text,text} 数组)
if content := deltaText(delta); content != "" {
if t.textIndex < 0 {
t.textIndex = t.nextIndex
t.nextIndex++
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.openBlocks = append(t.openBlocks, t.textIndex)
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content},
}))
}
// 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式)
if tcs, ok := delta["tool_calls"].([]any); ok {
for _, tc := range tcs {
call, ok := tc.(map[string]any)
if !ok {
continue
}
idx, _ := call["index"].(float64)
tcIdx := int(idx)
fn, _ := call["function"].(map[string]any)
name, _ := fn["name"].(string)
args, _ := fn["arguments"].(string)
blockIdx, seen := t.toolIdx[tcIdx]
if !seen {
blockIdx = t.nextIndex
t.nextIndex++
t.toolIdx[tcIdx] = blockIdx
toolID, _ := call["id"].(string)
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
"type": "tool_use", "id": toolID, "name": name, "input": map[string]any{},
},
}))
t.openBlocks = append(t.openBlocks, blockIdx)
}
if args != "" {
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args},
}))
}
}
}
if u, ok := m["usage"]; ok {
t.usage = u
}
return joinLines(out)
}
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
func deltaText(delta map[string]any) string {
if s, ok := delta["content"].(string); ok {
return s
}
if arr, ok := delta["content"].([]any); ok {
var parts []string
for _, b := range arr {
if bm, ok := b.(map[string]any); ok {
if t, _ := bm["text"].(string); t != "" {
parts = append(parts, t)
}
}
}
return strings.Join(parts, "")
}
return ""
}
func stopReasonOrEnd(s string) string {
if s == "" {
return "end_turn"
}
return chatStopToMessages(s)
}
// ---------------------------------------------------------------------------
// Responses → Messages
type responsesToMessages struct {
sseState
started bool
model string
usage any
nextIndex int // 下一个 content block index(顺序分配)
textIndex int // 文本块 index;-1 = 未开始
toolIdx map[string]int // function_call item_id → messages block index
openBlocks []int // 已开始未停止的 block index,按开始顺序
anyTool bool
}
func newResponsesToMessages() *responsesToMessages {
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
}
func (t *responsesToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData || done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if resp, ok := m["response"].(map[string]any); ok {
if t.model == "" {
t.model, _ = resp["model"].(string)
}
if u, ok := resp["usage"]; ok {
t.usage = u
}
}
var out [][]byte
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
// 避免纯函数调用响应提前开出一个空文本块。
ensureStarted := func() {
if t.started {
return
}
t.started = true
rid := ""
if resp, ok := m["response"].(map[string]any); ok {
rid, _ = resp["id"].(string)
}
out = append(out, eventLine("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
"model": t.model, "content": []any{},
},
}))
}
switch evt {
case "response.created":
ensureStarted()
case "response.output_text.delta":
delta, _ := m["delta"].(string)
if delta == "" {
return nil
}
if t.textIndex < 0 {
t.textIndex = t.nextIndex
t.nextIndex++
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.openBlocks = append(t.openBlocks, t.textIndex)
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta},
}))
case "response.output_item.added":
item, _ := m["item"].(map[string]any)
if item == nil || item["type"] != "function_call" {
return nil
}
blockIdx := t.nextIndex
t.nextIndex++
t.anyTool = true
itemID, _ := item["id"].(string)
t.toolIdx[itemID] = blockIdx
toolUseID, _ := item["call_id"].(string)
if toolUseID == "" {
toolUseID = itemID
}
name, _ := item["name"].(string)
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
"type": "tool_use", "id": toolUseID, "name": name, "input": map[string]any{},
},
}))
t.openBlocks = append(t.openBlocks, blockIdx)
case "response.function_call_arguments.delta":
itemID, _ := m["item_id"].(string)
blockIdx, ok := t.toolIdx[itemID]
if !ok {
return nil
}
delta, _ := m["delta"].(string)
if delta == "" {
return nil
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": delta},
}))
case "response.completed":
for _, idx := range t.openBlocks {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
}
stop := "end_turn"
if t.anyTool {
stop = "tool_use"
}
md := map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}}
if t.usage != nil {
md["usage"] = t.usage
}
out = append(out, eventLine("message_delta", md))
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Messages → Responses
type messagesToResponses struct {
sseState
model string
usage any
done bool
}
func newMessagesToResponses() *messagesToResponses { return &messagesToResponses{} }
func (t *messagesToResponses) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData || done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if msg, ok := m["message"].(map[string]any); ok {
if t.model == "" {
t.model, _ = msg["model"].(string)
}
if u, ok := msg["usage"]; ok {
t.usage = u
}
}
if u, ok := m["usage"]; ok {
t.usage = u
}
var out [][]byte
switch evt {
case "message_start":
id, _ := m["message"].(map[string]any)
rid := ""
if id != nil {
rid, _ = id["id"].(string)
}
out = append(out, eventLine("response.created", map[string]any{
"type": "response.created",
"response": map[string]any{
"id": "resp_" + strings.TrimPrefix(rid, "msg_"), "object": "response", "model": t.model, "status": "in_progress",
},
}))
case "content_block_delta":
delta, _ := m["delta"].(map[string]any)
text, _ := delta["text"].(string)
if text != "" {
out = append(out, eventLine("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "delta": text, "item_id": "msg_1", "output_index": 0, "content_index": 0,
}))
}
case "message_stop":
if !t.done {
t.done = true
out = append(out, eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
}))
}
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Responses → Chat
type responsesToChat struct {
sseState
id, model string
}
func newResponsesToChat() *responsesToChat { return &responsesToChat{} }
func (t *responsesToChat) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if resp, ok := m["response"].(map[string]any); ok {
if t.model == "" {
t.model, _ = resp["model"].(string)
}
if t.id == "" {
t.id, _ = resp["id"].(string)
}
}
var out [][]byte
switch evt {
case "response.created":
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
}))
case "response.output_text.delta":
delta, _ := m["delta"].(string)
if delta != "" {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": delta}, "finish_reason": nil}},
}))
}
case "response.completed":
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}},
}))
if u, ok := m["response"].(map[string]any); ok {
if usage, ok := u["usage"]; ok {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{}, "usage": usage,
}))
}
}
out = append(out, []byte("data: [DONE]\n\n"))
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Chat → Responses
type chatToResponses struct {
sseState
model string
usage any
finishSeen bool
done bool
createdSent bool
}
func newChatToResponses() *chatToResponses { return &chatToResponses{} }
func (t *chatToResponses) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
// 流结束兜底:finish 后 usage 未随块到达时在此补发 completed
if !t.done {
t.done = true
return eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
})
}
return nil
}
m := eventData(data)
if t.model == "" {
t.model, _ = m["model"].(string)
}
if u, ok := m["usage"]; ok {
t.usage = u
}
delta := map[string]any{}
var finish string
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
if c0, ok := choices[0].(map[string]any); ok {
if d, ok := c0["delta"].(map[string]any); ok {
delta = d
}
finish, _ = c0["finish_reason"].(string)
}
}
if finish != "" {
t.finishSeen = true
}
var out [][]byte
// 只发一次 response.created:部分上游(如 OpenRouter 的 reasoning 模型)会在
// 每个 chunk 的 delta 里都带 role:"assistant",不加守卫会刷出数十条 created。
if !t.createdSent && delta["role"] == "assistant" {
t.createdSent = true
out = append(out, eventLine("response.created", map[string]any{
"type": "response.created",
"response": map[string]any{"id": "resp_stream", "object": "response", "model": t.model, "status": "in_progress"},
}))
}
if content, _ := delta["content"].(string); content != "" {
out = append(out, eventLine("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0,
}))
}
// 上游 usage 块(choices 为空)通常晚于 finish_reason:此时再发 completed,携带 usage
if _, hasUsage := m["usage"]; hasUsage && t.finishSeen && !t.done {
t.done = true
out = append(out, eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
}))
}
return joinLines(out)
}
+131
View File
@@ -0,0 +1,131 @@
package convert
import (
"encoding/json"
)
// TokenUsage 从上游响应提取的 token 用量。
// 三种协议的字段名不同,此处统一为:input / output / cache_read / cache_creation,
// 供用量记录与计费使用。
type TokenUsage struct {
InputTokens int
OutputTokens int
CacheReadTokens int
CacheCreationTokens int
}
// has 判断是否真的拿到了非零用量(过滤掉没有 usage 字段的响应)。
func (u *TokenUsage) has() bool {
return u.InputTokens > 0 || u.OutputTokens > 0 ||
u.CacheReadTokens > 0 || u.CacheCreationTokens > 0
}
// mergeJSON 把一张 usage 对象并入累计值。proto 决定字段名(chat/responses 与 messages 不同)。
func (u *TokenUsage) mergeJSON(raw map[string]any, proto string) {
switch proto {
case ProtoChat, ProtoResponses:
in, _ := raw["prompt_tokens"].(float64)
out, _ := raw["completion_tokens"].(float64)
if in == 0 && out == 0 {
in, _ = raw["input_tokens"].(float64)
out, _ = raw["output_tokens"].(float64)
}
u.InputTokens += int(in)
u.OutputTokens += int(out)
if d, ok := raw["prompt_tokens_details"].(map[string]any); ok {
if c, _ := d["cached_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
}
if d, ok := raw["input_tokens_details"].(map[string]any); ok {
if c, _ := d["cached_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
}
case ProtoMessages:
in, _ := raw["input_tokens"].(float64)
out, _ := raw["output_tokens"].(float64)
u.InputTokens += int(in)
u.OutputTokens += int(out)
if c, _ := raw["cache_read_input_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
if c, _ := raw["cache_creation_input_tokens"].(float64); c > 0 {
u.CacheCreationTokens += int(c)
}
}
}
// ExtractUsageJSON 从完整非流式响应体中提取用量。proto 为上游协议。
// 返回 (用量, 是否有效)。
func ExtractUsageJSON(body []byte, proto string) (TokenUsage, bool) {
var top map[string]any
if err := json.Unmarshal(body, &top); err != nil {
return TokenUsage{}, false
}
var u TokenUsage
if usage, ok := top["usage"].(map[string]any); ok {
u.mergeJSON(usage, proto)
}
return u, u.has()
}
// StreamUsageAccum 流式用量累计器。逐行喂入上游 SSE 的 data 载荷,
// 按协议分别取各事件里的 usage 字段(各事件只会携带一部分字段,取最大值合并)。
type StreamUsageAccum struct {
u TokenUsage
}
// NewStreamUsageAccum 创建一个流式用量累计器。
func NewStreamUsageAccum() *StreamUsageAccum {
return &StreamUsageAccum{}
}
// Feed 喂入一行 SSE data 载荷(不含 "data:" 前缀与换行)。
func (a *StreamUsageAccum) Feed(payload []byte, proto string) {
var top map[string]any
if json.Unmarshal(payload, &top) != nil {
return
}
var t TokenUsage
switch proto {
case ProtoChat:
if usage, ok := top["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
case ProtoResponses:
// response.completed 事件把用量放在 response.usage 下。
if resp, ok := top["response"].(map[string]any); ok {
if usage, ok := resp["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
}
case ProtoMessages:
// message_start: {message: {usage: {input_tokens, cache_*}}}
// message_delta: {usage: {output_tokens}}
if msg, ok := top["message"].(map[string]any); ok {
if usage, ok := msg["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
}
if usage, ok := top["usage"].(map[string]any); ok {
var t2 TokenUsage
t2.mergeJSON(usage, proto)
t.InputTokens = max(t.InputTokens, t2.InputTokens)
t.OutputTokens = max(t.OutputTokens, t2.OutputTokens)
t.CacheReadTokens = max(t.CacheReadTokens, t2.CacheReadTokens)
t.CacheCreationTokens = max(t.CacheCreationTokens, t2.CacheCreationTokens)
}
default:
return
}
a.u.InputTokens = max(a.u.InputTokens, t.InputTokens)
a.u.OutputTokens = max(a.u.OutputTokens, t.OutputTokens)
a.u.CacheReadTokens = max(a.u.CacheReadTokens, t.CacheReadTokens)
a.u.CacheCreationTokens = max(a.u.CacheCreationTokens, t.CacheCreationTokens)
}
// Usage 返回当前累计用量。
func (a *StreamUsageAccum) Usage() TokenUsage {
return a.u
}
@@ -0,0 +1,170 @@
package convert
import (
"encoding/json"
"testing"
)
// ---- ExtractUsageJSON: 非流式各协议 ----
func TestExtractUsageJSONChat(t *testing.T) {
body := []byte(`{
"id": "chatcmpl-1",
"object": "chat.completion",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}}],
"usage": {
"prompt_tokens": 11,
"completion_tokens": 7,
"total_tokens": 18,
"prompt_tokens_details": {"cached_tokens": 4}
}
}`)
u, ok := ExtractUsageJSON(body, ProtoChat)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 11 || u.OutputTokens != 7 {
t.Fatalf("chat usage = %+v, want input=11 output=7", u)
}
if u.CacheReadTokens != 4 {
t.Fatalf("chat cacheRead = %d, want 4", u.CacheReadTokens)
}
}
func TestExtractUsageJSONMessages(t *testing.T) {
body := []byte(`{
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "hi"}],
"usage": {
"input_tokens": 15,
"output_tokens": 8,
"cache_read_input_tokens": 3,
"cache_creation_input_tokens": 2
}
}`)
u, ok := ExtractUsageJSON(body, ProtoMessages)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
t.Fatalf("messages usage = %+v", u)
}
}
func TestExtractUsageJSONResponses(t *testing.T) {
body := []byte(`{
"id": "resp_1",
"object": "response",
"output": [],
"usage": {
"input_tokens": 13,
"output_tokens": 9,
"input_tokens_details": {"cached_tokens": 5}
}
}`)
u, ok := ExtractUsageJSON(body, ProtoResponses)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
t.Fatalf("responses usage = %+v", u)
}
}
func TestExtractUsageJSONInvalidAndMissing(t *testing.T) {
if _, ok := ExtractUsageJSON([]byte("not json"), ProtoChat); ok {
t.Fatalf("invalid json should not report ok")
}
if _, ok := ExtractUsageJSON([]byte(`{"id": "x"}`), ProtoChat); ok {
t.Fatalf("missing usage should not report ok")
}
// 空对象 usage:全 0 视为无效
if _, ok := ExtractUsageJSON([]byte(`{"usage": {}}`), ProtoChat); ok {
t.Fatalf("empty usage should not report ok")
}
}
// ---- StreamUsageAccum: 流式各协议 ----
func feedLines(t *testing.T, proto string, lines ...string) TokenUsage {
t.Helper()
acc := NewStreamUsageAccum()
for _, ln := range lines {
acc.Feed([]byte(ln), proto)
}
return acc.Usage()
}
func TestStreamUsageChatFinalChunk(t *testing.T) {
// 前面的 chunk 不带 usage;最后一个 chunk 带完整 usage
u := feedLines(t, ProtoChat,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"llo"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":4}}}`,
)
if u.InputTokens != 11 || u.OutputTokens != 7 || u.CacheReadTokens != 4 {
t.Fatalf("chat stream usage = %+v", u)
}
}
func TestStreamUsageMessagesStartAndDelta(t *testing.T) {
// message_start 带 input/cache,message_delta 带 output;逐字段取 max 合并
u := feedLines(t, ProtoMessages,
`{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":15,"cache_read_input_tokens":3,"cache_creation_input_tokens":2}}}`,
`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}`,
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}}`,
)
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
t.Fatalf("messages stream usage = %+v", u)
}
}
func TestStreamUsageResponsesCompleted(t *testing.T) {
// response.completed 事件的用量嵌在 response.usage 下
u := feedLines(t, ProtoResponses,
`{"type":"response.output_text.delta","delta":"hi"}`,
`{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":13,"output_tokens":9,"input_tokens_details":{"cached_tokens":5}}}}`,
)
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
t.Fatalf("responses stream usage = %+v", u)
}
}
func TestStreamUsageIgnoresNonDataPayloads(t *testing.T) {
// [DONE]、垃圾行、空对象都不应产生用量
u := feedLines(t, ProtoChat, `[DONE]`, `{`, ``, `{"choices":[]}`)
if u.has() {
t.Fatalf("expected zero usage, got %+v", u)
}
}
func TestStreamUsageFeedKeepsMaxAcrossEvents(t *testing.T) {
// 同一字段在多个事件出现时取较大值(防乱序/重复)
u := feedLines(t, ProtoMessages,
`{"type":"message_start","message":{"usage":{"input_tokens":15}}}`,
`{"type":"message_delta","usage":{"output_tokens":5}}`,
`{"type":"message_delta","usage":{"output_tokens":8}}`,
)
if u.InputTokens != 15 || u.OutputTokens != 8 {
t.Fatalf("max-merge usage = %+v", u)
}
}
// ---- usage JSON 结构合法性(防止手写 struct 漂移)----
func TestUsageJSONRoundTrip(t *testing.T) {
u := TokenUsage{InputTokens: 10, OutputTokens: 5, CacheReadTokens: 2, CacheCreationTokens: 1}
b, err := json.Marshal(u)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back TokenUsage
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if back != u {
t.Fatalf("round trip = %+v, want %+v", back, u)
}
}
+96 -101
View File
@@ -1,6 +1,7 @@
package proxy package proxy
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@@ -13,6 +14,7 @@ import (
"opencatd-open/internal/dao" "opencatd-open/internal/dao"
"opencatd-open/internal/proxy/convert" "opencatd-open/internal/proxy/convert"
"opencatd-open/internal/store" "opencatd-open/internal/store"
"opencatd-open/internal/usage"
"opencatd-open/pkg/config" "opencatd-open/pkg/config"
"os" "os"
"strings" "strings"
@@ -35,6 +37,7 @@ type Gateway struct {
usageDAO *dao.UsageDAO usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO dailyDAO *dao.DailyUsageDAO
channelSvc *channel.Service channelSvc *channel.Service
usageRec *usage.Recorder
} }
func NewGateway(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Gateway { func NewGateway(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Gateway {
@@ -67,6 +70,11 @@ func (g *Gateway) SetChannelService(svc *channel.Service) {
g.channelSvc = svc g.channelSvc = svc
} }
// SetUsageRecorder 注入异步用量记录器;nil 时网关跳过用量上报。
func (g *Gateway) SetUsageRecorder(r *usage.Recorder) {
g.usageRec = r
}
// Request represents a parsed incoming request // Request represents a parsed incoming request
type Request struct { type Request struct {
Model string Model string
@@ -144,26 +152,24 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
return return
} }
// Determine target format and convert if needed // Determine target format: channel declares support for the client protocol
targetFormat := req.Protocol // then passthrough, otherwise convert to its first supported protocol
if len(ch.FormatsEffective()) > 0 { // (chat > messages > responses).
// Prefer the channel's native format targetFormat := g.conversionTarget(ch, req.Protocol)
for _, f := range ch.FormatsEffective() { if targetFormat == "" {
if f == req.Protocol { g.writeError(c, http.StatusBadGateway, fmt.Sprintf("channel %q declares no supported protocol format", ch.Name))
targetFormat = f return
break
}
}
} }
// Build upstream URL // Build upstream URL
upstreamPath := g.getUpstreamPath(req.Protocol) upstreamPath := g.getUpstreamPath(targetFormat)
upstreamURL := ch.UpstreamURL(req.Protocol, upstreamPath) upstreamURL := ch.UpstreamURL(targetFormat, upstreamPath)
// Convert request if needed // Convert request if needed
var requestBody []byte var requestBody []byte
if targetFormat != req.Protocol { if targetFormat != req.Protocol {
requestBody, err = g.convertRequest(req.Body, req.Protocol, targetFormat) var err error
requestBody, err = convert.ConvertRequest(req.Body, req.Protocol, targetFormat)
if err != nil { if err != nil {
g.writeError(c, http.StatusBadRequest, "conversion failed: "+err.Error()) g.writeError(c, http.StatusBadRequest, "conversion failed: "+err.Error())
return return
@@ -206,12 +212,31 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
// Stream or buffer response // Stream or buffer response
if req.Stream { if req.Stream {
g.streamResponse(c, resp, req.Protocol, ch) g.streamResponse(c, resp, req.Protocol, targetFormat)
} else { } else {
g.bufferResponse(c, resp, req.Protocol, ch) g.bufferResponse(c, resp, req.Protocol, targetFormat)
} }
} }
// conversionTarget 决定客户端协议在渠道上的处理方式:
// 渠道声明支持该协议则直通;否则转为其首选支持协议(chat > messages > responses)。
func (g *Gateway) conversionTarget(ch *store.Channel, clientProto string) string {
formats := ch.FormatsEffective()
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 ""
}
func (g *Gateway) getUpstreamPath(protocol string) string { func (g *Gateway) getUpstreamPath(protocol string) string {
switch protocol { switch protocol {
case "chat": case "chat":
@@ -237,116 +262,86 @@ func (g *Gateway) setHeaders(req *http.Request, ch *store.Channel, apiKey string
} }
} }
func (g *Gateway) convertRequest(body []byte, from, to string) ([]byte, error) {
switch {
case from == "chat" && to == "messages":
var req convert.ChatCompletionRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
msgReq, err := convert.ChatToMessages(&req)
if err != nil {
return nil, err
}
return json.Marshal(msgReq)
case from == "chat" && to == "responses": // streamResponse 流式响应:按 \n\n 分块零缓冲转发;跨协议时逐行转换。
var req convert.ChatCompletionRequest func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string) {
if err := json.Unmarshal(body, &req); err != nil { w := c.Writer
return nil, err
}
respReq, err := convert.ChatToResponses(&req)
if err != nil {
return nil, err
}
return json.Marshal(respReq)
case from == "messages" && to == "chat":
var req convert.MessagesRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
// Messages -> Chat: we need to construct a ChatCompletionRequest
chatReq := &convert.ChatCompletionRequest{
Model: req.Model,
}
for _, m := range req.Messages {
chatReq.Messages = append(chatReq.Messages, m)
}
if req.Temperature != nil {
chatReq.Temperature = req.Temperature
}
if req.TopP != nil {
chatReq.TopP = req.TopP
}
chatReq.Tools = req.Tools
chatReq.Stream = req.Stream
return json.Marshal(chatReq)
case from == "responses" && to == "chat":
var req convert.ResponsesRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
chatReq := &convert.ChatCompletionRequest{
Model: req.Model,
}
for _, item := range req.Input {
chatReq.Messages = append(chatReq.Messages, convert.Message{
Role: item.Role,
Content: item.Content,
})
}
chatReq.Tools = req.Tools
chatReq.Stream = req.Stream
return json.Marshal(chatReq)
default:
return body, nil
}
}
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive") c.Header("Connection", "keep-alive")
c.Status(http.StatusOK) c.Status(http.StatusOK)
writer := convert.NewSSEWriter(c.Writer) flusher, _ := w.(http.Flusher)
parser := convert.NewSSEParser(resp.Body)
// 跨协议时按行转换;同协议直通(lineConv 为 nil)。
var lineConv func([]byte) []byte
if upstreamProto != clientProto {
lineConv = convert.NewStreamTransformer(upstreamProto, clientProto)
}
// 上游原始行按 \n\n 分块,避免把 data 行内的转义换行当成事件边界。
r := bufio.NewReaderSize(resp.Body, 32*1024)
for { for {
event, err := parser.ReadEvent() buf := []byte{}
if err != nil { for {
line, err := r.ReadSlice('\n')
if err == bufio.ErrBufferFull {
buf = append(buf, line...)
continue
}
buf = append(buf, line...)
if err == io.EOF { if err == io.EOF {
break if len(buf) == 0 {
return
} }
log.Printf("Stream parse error: %v", err) if !bytes.HasSuffix(buf, []byte("\n")) {
break buf = append(buf, '\n')
} }
} else if err != nil {
if event.Event == "error" { log.Printf("stream read error: %v", err)
log.Printf("Upstream stream error: %s", event.Data) return
break
} }
if len(buf) >= 2 && bytes.HasSuffix(buf, []byte("\n\n")) {
// Write raw SSE event based on protocol
if err := writer.WriteEvent("chat CompletionChunk", event.Data); err != nil {
break break
} }
} }
writer.WriteDone() out := buf
if lineConv != nil {
out = lineConv(buf)
}
if len(out) == 0 {
continue
}
if _, err := w.Write(out); err != nil {
return // 客户端已断开
}
if flusher != nil {
flusher.Flush()
}
}
} }
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) { func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string) {
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
g.writeError(c, http.StatusBadGateway, "failed to read response") g.writeError(c, http.StatusBadGateway, "failed to read response")
return return
} }
c.Data(resp.StatusCode, "application/json", body) out := body
if upstreamProto != clientProto {
if converted, cerr := convert.ConvertResponse(body, upstreamProto, clientProto); cerr == nil {
out = converted
} else {
// 转换失败时至少剥掉非 JSON 前缀,让客户端能解析出正文
out = convert.CleanJSON(body)
}
} else {
// 直通:部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释
out = convert.CleanJSON(body)
}
c.Data(resp.StatusCode, "application/json", out)
} }
func (g *Gateway) writeError(c *gin.Context, status int, message string) { func (g *Gateway) writeError(c *gin.Context, status int, message string) {
+61 -1
View File
@@ -2,6 +2,7 @@ package usage
import ( import (
"context" "context"
"fmt"
"log" "log"
"opencatd-open/internal/dao" "opencatd-open/internal/dao"
"opencatd-open/internal/store" "opencatd-open/internal/store"
@@ -17,10 +18,20 @@ type Event struct {
PromptTokens int PromptTokens int
CompletionTokens int CompletionTokens int
CacheReadTokens int CacheReadTokens int
CacheCreationTokens int
Cost float64 Cost float64
IsError bool IsError bool
IsCanceled bool IsCanceled bool
RequestID string RequestID string
KeyID uint64
Protocol string
ErrorCode string
LatencyMS int
InputPrice float64
OutputPrice float64
CacheReadPrice float64
TraceID string // TraceID for distributed tracing
ModelID uint64 // Model ID from channel-model binding
} }
// Recorder handles async usage recording // Recorder handles async usage recording
@@ -117,16 +128,31 @@ func (r *Recorder) flush(events []Event) {
status = store.UsageStatusCanceled status = store.UsageStatusCanceled
} }
var errCode *string
if e.ErrorCode != "" {
errCode = &e.ErrorCode
}
log := &store.UsageLog{ log := &store.UsageLog{
UserID: e.UserID, UserID: e.UserID,
ModelName: e.ModelName, KeyID: e.KeyID,
ChannelID: e.ChannelID, ChannelID: e.ChannelID,
ModelID: e.ModelID,
ModelName: e.ModelName,
Protocol: e.Protocol,
InputTokens: int64(e.PromptTokens), InputTokens: int64(e.PromptTokens),
OutputTokens: int64(e.CompletionTokens), OutputTokens: int64(e.CompletionTokens),
CacheReadTokens: int64(e.CacheReadTokens), CacheReadTokens: int64(e.CacheReadTokens),
CacheCreationTokens: int64(e.CacheCreationTokens),
InputPrice: e.InputPrice,
OutputPrice: e.OutputPrice,
CacheReadPrice: e.CacheReadPrice,
Cost: e.Cost, Cost: e.Cost,
LatencyMS: e.LatencyMS,
Status: status, Status: status,
ErrorCode: errCode,
RequestID: e.RequestID, RequestID: e.RequestID,
TraceID: e.TraceID,
} }
logs = append(logs, log) logs = append(logs, log)
} }
@@ -136,5 +162,39 @@ func (r *Recorder) flush(events []Event) {
log.Printf("Failed to batch create usage logs: %v", err) log.Printf("Failed to batch create usage logs: %v", err)
} }
// Daily rollup for success and canceled requests
dailyMap := make(map[string]*store.UsageDaily)
for _, e := range events {
if e.IsError {
continue
}
date := time.Now().Format("2006-01-02")
key := fmt.Sprintf("%d:%d:%s", e.UserID, e.ModelID, date)
d := dailyMap[key]
if d == nil {
d = &store.UsageDaily{
UserID: e.UserID,
ModelID: e.ModelID,
Date: date,
Requests: 0,
InputTokens: 0,
OutputTokens: 0,
CacheReadTokens: 0,
Cost: 0,
}
dailyMap[key] = d
}
d.Requests++
d.InputTokens += int64(e.PromptTokens)
d.OutputTokens += int64(e.CompletionTokens)
d.CacheReadTokens += int64(e.CacheReadTokens)
d.Cost += e.Cost
}
for _, d := range dailyMap {
if err := r.dailyDAO.UpsertDailyUsage(context.Background(), d); err != nil {
log.Printf("Failed to upsert daily usage: %v", err)
}
}
log.Printf("Flushed %d usage logs", len(logs)) log.Printf("Flushed %d usage logs", len(logs))
} }
+31 -33
View File
@@ -9,45 +9,34 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// keyPrefixLen 是 key_prefix 列的截断长度,必须与 api.go:459 的 keyValue[:12] 一致。
// 真实 key 为 sk-ot- + 48 位 hex(54 字符),故 12 位足够唯一。
const keyPrefixLen = 12
func AuthLLM(db *gorm.DB) gin.HandlerFunc { func AuthLLM(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
authToken := c.GetHeader("Authorization") key := extractAPIKey(c.GetHeader("Authorization"))
if authToken == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ // 区分「没传」和「传了但不对」,便于排查客户端配置。
"error": map[string]interface{}{ if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
"message": "未提供认证信息", unauthorized(c, "未提供认证信息")
"type": "invalid_request_error", return
}, }
}) // 长度不足时直接拒绝:避免下方 authToken[:12] 越界 panic 打崩进程。
if len(key) < keyPrefixLen {
unauthorized(c, "无效的API密钥")
return return
} }
// Extract API key from Bearer token
if len(authToken) > 7 {
authToken = authToken[7:]
}
// Find API key by prefix
var apiKey store.APIKey var apiKey store.APIKey
if err := db.Where("key_prefix = ? AND status = ?", authToken[:8], store.KeyStatusActive).First(&apiKey).Error; err != nil { if err := db.Where("key_prefix = ? AND status = ?", key[:keyPrefixLen], store.KeyStatusActive).First(&apiKey).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ unauthorized(c, "无效的API密钥")
"error": map[string]interface{}{
"message": "无效的API密钥",
"type": "invalid_request_error",
},
})
return return
} }
// Verify full key hash // Verify full key hash
keyHash := store.HashAPIKey(authToken) if apiKey.KeyHash != store.HashAPIKey(key) {
if apiKey.KeyHash != keyHash { unauthorized(c, "无效的API密钥")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": map[string]interface{}{
"message": "无效的API密钥",
"type": "invalid_request_error",
},
})
return return
} }
@@ -57,11 +46,20 @@ func AuthLLM(db *gorm.DB) gin.HandlerFunc {
} }
} }
// extractAPIKey extracts the API key from the Authorization header // extractAPIKey 从 Authorization 头取 Bearer token,兼容无 "Bearer " 前缀的直传。
func extractAPIKey(c *gin.Context) string { func extractAPIKey(auth string) string {
auth := c.GetHeader("Authorization") auth = strings.TrimSpace(auth)
if strings.HasPrefix(auth, "Bearer ") { if strings.HasPrefix(auth, "Bearer ") {
return auth[7:] return strings.TrimSpace(auth[len("Bearer "):])
} }
return auth return auth
} }
func unauthorized(c *gin.Context, message string) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": map[string]interface{}{
"message": message,
"type": "invalid_request_error",
},
})
}
+33 -4
View File
@@ -50,7 +50,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
// Initialize health checker and start periodic checks // Initialize health checker and start periodic checks
healthChecker := channel.NewHealthChecker(channelDAO, channelSvc) healthChecker := channel.NewHealthChecker(channelDAO, channelSvc)
go healthChecker.StartPeriodicCheck(ctx, 5*time.Minute) go healthChecker.StartPeriodicCheck(ctx)
// Initialize usage recorder and start background worker // Initialize usage recorder and start background worker
usageRecorder := usage.NewRecorder(usageDAO, dailyDAO) usageRecorder := usage.NewRecorder(usageDAO, dailyDAO)
@@ -60,7 +60,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
// Initialize gateway // Initialize gateway
gateway := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO) gateway := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
gateway.SetChannelService(channelSvc) gateway.SetChannelService(channelSvc)
gateway.SetUsageRecorder(usageRecorder)
// Initialize API handler // Initialize API handler
apiHandler := api.NewHandler(db) apiHandler := api.NewHandler(db)
@@ -99,7 +99,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey) apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey)
apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys) apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys)
// Channel management // Channel management (legacy endpoints)
apiGroup.GET("/channels", apiHandler.ListChannels) apiGroup.GET("/channels", apiHandler.ListChannels)
apiGroup.POST("/channels", apiHandler.CreateChannel) apiGroup.POST("/channels", apiHandler.CreateChannel)
apiGroup.PUT("/channels/:id", apiHandler.UpdateChannel) apiGroup.PUT("/channels/:id", apiHandler.UpdateChannel)
@@ -107,11 +107,40 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
apiGroup.GET("/channels/:id/models", apiHandler.GetChannelModels) apiGroup.GET("/channels/:id/models", apiHandler.GetChannelModels)
apiGroup.POST("/channels/:id/models", apiHandler.BindChannelModels) apiGroup.POST("/channels/:id/models", apiHandler.BindChannelModels)
// Model management // Model management (legacy endpoints)
apiGroup.GET("/models", apiHandler.ListModels) apiGroup.GET("/models", apiHandler.ListModels)
apiGroup.POST("/models", apiHandler.CreateModel) apiGroup.POST("/models", apiHandler.CreateModel)
apiGroup.PUT("/models/:id", apiHandler.UpdateModel) apiGroup.PUT("/models/:id", apiHandler.UpdateModel)
apiGroup.DELETE("/models/:id", apiHandler.DeleteModel) apiGroup.DELETE("/models/:id", apiHandler.DeleteModel)
// Admin channel management (enhanced)
apiGroup.GET("/admin/channels", apiHandler.AdminChannels)
apiGroup.POST("/admin/channels", apiHandler.AdminCreateChannel)
apiGroup.PUT("/admin/channels/:id", apiHandler.AdminUpdateChannel)
apiGroup.DELETE("/admin/channels/:id", apiHandler.AdminDeleteChannel)
apiGroup.POST("/admin/channels/:id/test", apiHandler.AdminTestChannel)
apiGroup.GET("/admin/channels/:id/models/remote", apiHandler.AdminChannelRemoteModels)
apiGroup.GET("/admin/channels/:id/models", apiHandler.AdminChannelModels)
apiGroup.POST("/admin/channels/:id/models", apiHandler.AdminChannelAddModel)
apiGroup.PATCH("/admin/channels/:id/models/:bid", apiHandler.AdminChannelUpdateModel)
apiGroup.DELETE("/admin/channels/:id/models/:bid", apiHandler.AdminChannelDeleteModel)
// Admin model management (enhanced)
apiGroup.GET("/admin/models", apiHandler.AdminModels)
apiGroup.DELETE("/admin/models/unused", apiHandler.AdminDeleteUnusedModels)
apiGroup.POST("/admin/models", apiHandler.AdminCreateModel)
apiGroup.PUT("/admin/models/:id", apiHandler.AdminUpdateModel)
apiGroup.DELETE("/admin/models/:id", apiHandler.AdminDeleteModel)
apiGroup.POST("/admin/models/:id/bindings", apiHandler.AdminCreateModelBinding)
apiGroup.DELETE("/admin/models/:id/bindings/:bid", apiHandler.AdminDeleteModelBinding)
// Admin system config
apiGroup.GET("/admin/config", apiHandler.AdminGetConfig)
apiGroup.PUT("/admin/config", apiHandler.AdminUpdateConfig)
apiGroup.GET("/admin/config/registration", apiHandler.AdminGetRegistration)
apiGroup.PUT("/admin/config/registration", apiHandler.AdminUpdateRegistration)
apiGroup.GET("/admin/config/password-login", apiHandler.AdminGetPasswordLogin)
apiGroup.PUT("/admin/config/password-login", apiHandler.AdminUpdatePasswordLogin)
} }
// LLM proxy routes // LLM proxy routes
+2 -2
View File
@@ -47,9 +47,9 @@ const iconForType = (type: ToastType) => {
const typeClasses = (type: ToastType) => { const typeClasses = (type: ToastType) => {
switch (type) { switch (type) {
case 'success': case 'success':
return 'border-success/20 bg-success/10 text-success-content dark:border-success/30 dark:bg-success/15'; return 'border-success bg-success/15 text-success';
case 'error': case 'error':
return 'border-error/20 bg-error/10 text-error-content dark:border-error/30 dark:bg-error/15'; return 'border-error bg-error/15 text-error';
default: default:
return 'border-base-300 bg-base-100 text-base-content'; return 'border-base-300 bg-base-100 text-base-content';
} }
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent' }>(), {
variant: 'neutral',
})
</script>
<template>
<span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] leading-5"
:class="{
neutral: 'bg-base-200 text-base-content',
ok: 'bg-success/10 text-success',
warn: 'bg-warning/10 text-warning',
err: 'bg-error/10 text-error',
accent: 'bg-primary/10 text-primary',
}[variant]"
>
<span
v-if="variant !== 'neutral'"
class="size-1.5 rounded-full"
:class="{
ok: 'bg-success',
warn: 'bg-warning',
err: 'bg-error',
accent: 'bg-primary',
}[variant]"
/>
<slot />
</span>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'danger'
size?: 'sm' | 'md'
loading?: boolean
disabled?: boolean
}>(),
{ variant: 'primary', size: 'md', loading: false, disabled: false },
)
</script>
<template>
<button
:disabled="disabled || loading"
class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none"
:class="[
size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm',
variant === 'primary' && 'bg-primary text-primary-content hover:bg-primary/90',
variant === 'ghost' && 'border border-base-300/60 text-base-content hover:bg-base-200/50',
variant === 'danger' && 'border border-error text-error hover:bg-error/10',
]"
>
<span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<slot />
</button>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string
modelValue?: string | number
type?: string
placeholder?: string
hint?: string
error?: string
autocomplete?: string
disabled?: boolean
maxlength?: number
}>(),
{ type: 'text', modelValue: '', disabled: false },
)
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
</script>
<template>
<label class="block">
<span v-if="label" class="mb-1.5 block text-xs font-medium text-base-content/50">{{ label }}</span>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:autocomplete="autocomplete"
:disabled="disabled"
:maxlength="maxlength"
class="h-10 w-full rounded-md border border-base-300/60 bg-base-100 px-3 text-sm text-base-content placeholder-base-content/40 outline-none transition focus:border-primary focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
:class="error && 'border-error focus:border-error focus:ring-error'"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
/>
<span v-if="hint && !error" class="mt-1.5 block text-xs text-base-content/50">{{ hint }}</span>
<span v-if="error" class="mt-1.5 block text-xs text-error">{{ error }}</span>
</label>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { X } from '@lucide/vue'
const props = withDefaults(
defineProps<{
open: boolean
title?: string
width?: string
}>(),
{ width: 'max-w-md' },
)
const emit = defineEmits<{ close: [] }>()
const panel = ref<HTMLElement | null>(null)
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) emit('close')
}
onMounted(() => window.addEventListener('keydown', onKey))
onUnmounted(() => window.removeEventListener('keydown', onKey))
watch(
() => props.open,
async (open) => {
document.body.style.overflow = open ? 'hidden' : ''
if (open) {
await nextTick()
panel.value?.focus()
}
},
)
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-150"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-[12vh] backdrop-blur-sm"
@mousedown.self="emit('close')"
>
<Transition
enter-active-class="transition-transform duration-150"
enter-from-class="scale-[0.97] opacity-0"
leave-active-class="transition-transform duration-150"
leave-to-class="scale-[0.97] opacity-0"
>
<div
v-if="open"
ref="panel"
role="dialog"
aria-modal="true"
:aria-label="title || '对话框'"
tabindex="-1"
class="card w-full bg-base-100 shadow-xl outline-none"
:class="width"
>
<div class="flex items-center justify-between border-b border-base-300/60 px-5 py-3.5">
<h3 class="text-sm font-semibold text-base-content">{{ title }}</h3>
<button class="rounded-md p-1 text-base-content/40 hover:bg-base-200 hover:text-base-content" aria-label="关闭" @click="emit('close')">
<X :size="16" />
</button>
</div>
<div class="px-5 py-4">
<slot />
</div>
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-base-300/60 px-5 py-3.5">
<slot name="footer" />
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
+27
View File
@@ -0,0 +1,27 @@
// 协议格式显示名与选项
export const PROTOCOL_NAMES: Record<string, string> = {
chat: 'OpenAI Chat Completions',
responses: 'OpenAI Responses API',
messages: 'Anthropic Messages',
}
// 渠道表格用的短标识
export const PROTOCOL_SHORT: Record<string, string> = {
chat: 'chat/completions',
responses: 'responses',
messages: 'messages',
}
export function protocolShort(p: string): string {
return PROTOCOL_SHORT[p] ?? p
}
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
}
+101
View File
@@ -8,6 +8,8 @@ export type Channel = {
name: string name: string
provider: string provider: string
base_url: string base_url: string
base_urls?: Record<string, string>
api_key_masked?: string
weight: number weight: number
priority: number priority: number
timeout_ms: number timeout_ms: number
@@ -31,6 +33,14 @@ export type NewChannelPayload = {
formats?: string[] formats?: string[]
} }
export type ChannelModelBinding = {
id: number
model_id: number
model_name: string
upstream_model: string
weight: number
}
export const useChannelStore = defineStore('channel', () => { export const useChannelStore = defineStore('channel', () => {
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@@ -125,6 +135,91 @@ export const useChannelStore = defineStore('channel', () => {
} }
}; };
// Admin API methods
const testChannel = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/admin/channels/${id}/test`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to test channel';
throw err;
} finally {
loading.value = false;
}
};
const fetchRemoteModels = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/admin/channels/${id}/models/remote`);
return response.data.data ?? [];
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to fetch remote models';
throw err;
} finally {
loading.value = false;
}
};
const fetchChannelModels = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/admin/channels/${id}/models`);
return response.data.data ?? [];
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to fetch channel models';
throw err;
} finally {
loading.value = false;
}
};
const addChannelModel = async (id: number | string, data: { model_id: number; upstream_model: string; weight?: number }) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/admin/channels/${id}/models`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to add model';
throw err;
} finally {
loading.value = false;
}
};
const updateChannelModel = async (channelId: number | string, bindingId: number | string, data: { upstream_model?: string; weight?: number }) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.patch(`/admin/channels/${channelId}/models/${bindingId}`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to update binding';
throw err;
} finally {
loading.value = false;
}
};
const deleteChannelModel = async (channelId: number | string, bindingId: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/admin/channels/${channelId}/models/${bindingId}`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete binding';
throw err;
} finally {
loading.value = false;
}
};
return { return {
loading, error, loading, error,
channel, channels, totalChannels, channel, channels, totalChannels,
@@ -134,5 +229,11 @@ export const useChannelStore = defineStore('channel', () => {
updateChannel, updateChannel,
deleteChannel, deleteChannel,
batchChannels, batchChannels,
testChannel,
fetchRemoteModels,
fetchChannelModels,
addChannelModel,
updateChannelModel,
deleteChannelModel,
}; };
}); });
+171
View File
@@ -0,0 +1,171 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { AxiosResponse } from 'axios';
import request from '@/api/client';
export type Model = {
id: number
name: string
display_name?: string
input_price: number
output_price: number
cache_read_price: number
enabled: boolean
sort: number
channels?: ModelBinding[]
used?: boolean
needs_pricing?: boolean
denied?: boolean
created_at?: string
updated_at?: string
[key: string]: unknown
}
export type ModelBinding = {
id: number
channel_id: number
channel_name: string
upstream_model: string
weight: number
}
export type NewModelPayload = {
name: string
display_name?: string
input_price?: number
output_price?: number
cache_read_price?: number
sort?: number
enabled?: boolean
}
export type ModelSummary = {
total: number
unpriced: number
missing: OrphanBinding[]
denied_count: number
}
export type OrphanBinding = {
channel: string
model_id: number
upstream_model: string
}
export const useModelStore = defineStore('model', () => {
const loading = ref(false);
const error = ref<string | null>(null);
const models = ref<Model[]>([]);
const summary = ref<ModelSummary | null>(null);
const fetchModels = async () => {
loading.value = true;
error.value = null;
try {
const response = await request.get('/admin/models');
models.value = response.data.data ?? [];
summary.value = response.data.summary ?? null;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to fetch models';
throw err;
} finally {
loading.value = false;
}
};
const createModel = async (data: NewModelPayload) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post('/admin/models', data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create model';
throw err;
} finally {
loading.value = false;
}
};
const updateModel = async (id: number | string, data: Partial<Model>) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.put(`/admin/models/${id}`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to update model';
throw err;
} finally {
loading.value = false;
}
};
const deleteModel = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/admin/models/${id}`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete model';
throw err;
} finally {
loading.value = false;
}
};
const deleteUnusedModels = async () => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete('/admin/models/unused');
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete unused models';
throw err;
} finally {
loading.value = false;
}
};
const createModelBinding = async (modelId: number | string, data: { channel_id: number; upstream_model: string; weight?: number }) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/admin/models/${modelId}/bindings`, data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create binding';
throw err;
} finally {
loading.value = false;
}
};
const deleteModelBinding = async (modelId: number | string, bindingId: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/admin/models/${modelId}/bindings/${bindingId}`);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to delete binding';
throw err;
} finally {
loading.value = false;
}
};
return {
loading, error,
models, summary,
fetchModels,
createModel,
updateModel,
deleteModel,
deleteUnusedModels,
createModelBinding,
deleteModelBinding,
};
});
+56
View File
@@ -115,3 +115,59 @@ export type NewUserPayload = {
unlimited_quota?: boolean unlimited_quota?: boolean
language?: string language?: string
} }
// Channel 渠道管理
export interface Channel {
id: number
name: string
provider: 'openai' | 'anthropic' | 'compatible'
formats: string[] // chat | responses | messages
base_url: string
base_urls?: Record<string, string> | null
api_key_masked: string
weight: number
priority: number
timeout_ms: number
max_concurrency: number
health_status: string
enabled: boolean
created_at: string
}
export interface ChannelModelMapping {
id: number
model_id: number
model_name: string
upstream_model: string
weight: number
}
// Model 模型定价
export interface ModelBinding {
id: number
channel_id: number
channel_name: string
upstream_model: string
weight: number
}
export interface Model {
id: number
name: string
input_price: number
output_price: number
cache_read_price: number
enabled: boolean
sort: number
channels: ModelBinding[]
used?: boolean
needs_pricing?: boolean
denied?: boolean
}
export interface ModelSummary {
total: number
unpriced: number
missing: { channel: string; model_id: number; upstream_model: string }[]
denied_count: number
}
+7 -2
View File
@@ -6,6 +6,8 @@ import {
KeyRoundIcon, KeyRoundIcon,
SettingsIcon, SettingsIcon,
GlobeIcon, GlobeIcon,
BoxesIcon,
SlidersHorizontalIcon,
} from '@lucide/vue' } from '@lucide/vue'
export type MenuLink = { label: string; to: string; icon?: Component } export type MenuLink = { label: string; to: string; icon?: Component }
@@ -48,8 +50,9 @@ export const routes: RouteRecordRaw[] = [
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } }, { path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } },
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: '新建用户' } }, { path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: '新建用户' } },
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } }, { path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } }, { path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/ChannelsView.vue'), meta: { title: '渠道管理' } },
{ path: 'channels/view', name: 'ChannelView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } }, { path: 'models', name: 'Models', component: () => import('@/views/dashboard/Models.vue'), meta: { title: '模型定价' } },
{ path: 'config', name: 'SystemConfig', component: () => import('@/views/dashboard/SystemConfig.vue'), meta: { title: '系统配置' } },
], ],
}, },
{ {
@@ -76,4 +79,6 @@ export const consoleMenu: MenuLink[] = [
export const adminMenu: MenuLink[] = [ export const adminMenu: MenuLink[] = [
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon }, { label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
{ label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon }, { label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon },
{ label: '模型定价', to: '/dashboard/manager/models', icon: BoxesIcon },
{ label: '系统配置', to: '/dashboard/manager/config', icon: SlidersHorizontalIcon },
] ]
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { RefreshCw, Plus, X } from '@lucide/vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
import Button from '@/components/ui/Button.vue'
import type { Channel, ChannelModelMapping } from '@/types'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
const props = defineProps<{ channel: Channel }>()
const { setToast } = useToast()
const mappings = ref<ChannelModelMapping[]>([])
const remote = ref<string[]>([])
const selected = ref<string[]>([])
const loading = ref(false)
const fetched = ref(false)
const addForm = reactive({ custom_name: '', upstream_model: '' })
async function load() {
try {
const { data } = await request.get(`/admin/channels/${props.channel.id}/models`)
mappings.value = data.data?.items || data.data || []
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function fetchRemote() {
loading.value = true
try {
const { data } = await request.get(`/admin/channels/${props.channel.id}/models/remote`)
remote.value = data.data || []
selected.value = []
fetched.value = true
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
loading.value = false
}
}
async function addSelected() {
let added = 0
for (const name of selected.value) {
try {
await request.post(`/admin/channels/${props.channel.id}/models`, {
upstream_model: name,
})
added++
} catch {
/* 单个失败不中断 */
}
}
selected.value = []
setToast(added ? `已添加 ${added} 个模型` : '所选均已添加', 'success')
await load()
await fetchRemote()
}
async function addManual() {
if (!addForm.upstream_model.trim()) return
try {
await request.post(`/admin/channels/${props.channel.id}/models`, {
upstream_model: addForm.upstream_model.trim(),
custom_name: addForm.custom_name.trim() || undefined,
})
setToast('已添加', 'success')
addForm.custom_name = ''
addForm.upstream_model = ''
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function saveUpstream(b: ChannelModelMapping) {
try {
await request.patch(`/admin/channels/${props.channel.id}/models/${b.id}`, {
upstream_model: b.upstream_model,
})
setToast('已更新', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function remove(b: ChannelModelMapping) {
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
try {
await request.delete(`/admin/channels/${props.channel.id}/models/${b.id}`)
setToast('已解除', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
onMounted(load)
</script>
<template>
<div class="space-y-3">
<!-- 已允许的模型 -->
<div>
<p class="mb-1.5 text-xs font-medium text-base-content/50">已允许的模型({{ mappings.length }})</p>
<div v-if="mappings.length" class="flex flex-wrap gap-2">
<div
v-for="b in mappings"
:key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-base-300/60 bg-base-100 px-2 py-1 font-mono text-[11px] text-base-content/60"
>
<span class="text-base-content">{{ b.model_name }}</span>
<span class="opacity-60">→</span>
<input
v-model="b.upstream_model"
class="w-28 rounded border border-transparent bg-transparent px-1 text-[11px] text-primary outline-none transition focus:border-primary/50 focus:bg-base-200/50"
@change="saveUpstream(b)"
/>
<button class="text-base-content/40 hover:text-error" aria-label="解除" @click="remove(b)">
<X :size="12" />
</button>
</div>
</div>
<p v-else class="text-xs text-base-content/50">尚未允许任何模型</p>
</div>
<!-- 从接口拉取 + 勾选 -->
<div class="border-t border-base-300/60 pt-3">
<div class="mb-1.5 flex items-center justify-between">
<p class="text-xs font-medium text-base-content/50">从接口拉取模型</p>
<Button size="sm" variant="ghost" :loading="loading" @click="fetchRemote">
<RefreshCw :size="13" />
拉取
</Button>
</div>
<div v-if="remote.length" class="flex max-h-36 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in remote"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[11px] text-base-content/60 transition select-none"
:class="selected.includes(m) ? 'border-primary bg-primary/10 text-base-content' : 'border-base-300/60 hover:border-base-content/30'"
>
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-primary" />
{{ m }}
</label>
</div>
<div v-if="remote.length" class="mt-2">
<Button size="sm" @click="addSelected">
<Plus :size="13" />
添加所选({{ selected.length }})
</Button>
</div>
<p v-else-if="!loading" class="text-xs text-base-content/50">
{{ remote.length === 0 && fetched ? '接口返回的模型均已允许,无新增候选' : '点「拉取」获取渠道接口返回的新模型,勾选需要的加入' }}
</p>
</div>
<!-- 手动添加 -->
<div class="flex items-center gap-2 border-t border-base-300/60 pt-3">
<input
v-model="addForm.custom_name"
placeholder="自定义名称(可选)"
class="h-8 min-w-0 flex-1 rounded-md border border-base-300/60 bg-base-100 px-2 font-mono text-xs outline-none focus:border-primary"
@keyup.enter="addManual"
/>
<input
v-model="addForm.upstream_model"
placeholder="上游模型名"
class="h-8 min-w-0 flex-1 rounded-md border border-base-300/60 bg-base-100 px-2 font-mono text-xs outline-none focus:border-primary"
@keyup.enter="addManual"
/>
<Button size="sm" class="shrink-0" @click="addManual">
<Plus :size="13" />
添加
</Button>
</div>
</div>
</template>
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ChevronDown, Zap, Pencil, Trash2, Layers } from '@lucide/vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
import ChannelModelsDrawer from '@/views/dashboard/ChannelModelsDrawer.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Channel } from '@/types'
const { setToast } = useToast()
const channels = ref<Channel[]>([])
const editOpen = ref(false)
const editing = ref<Channel | null>(null)
const saving = ref(false)
const busyId = ref<number | null>(null)
const expandedId = ref<number | null>(null)
function toggleDrawer(ch: Channel) {
expandedId.value = expandedId.value === ch.id ? null : ch.id
}
const form = reactive({
name: '',
formats: ['chat'] as string[],
base_url: '',
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
api_key: '',
weight: 1,
priority: 0,
timeout_ms: 120000,
max_concurrency: 16,
enabled: true,
})
async function load() {
try {
const { data } = await request.get('/admin/channels')
channels.value = data.data.items || data.data
} catch (e) {
setToast(errMsg(e), 'error')
}
}
function openCreate() {
editing.value = null
Object.assign(form, {
name: '', formats: ['chat'], base_url: '',
base_urls: { chat: '', responses: '', messages: '' },
api_key: '',
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
})
editOpen.value = true
}
function openEdit(ch: Channel) {
editing.value = ch
Object.assign(form, {
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
base_url: ch.base_url,
base_urls: {
chat: ch.base_urls?.chat ?? '',
responses: ch.base_urls?.responses ?? '',
messages: ch.base_urls?.messages ?? '',
},
api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
})
editOpen.value = true
}
async function save() {
if (form.formats.length === 0) {
setToast('请至少选择一种 API 格式', 'error')
return
}
saving.value = true
const payload = {
...form,
weight: Number(form.weight),
priority: Number(form.priority),
timeout_ms: Number(form.timeout_ms),
max_concurrency: Number(form.max_concurrency),
}
try {
if (editing.value) {
await request.put(`/admin/channels/${editing.value.id}`, payload)
setToast('渠道已更新', 'success')
} else {
await request.post('/admin/channels', payload)
setToast('渠道已创建', 'success')
}
editOpen.value = false
await load()
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
async function remove(ch: Channel) {
if (!confirm(`删除渠道 ${ch.name}?关联的模型绑定也会清除。`)) return
try {
await request.delete(`/admin/channels/${ch.id}`)
setToast('渠道已删除', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
async function testChannel(ch: Channel) {
busyId.value = ch.id
try {
await request.post(`/admin/channels/${ch.id}/test`)
setToast(`渠道 ${ch.name} 连接正常`, 'success')
} catch (e) {
setToast(`连接失败: ${errMsg(e)}`, 'error')
} finally {
busyId.value = null
await load()
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-base-content/60">接入上游服务,API Key 加密存储</p>
</div>
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
</div>
<!-- 移动端:卡片列表 -->
<div class="space-y-3 md:hidden">
<div v-for="ch in channels" :key="ch.id" class="card border border-base-300/60 bg-base-100 p-4 shadow-sm" :class="ch.enabled ? 'border-l-2 border-l-success' : ''">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-sm font-medium">{{ ch.name }}</p>
<div class="mt-1.5 flex flex-wrap gap-1">
<code
v-for="f in ch.formats || []"
:key="f"
class="rounded bg-base-200 px-1.5 py-0.5 font-mono text-[10px] text-base-content/60"
>{{ protocolShort(f) }}</code>
</div>
</div>
<div class="flex shrink-0 gap-1.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
</div>
</div>
<p class="mt-2 truncate font-mono text-[11px] text-base-content/60">{{ ch.base_url }}</p>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-base-300/60 pt-3">
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-primary" :disabled="busyId === ch.id" @click="testChannel(ch)">
<Zap :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-primary hover:text-primary/80" @click="toggleDrawer(ch)">
<Layers :size="13" />
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-base-content" @click="openEdit(ch)">
<Pencil :size="13" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-error" @click="remove(ch)">
<Trash2 :size="13" />
删除
</button>
</div>
<div v-if="expandedId === ch.id" class="mt-3 border-t border-base-300/60 pt-3">
<ChannelModelsDrawer :channel="ch" />
</div>
</div>
<p v-if="channels.length === 0" class="card border border-base-300/60 bg-base-100 px-4 py-10 text-center text-sm text-base-content/60">
还没有渠道,点击「添加渠道」
</p>
</div>
<!-- 桌面端:表格 -->
<div class="card hidden border border-base-300/60 bg-base-100 shadow-sm md:block">
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[820px]">
<thead>
<tr class="border-b border-base-300/60 text-left text-xs text-base-content/50">
<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>
<th scope="col" class="px-4 py-2.5 font-medium">启用</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<template v-for="ch in channels" :key="ch.id">
<tr class="border-b border-base-300/40 last:border-0 hover:bg-base-200/50" :style="ch.enabled ? { borderLeft: '2px solid oklch(var(--p))' } : {}">
<td class="px-4 py-2.5">
<button class="inline-flex items-center gap-1.5 transition hover:text-primary" @click="toggleDrawer(ch)">
<span class="truncate">{{ ch.name }}</span>
<ChevronDown :size="12" class="shrink-0 text-base-content/50 transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
</button>
</td>
<td class="px-4 py-2.5">
<div class="flex flex-col gap-0.5">
<code
v-for="f in ch.formats || []"
:key="f"
class="font-mono text-[11px] leading-4 text-base-content/60"
>{{ protocolShort(f) }}</code>
</div>
</td>
<td class="max-w-[220px] truncate px-4 py-2.5 font-mono text-xs text-base-content/60">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-base-content/60">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
</td>
<td class="px-4 py-2.5 text-xs text-base-content/60">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-primary" :disabled="busyId === ch.id" @click="testChannel(ch)">
<Zap :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-base-content" @click="openEdit(ch)">
<Pencil :size="13" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-base-content/60 hover:text-error" @click="remove(ch)">
<Trash2 :size="13" />
删除
</button>
</div>
</td>
</tr>
<tr v-if="expandedId === ch.id" class="bg-base-200/30">
<td colspan="7" class="px-4 py-3">
<ChannelModelsDrawer :channel="ch" />
</td>
</tr>
</template>
<tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-base-content/60">还没有渠道,点击「添加渠道」</td>
</tr>
</tbody>
</table>
</div>
</div>
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="名称" placeholder="openai" />
<div>
<span class="mb-1.5 block text-xs font-medium text-base-content/50">支持的 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-primary bg-primary/10' : 'border-base-300 text-base-content/60 hover:border-base-content/30'"
>
<input
v-model="form.formats"
type="checkbox"
:value="opt.value"
class="size-3.5 accent-primary"
/>
{{ opt.label }}
</label>
</div>
<p class="mt-1.5 text-xs text-base-content/50">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div>
<Input
v-model="form.base_url"
label="Base URL(可选)"
placeholder="https://api.openai.com/v1"
:maxlength="255"
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
/>
<div class="space-y-3 rounded-md border border-base-300/60 p-3">
<p class="text-xs font-medium text-base-content/50">分协议 Base URL(可选,如智谱三种格式不同)</p>
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" :maxlength="255" />
<p class="text-xs text-base-content/50">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
</div>
<Input
v-model="form.api_key"
label="上游 API Key"
:placeholder="editing ? '留空则不修改' : 'sk-...'"
/>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input v-model="form.weight" label="权重" type="number" />
<Input v-model="form.priority" label="优先级" type="number" />
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
</div>
<div class="flex items-center justify-between rounded-md border border-base-300/60 p-3">
<div>
<p class="text-sm font-medium">启用渠道</p>
<p class="text-xs text-base-content/50">禁用后该渠道不会被用于请求转发</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.enabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="form.enabled ? 'bg-primary' : 'bg-base-200'"
@click="form.enabled = !form.enabled"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="form.enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
</div>
</template>
+148 -1
View File
@@ -87,6 +87,10 @@
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4"> <div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
<button type="button" @click="goBack" class="btn btn-ghost btn-sm">Back</button> <button type="button" @click="goBack" class="btn btn-ghost btn-sm">Back</button>
<button type="button" @click="testChannel" class="btn btn-warning btn-sm" :disabled="testing">
<span v-if="testing" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Test Connection
</button>
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating"> <button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span> <span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Save Changes Save Changes
@@ -94,6 +98,47 @@
</div> </div>
</form> </form>
</div> </div>
<!-- Model Bindings -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body gap-4 p-4 sm:p-6">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Model Bindings</h2>
<button class="btn btn-primary btn-sm" @click="openAddModelModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Add Model
</button>
</div>
<div v-if="bindings.length > 0" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th>Model Name</th>
<th>Upstream Model</th>
<th class="text-right">Weight</th>
<th class="text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="b in bindings" :key="b.id" class="border-base-300/40">
<td class="font-medium">{{ b.model_name }}</td>
<td class="font-mono text-xs">{{ b.upstream_model }}</td>
<td class="text-right">{{ b.weight }}</td>
<td class="text-right">
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteBinding(b)"
aria-label="Delete binding">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="py-6 text-center text-sm text-base-content/50">
No model bindings configured.
</div>
</div>
</div>
</div> </div>
<!-- Loading state --> <!-- Loading state -->
@@ -104,22 +149,59 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Add Model Modal -->
<dialog ref="addModelModalRef" class="modal">
<div class="modal-box max-w-lg px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<h3 class="mb-4 text-lg font-bold">Add Model Binding</h3>
<form @submit.prevent="addModelBinding" class="space-y-4">
<label class="floating-label">
<span>Model ID *</span>
<input v-model.number="newBinding.model_id" type="number" placeholder="Model ID" class="input w-full" required />
</label>
<label class="floating-label">
<span>Upstream Model Name *</span>
<input v-model="newBinding.upstream_model" type="text" placeholder="e.g. gpt-4o" class="input w-full" required />
</label>
<label class="floating-label">
<span>Weight</span>
<input v-model.number="newBinding.weight" type="number" min="1" placeholder="1" class="input w-full" />
</label>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="closeAddModelModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="addingModel">
{{ addingModel ? 'Adding...' : 'Add' }}
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useChannelStore, type Channel } from '../../stores/channel'; import { useChannelStore, type Channel, type ChannelModelBinding } from '../../stores/channel';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useToast } from '@/composables/toast'; import { useToast } from '@/composables/toast';
import { PlusIcon, TrashIcon } from '@lucide/vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const channelStore = useChannelStore(); const channelStore = useChannelStore();
const { setToast } = useToast(); const { setToast } = useToast();
const updating = ref(false); const updating = ref(false);
const testing = ref(false);
const api_key = ref(''); const api_key = ref('');
const bindings = ref<ChannelModelBinding[]>([]);
const channelId = computed(() => route.query.id); const channelId = computed(() => route.query.id);
const ch = computed(() => channelStore.channel); const ch = computed(() => channelStore.channel);
@@ -127,9 +209,16 @@ const ch = computed(() => channelStore.channel);
onMounted(async () => { onMounted(async () => {
if (channelId.value) { if (channelId.value) {
await channelStore.fetchChannel(channelId.value as string); await channelStore.fetchChannel(channelId.value as string);
await fetchBindings();
} }
}); });
const fetchBindings = async () => {
if (channelId.value) {
bindings.value = await channelStore.fetchChannelModels(channelId.value as string);
}
};
const toggleEnabled = () => { const toggleEnabled = () => {
if (!ch.value) return; if (!ch.value) return;
ch.value.enabled = !ch.value.enabled; ch.value.enabled = !ch.value.enabled;
@@ -163,7 +252,65 @@ const updateCh = async () => {
} }
}; };
const testChannel = async () => {
if (!ch.value) return;
testing.value = true;
try {
const result = await channelStore.testChannel(ch.value.id);
setToast(`Connection OK (${result.data?.latency_ms}ms)`, 'success');
} catch (err: any) {
setToast(err.response?.data?.error || 'Connection test failed', 'error');
} finally {
testing.value = false;
}
};
const goBack = () => { const goBack = () => {
router.push({ name: 'Channels' }); router.push({ name: 'Channels' });
}; };
// Model binding
const addModelModalRef = ref<HTMLDialogElement | null>(null);
const addingModel = ref(false);
const newBinding = ref({
model_id: 0,
upstream_model: '',
weight: 1,
});
const openAddModelModal = () => {
newBinding.value = { model_id: 0, upstream_model: '', weight: 1 };
addModelModalRef.value?.showModal();
};
const closeAddModelModal = () => {
addModelModalRef.value?.close();
};
const addModelBinding = async () => {
if (!channelId.value || !newBinding.value.model_id || !newBinding.value.upstream_model) return;
addingModel.value = true;
try {
await channelStore.addChannelModel(channelId.value as string, newBinding.value);
setToast('Model binding added', 'success');
closeAddModelModal();
await fetchBindings();
} catch (err: any) {
setToast(err.response?.data?.error || 'Failed to add binding', 'error');
} finally {
addingModel.value = false;
}
};
const confirmDeleteBinding = async (b: ChannelModelBinding) => {
if (confirm(`Remove binding for model "${b.model_name}"?`)) {
try {
await channelStore.deleteChannelModel(channelId.value as string, b.id);
setToast('Binding removed', 'success');
await fetchBindings();
} catch (err: any) {
setToast('Delete failed', 'error');
}
}
};
</script> </script>
+277
View File
@@ -0,0 +1,277 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader />
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm text-base-content/60">Manage model pricing and channel bindings.</p>
<div v-if="summary" class="mt-1 flex gap-3 text-xs text-base-content/50">
<span>Total: {{ summary.total }}</span>
<span v-if="summary.unpriced > 0" class="text-warning">{{ summary.unpriced }} unpriced</span>
<span v-if="summary.missing.length > 0" class="text-error">{{ summary.missing.length }} orphan bindings</span>
</div>
</div>
<div class="flex items-center gap-2">
<button v-if="models.length > 0" class="btn btn-ghost btn-sm" @click="confirmDeleteUnused">
<TrashIcon class="h-4 w-4" aria-hidden="true" />Clean Unused
</button>
<button class="btn btn-primary btn-sm" @click="openCreateModal" aria-label="Create new model">
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Model
</button>
</div>
</div>
<!-- Model cards -->
<div class="space-y-3">
<div v-for="m in models" :key="m.id"
class="card border bg-base-100 shadow-sm"
:class="m.channels && m.channels.length > 0 ? 'border-base-300/60' : 'border-warning/60 bg-warning/5'">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm font-medium text-base-content">{{ m.name }}</span>
<span v-if="m.display_name" class="text-xs text-base-content/50">{{ m.display_name }}</span>
<span v-if="m.channels && m.channels.length > 0" class="badge badge-xs badge-ghost">渠道允许</span>
<span v-else class="badge badge-xs bg-yellow-200 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300">悬空</span>
<span :class="m.enabled ? 'badge badge-xs bg-green-200 text-green-800 dark:bg-green-900/50 dark:text-green-300' : 'badge badge-xs badge-ghost'">{{ m.enabled ? '启用' : '停用' }}</span>
<span v-if="m.denied" class="badge badge-xs badge-error">已禁止</span>
<span v-if="m.needs_pricing" class="badge badge-xs bg-orange-200 text-orange-800 dark:bg-orange-900/50 dark:text-orange-300">未定价</span>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(m)">编辑</button>
<button class="btn btn-ghost btn-xs text-error" @click="confirmDeleteModel(m)">删除</button>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span class="font-mono text-xs text-base-content/60">入 {{ formatPrice(m.input_price) }}</span>
<span class="font-mono text-xs text-base-content/60">出 {{ formatPrice(m.output_price) }}</span>
<span class="font-mono text-xs text-base-content/60">缓存读 {{ formatPrice(m.cache_read_price) }}</span>
</div>
</div>
<div v-if="m.channels && m.channels.length > 0" class="border-t border-base-300/60 px-4 py-2">
<p class="mb-1.5 text-[11px] font-medium text-base-content/50">允许渠道</p>
<div class="flex flex-wrap gap-2">
<span v-for="ch in m.channels" :key="ch.id"
class="inline-flex items-center rounded-md border border-base-300/60 bg-base-100 px-2 py-0.5 font-mono text-[11px] text-base-content/60">
{{ ch.channel_name }} → {{ ch.upstream_model }}
</span>
</div>
</div>
<p v-else class="border-t border-base-300/60 bg-amber-100 px-4 py-2 text-xs font-medium text-amber-900 dark:bg-amber-900/40 dark:text-amber-100">
悬空模型:无任何渠道提供,客户端无法调用
</p>
</div>
<!-- Empty state -->
<div v-if="models.length === 0" class="card border border-base-300/60 bg-base-100 px-4 py-14 text-center">
<BoxesIcon class="mx-auto h-10 w-10 text-base-content/20" aria-hidden="true" />
<h2 class="mt-2 text-sm font-semibold">No models yet</h2>
<p class="mt-1 max-w-xs text-sm text-base-content/60">
Add models to manage pricing and channel bindings.
</p>
<button class="btn btn-primary btn-sm mt-3" @click="openCreateModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Model
</button>
</div>
</div>
<!-- Create/Edit modal -->
<dialog ref="modalRef" class="modal">
<div class="modal-box max-w-lg px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<h3 class="mb-4 text-lg font-bold">{{ editingModel ? 'Edit Model' : 'New Model' }}</h3>
<form @submit.prevent="saveModel" class="space-y-4">
<label class="floating-label">
<span>Model Name *</span>
<input v-model="form.name" type="text" placeholder="e.g. gpt-4o" class="input w-full" required
:disabled="!!editingModel" />
</label>
<label class="floating-label">
<span>Display Name</span>
<input v-model="form.display_name" type="text" placeholder="e.g. GPT-4o" class="input w-full" />
</label>
<div class="grid grid-cols-3 gap-3">
<label class="floating-label">
<span>Input $/M tokens</span>
<input v-model.number="form.input_price" type="number" step="0.01" min="0" placeholder="0"
class="input w-full" />
</label>
<label class="floating-label">
<span>Output $/M tokens</span>
<input v-model.number="form.output_price" type="number" step="0.01" min="0" placeholder="0"
class="input w-full" />
</label>
<label class="floating-label">
<span>Cache Read $/M</span>
<input v-model.number="form.cache_read_price" type="number" step="0.01" min="0" placeholder="0"
class="input w-full" />
</label>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="floating-label">
<span>Sort Order</span>
<input v-model.number="form.sort" type="number" min="0" placeholder="0" class="input w-full" />
</label>
<div class="flex items-center gap-2 pt-6">
<input type="checkbox" class="toggle toggle-success toggle-sm" v-model="form.enabled" />
<span class="text-sm">{{ form.enabled ? 'Enabled' : 'Disabled' }}</span>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (editingModel ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useModelStore, type Model, type NewModelPayload } from '@/stores/model';
import { useToast } from '@/composables/toast';
import {
BoxesIcon, PencilIcon, PlusIcon, TrashIcon
} from '@lucide/vue';
const modelStore = useModelStore();
const { setToast } = useToast();
const models = ref<Model[]>([]);
const summary = ref(modelStore.summary);
const editingModel = ref<Model | null>(null);
const saving = ref(false);
const form = reactive<NewModelPayload & { enabled: boolean }>({
name: '',
display_name: '',
input_price: 0,
output_price: 0,
cache_read_price: 0,
sort: 0,
enabled: true,
});
onMounted(async () => {
await fetchModels();
});
const fetchModels = async () => {
await modelStore.fetchModels();
models.value = modelStore.models;
summary.value = modelStore.summary;
};
const formatPrice = (price: number) => {
return price === 0 ? '-' : `$${price.toFixed(2)}`;
};
const toggleEnabled = async (m: Model) => {
try {
await modelStore.updateModel(m.id, { enabled: !m.enabled });
setToast(`Model ${m.name} ${m.enabled ? 'disabled' : 'enabled'}`, 'success');
await fetchModels();
} catch (error: any) {
setToast('Status update failed', 'error');
}
};
const openCreateModal = () => {
editingModel.value = null;
form.name = '';
form.display_name = '';
form.input_price = 0;
form.output_price = 0;
form.cache_read_price = 0;
form.sort = 0;
form.enabled = true;
modalRef.value?.showModal();
};
const openEditModal = (m: Model) => {
editingModel.value = m;
form.name = m.name;
form.display_name = m.display_name || '';
form.input_price = m.input_price;
form.output_price = m.output_price;
form.cache_read_price = m.cache_read_price;
form.sort = m.sort;
form.enabled = m.enabled;
modalRef.value?.showModal();
};
const saveModel = async () => {
saving.value = true;
try {
if (editingModel.value) {
await modelStore.updateModel(editingModel.value.id, {
display_name: form.display_name,
input_price: form.input_price,
output_price: form.output_price,
cache_read_price: form.cache_read_price,
sort: form.sort,
enabled: form.enabled,
});
setToast('Model updated', 'success');
} else {
await modelStore.createModel({
name: form.name,
display_name: form.display_name,
input_price: form.input_price,
output_price: form.output_price,
cache_read_price: form.cache_read_price,
sort: form.sort,
enabled: form.enabled,
});
setToast('Model created', 'success');
}
closeModal();
await fetchModels();
} catch (error: any) {
setToast(error.message || 'Save failed', 'error');
} finally {
saving.value = false;
}
};
const confirmDeleteModel = async (m: Model) => {
if (confirm(`Delete model "${m.name}"? This will also remove all channel bindings.`)) {
try {
await modelStore.deleteModel(m.id);
setToast(`Model ${m.name} deleted`, 'success');
await fetchModels();
} catch (error: any) {
setToast('Delete failed', 'error');
}
}
};
const confirmDeleteUnused = async () => {
if (confirm('Delete all models that are not bound to any channel?')) {
try {
const result = await modelStore.deleteUnusedModels();
const count = result.data?.count || 0;
setToast(`Deleted ${count} unused models`, 'success');
await fetchModels();
} catch (error: any) {
setToast('Cleanup failed', 'error');
}
}
};
const modalRef = ref<HTMLDialogElement | null>(null);
const closeModal = () => {
modalRef.value?.close();
};
</script>
+225
View File
@@ -0,0 +1,225 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Model, ModelSummary } from '@/types'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
const { setToast } = useToast()
const models = ref<Model[]>([])
const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
const editOpen = ref(false)
const editing = ref<Model | null>(null)
const saving = ref(false)
const quickName = ref('')
const clearing = ref(false)
const unused = computed(() => models.value.filter((m) => m.channels.length === 0))
async function clearUnused() {
if (!unused.value.length) {
setToast('没有未绑定渠道的模型', 'info')
return
}
const names = unused.value.map((m) => m.name)
if (!confirm(`确定删除 ${names.length} 个未绑定渠道的模型?\n\n${names.join('\n')}`)) return
clearing.value = true
try {
const { data } = await request.delete('/admin/models/unused')
setToast(`已清除 ${data.data.count} 个模型`, 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
clearing.value = false
}
}
function quickAdd() {
openCreate()
if (quickName.value) form.name = quickName.value.trim()
}
const form = reactive({
name: '',
input_price: 0,
output_price: 0,
cache_read_price: 0,
enabled: true,
})
async function load() {
try {
const { data } = await request.get('/admin/models')
models.value = data.data
summary.value = data.summary
} catch (e) {
setToast(errMsg(e), 'error')
}
}
function openCreate() {
editing.value = null
Object.assign(form, { name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
editOpen.value = true
}
function openEdit(m: Model) {
editing.value = m
Object.assign(form, {
name: m.name,
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
enabled: m.enabled,
})
editOpen.value = true
}
async function save() {
saving.value = true
const payload = {
input_price: Number(form.input_price),
output_price: Number(form.output_price),
cache_read_price: Number(form.cache_read_price),
enabled: form.enabled,
}
try {
if (editing.value) {
await request.put(`/admin/models/${editing.value.id}`, payload)
setToast('模型已更新', 'success')
} else {
await request.post('/admin/models', { name: form.name, ...payload })
setToast('模型已创建', 'success')
}
editOpen.value = false
await load()
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
async function removeModel(m: Model) {
if (!confirm(`删除模型 ${m.name}?`)) return
try {
await request.delete(`/admin/models/${m.id}`)
setToast('模型已删除', 'success')
await load()
} catch (e) {
setToast(errMsg(e), 'error')
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold">模型与定价</h1>
<p class="text-sm text-base-content/60">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
</div>
<div class="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
<input
v-model="quickName"
placeholder="模型名,如 glm-4.7-flash"
class="h-10 min-w-0 flex-1 rounded-md border border-base-300/60 bg-base-100 px-3 font-mono text-xs outline-none focus:border-primary sm:w-52 sm:flex-none"
@keyup.enter="quickAdd"
/>
<Button class="shrink-0" @click="quickAdd">添加模型</Button>
<Button
size="md"
variant="danger"
class="shrink-0 px-1!"
:loading="clearing"
:disabled="!unused.length"
@click="clearUnused"
>
清除悬空{{ unused.length ? `(${unused.length})` : '' }}
</Button>
</div>
</div>
<!-- 提示:定价目录 = 渠道选中的模型 + 手动添加的模型 -->
<div v-if="summary.missing.length" class="card border border-error/50 bg-error/5 p-4">
<p class="text-sm font-medium text-error">以下渠道选中的模型不在定价目录</p>
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-base-content/60">
{{ x.channel }} → {{ x.upstream_model || '模型 #' + x.model_id }}(请到渠道抽屉重新选中,或手动添加)
</p>
</div>
<p v-else-if="summary.unpriced > 0" class="text-xs text-base-content/60">
有 <span class="font-mono text-warning">{{ summary.unpriced }}</span> 个渠道允许的模型未定价,网关将按示例价计费
</p>
<p v-else class="text-xs text-base-content/60">定价目录中渠道允许的模型均已定价</p>
<div class="space-y-3">
<div v-for="m in models" :key="m.id" :class="m.channels.length ? 'card border border-base-300/60 bg-base-100' : 'card border border-warning/60 bg-warning/5'">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-base-content">{{ m.name }}</span>
<Badge v-if="m.channels.length" variant="neutral">渠道允许</Badge>
<Badge v-else variant="warn">悬空</Badge>
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
<Badge v-if="m.denied" variant="err">已禁止</Badge>
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
</div>
<div class="flex gap-2">
<button class="text-xs text-base-content/60 hover:text-base-content" @click="openEdit(m)">编辑</button>
<button class="text-xs text-base-content/60 hover:text-error" @click="removeModel(m)">删除</button>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span class="font-mono text-xs text-base-content/60">入 {{ m.input_price }}</span>
<span class="font-mono text-xs text-base-content/60">出 {{ m.output_price }}</span>
<span class="font-mono text-xs text-base-content/60">缓存读 {{ m.cache_read_price }}</span>
</div>
</div>
<div v-if="m.channels.length" class="border-t border-base-300/60 px-4 py-2">
<p class="mb-1.5 text-[11px] font-medium text-base-content/50">允许渠道(渠道抽屉中管理)</p>
<div class="flex flex-wrap gap-2">
<span
v-for="b in m.channels"
:key="b.id"
class="inline-flex items-center rounded-md border border-base-300/60 bg-base-100 px-2 py-0.5 font-mono text-[11px] text-base-content/60"
>
{{ b.channel_name }} → {{ b.upstream_model }}
</span>
</div>
</div>
<p v-else class="border-t border-base-300/60 px-4 py-2 text-xs text-warning">
悬空模型:无任何渠道提供,客户端无法调用
</p>
</div>
<p v-if="models.length === 0" class="card border border-base-300/60 bg-base-100 px-4 py-10 text-center text-sm text-base-content/60">
还没有模型,点击「添加模型」或到渠道页「导入模型」
</p>
</div>
<!-- 模型编辑 -->
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import request from '@/api/client'
import { useToast } from '@/composables/toast'
function errMsg(e: unknown) {
return (e as any)?.response?.data?.error || (e as any)?.message || '请求失败'
}
const { setToast } = useToast()
const loading = ref(false)
const saving = ref(false)
const registrationEnabled = ref(true)
const passwordLoginEnabled = ref(true)
async function load() {
loading.value = true
try {
const [regRes, pwdRes] = await Promise.all([
request.get('/admin/config/registration'),
request.get('/admin/config/password-login'),
])
registrationEnabled.value = regRes.data.data.enabled
passwordLoginEnabled.value = pwdRes.data.data.enabled
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
loading.value = false
}
}
async function saveRegistration(enabled: boolean) {
saving.value = true
try {
await request.put('/admin/config/registration', { enabled })
registrationEnabled.value = enabled
setToast('注册设置已更新', 'success')
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
async function savePasswordLogin(enabled: boolean) {
saving.value = true
try {
await request.put('/admin/config/password-login', { enabled })
passwordLoginEnabled.value = enabled
setToast('密码登录设置已更新', 'success')
} catch (e) {
setToast(errMsg(e), 'error')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-lg font-semibold">系统配置</h1>
<p class="text-sm text-base-content/60">管理平台全局设置</p>
</div>
<div v-if="loading" class="py-10 text-center text-sm text-base-content/50">加载中…</div>
<template v-else>
<!-- 开放注册 -->
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">开放注册</h3>
<p class="mt-1 text-xs text-base-content/50">允许新用户通过注册页面创建账号</p>
</div>
<button
type="button"
role="switch"
:aria-checked="registrationEnabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="registrationEnabled ? 'bg-primary' : 'bg-base-200'"
:disabled="saving"
@click="saveRegistration(!registrationEnabled)"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="registrationEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
<!-- 密码登录 -->
<div class="card border border-base-300/60 bg-base-100 p-4 shadow-sm">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">密码登录</h3>
<p class="mt-1 text-xs text-base-content/50">允许用户通过用户名和密码登录</p>
</div>
<button
type="button"
role="switch"
:aria-checked="passwordLoginEnabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="passwordLoginEnabled ? 'bg-primary' : 'bg-base-200'"
:disabled="saving"
@click="savePasswordLogin(!passwordLoginEnabled)"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="passwordLoginEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</template>
</div>
</template>
+1 -1
View File
@@ -9,7 +9,7 @@ import path from 'path'
// 需要自签名 HTTPS 时设置 VITE_DEV_HTTPS=true // 需要自签名 HTTPS 时设置 VITE_DEV_HTTPS=true
const useHttps = process.env.VITE_DEV_HTTPS === 'true' const useHttps = process.env.VITE_DEV_HTTPS === 'true'
// 后端地址:默认 make dev-backend 启动的 8080,可用 VITE_DEV_API_TARGET 覆盖 // 后端地址:默认 make dev-backend 启动的 8080,可用 VITE_DEV_API_TARGET 覆盖
const apiTarget = process.env.VITE_DEV_API_TARGET || 'http://localhost:8080' const apiTarget = process.env.VITE_DEV_API_TARGET || 'http://localhost:3000'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({