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:
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
@@ -556,7 +556,7 @@ func (h *Handler) DeleteApiKey(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// --- Channels ---
|
||||
// --- Legacy Channel endpoints (kept for backward compatibility) ---
|
||||
|
||||
func (h *Handler) ListChannels(c *gin.Context) {
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// --- Models ---
|
||||
// --- Legacy Model endpoints (kept for backward compatibility) ---
|
||||
|
||||
func (h *Handler) ListModels(c *gin.Context) {
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// --- Channel-Model Bindings ---
|
||||
// --- Legacy Channel-Model Bindings (kept for backward compatibility) ---
|
||||
|
||||
func (h *Handler) BindChannelModels(c *gin.Context) {
|
||||
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -19,6 +19,9 @@ type Service struct {
|
||||
// Health tracking
|
||||
mu sync.RWMutex
|
||||
healthStatus map[uint64]*channelHealth
|
||||
|
||||
// Concurrency control per channel
|
||||
sems map[uint64]chan struct{}
|
||||
}
|
||||
|
||||
type channelHealth struct {
|
||||
@@ -33,6 +36,7 @@ func NewService(channelDAO *dao.ChannelDAO, modelDAO *dao.ModelDAO) *Service {
|
||||
channelDAO: channelDAO,
|
||||
modelDAO: modelDAO,
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,45 @@ import (
|
||||
"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 {
|
||||
channelDAO *dao.ChannelDAO
|
||||
service *Service
|
||||
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{
|
||||
channelDAO: channelDAO,
|
||||
service: service,
|
||||
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
|
||||
func (hc *HealthChecker) StartPeriodicCheck(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
func (hc *HealthChecker) StartPeriodicCheck(ctx context.Context, interval ...time.Duration) {
|
||||
interval_ := hc.config.Interval
|
||||
if len(interval) > 0 {
|
||||
interval_ = interval[0]
|
||||
}
|
||||
ticker := time.NewTicker(interval_)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
|
||||
@@ -82,10 +82,19 @@ func (d *DailyUsageDAO) GetByDate(ctx context.Context, userID uint64, date strin
|
||||
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 {
|
||||
return d.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"requests", "input_tokens", "output_tokens", "cache_read_tokens", "cost"}),
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
-108
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/proxy/convert"
|
||||
"opencatd-open/internal/store"
|
||||
"opencatd-open/internal/usage"
|
||||
"opencatd-open/pkg/config"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -35,6 +37,7 @@ type Gateway struct {
|
||||
usageDAO *dao.UsageDAO
|
||||
dailyDAO *dao.DailyUsageDAO
|
||||
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 {
|
||||
@@ -67,6 +70,11 @@ func (g *Gateway) SetChannelService(svc *channel.Service) {
|
||||
g.channelSvc = svc
|
||||
}
|
||||
|
||||
// SetUsageRecorder 注入异步用量记录器;nil 时网关跳过用量上报。
|
||||
func (g *Gateway) SetUsageRecorder(r *usage.Recorder) {
|
||||
g.usageRec = r
|
||||
}
|
||||
|
||||
// Request represents a parsed incoming request
|
||||
type Request struct {
|
||||
Model string
|
||||
@@ -144,26 +152,24 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine target format and convert if needed
|
||||
targetFormat := req.Protocol
|
||||
if len(ch.FormatsEffective()) > 0 {
|
||||
// Prefer the channel's native format
|
||||
for _, f := range ch.FormatsEffective() {
|
||||
if f == req.Protocol {
|
||||
targetFormat = f
|
||||
break
|
||||
}
|
||||
}
|
||||
// Determine target format: channel declares support for the client protocol
|
||||
// then passthrough, otherwise convert to its first supported protocol
|
||||
// (chat > messages > responses).
|
||||
targetFormat := g.conversionTarget(ch, req.Protocol)
|
||||
if targetFormat == "" {
|
||||
g.writeError(c, http.StatusBadGateway, fmt.Sprintf("channel %q declares no supported protocol format", ch.Name))
|
||||
return
|
||||
}
|
||||
|
||||
// Build upstream URL
|
||||
upstreamPath := g.getUpstreamPath(req.Protocol)
|
||||
upstreamURL := ch.UpstreamURL(req.Protocol, upstreamPath)
|
||||
upstreamPath := g.getUpstreamPath(targetFormat)
|
||||
upstreamURL := ch.UpstreamURL(targetFormat, upstreamPath)
|
||||
|
||||
// Convert request if needed
|
||||
var requestBody []byte
|
||||
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 {
|
||||
g.writeError(c, http.StatusBadRequest, "conversion failed: "+err.Error())
|
||||
return
|
||||
@@ -206,12 +212,31 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
|
||||
// Stream or buffer response
|
||||
if req.Stream {
|
||||
g.streamResponse(c, resp, req.Protocol, ch)
|
||||
g.streamResponse(c, resp, req.Protocol, targetFormat)
|
||||
} 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 {
|
||||
switch protocol {
|
||||
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":
|
||||
var req convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
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) {
|
||||
// streamResponse 流式响应:按 \n\n 分块零缓冲转发;跨协议时逐行转换。
|
||||
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string) {
|
||||
w := c.Writer
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
writer := convert.NewSSEWriter(c.Writer)
|
||||
parser := convert.NewSSEParser(resp.Body)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
for {
|
||||
event, err := parser.ReadEvent()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
log.Printf("Stream parse error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if event.Event == "error" {
|
||||
log.Printf("Upstream stream error: %s", event.Data)
|
||||
break
|
||||
}
|
||||
|
||||
// Write raw SSE event based on protocol
|
||||
if err := writer.WriteEvent("chat CompletionChunk", event.Data); err != nil {
|
||||
break
|
||||
}
|
||||
// 跨协议时按行转换;同协议直通(lineConv 为 nil)。
|
||||
var lineConv func([]byte) []byte
|
||||
if upstreamProto != clientProto {
|
||||
lineConv = convert.NewStreamTransformer(upstreamProto, clientProto)
|
||||
}
|
||||
|
||||
writer.WriteDone()
|
||||
// 上游原始行按 \n\n 分块,避免把 data 行内的转义换行当成事件边界。
|
||||
r := bufio.NewReaderSize(resp.Body, 32*1024)
|
||||
for {
|
||||
buf := []byte{}
|
||||
for {
|
||||
line, err := r.ReadSlice('\n')
|
||||
if err == bufio.ErrBufferFull {
|
||||
buf = append(buf, line...)
|
||||
continue
|
||||
}
|
||||
buf = append(buf, line...)
|
||||
if err == io.EOF {
|
||||
if len(buf) == 0 {
|
||||
return
|
||||
}
|
||||
if !bytes.HasSuffix(buf, []byte("\n")) {
|
||||
buf = append(buf, '\n')
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Printf("stream read error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(buf) >= 2 && bytes.HasSuffix(buf, []byte("\n\n")) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to read response")
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package usage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/store"
|
||||
@@ -11,16 +12,26 @@ import (
|
||||
|
||||
// Event represents a usage event to be recorded
|
||||
type Event struct {
|
||||
UserID uint64
|
||||
ModelName string
|
||||
ChannelID uint64
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
CacheReadTokens int
|
||||
Cost float64
|
||||
IsError bool
|
||||
IsCanceled bool
|
||||
RequestID string
|
||||
UserID uint64
|
||||
ModelName string
|
||||
ChannelID uint64
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
CacheReadTokens int
|
||||
CacheCreationTokens int
|
||||
Cost float64
|
||||
IsError bool
|
||||
IsCanceled bool
|
||||
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
|
||||
@@ -117,16 +128,31 @@ func (r *Recorder) flush(events []Event) {
|
||||
status = store.UsageStatusCanceled
|
||||
}
|
||||
|
||||
var errCode *string
|
||||
if e.ErrorCode != "" {
|
||||
errCode = &e.ErrorCode
|
||||
}
|
||||
|
||||
log := &store.UsageLog{
|
||||
UserID: e.UserID,
|
||||
ModelName: e.ModelName,
|
||||
ChannelID: e.ChannelID,
|
||||
InputTokens: int64(e.PromptTokens),
|
||||
OutputTokens: int64(e.CompletionTokens),
|
||||
CacheReadTokens: int64(e.CacheReadTokens),
|
||||
Cost: e.Cost,
|
||||
Status: status,
|
||||
RequestID: e.RequestID,
|
||||
UserID: e.UserID,
|
||||
KeyID: e.KeyID,
|
||||
ChannelID: e.ChannelID,
|
||||
ModelID: e.ModelID,
|
||||
ModelName: e.ModelName,
|
||||
Protocol: e.Protocol,
|
||||
InputTokens: int64(e.PromptTokens),
|
||||
OutputTokens: int64(e.CompletionTokens),
|
||||
CacheReadTokens: int64(e.CacheReadTokens),
|
||||
CacheCreationTokens: int64(e.CacheCreationTokens),
|
||||
InputPrice: e.InputPrice,
|
||||
OutputPrice: e.OutputPrice,
|
||||
CacheReadPrice: e.CacheReadPrice,
|
||||
Cost: e.Cost,
|
||||
LatencyMS: e.LatencyMS,
|
||||
Status: status,
|
||||
ErrorCode: errCode,
|
||||
RequestID: e.RequestID,
|
||||
TraceID: e.TraceID,
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
@@ -136,5 +162,39 @@ func (r *Recorder) flush(events []Event) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -9,45 +9,34 @@ import (
|
||||
"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 {
|
||||
return func(c *gin.Context) {
|
||||
authToken := c.GetHeader("Authorization")
|
||||
if authToken == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": map[string]interface{}{
|
||||
"message": "未提供认证信息",
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
key := extractAPIKey(c.GetHeader("Authorization"))
|
||||
|
||||
// 区分「没传」和「传了但不对」,便于排查客户端配置。
|
||||
if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
|
||||
unauthorized(c, "未提供认证信息")
|
||||
return
|
||||
}
|
||||
// 长度不足时直接拒绝:避免下方 authToken[:12] 越界 panic 打崩进程。
|
||||
if len(key) < keyPrefixLen {
|
||||
unauthorized(c, "无效的API密钥")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract API key from Bearer token
|
||||
if len(authToken) > 7 {
|
||||
authToken = authToken[7:]
|
||||
}
|
||||
|
||||
// Find API key by prefix
|
||||
var apiKey store.APIKey
|
||||
if err := db.Where("key_prefix = ? AND status = ?", authToken[:8], store.KeyStatusActive).First(&apiKey).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": map[string]interface{}{
|
||||
"message": "无效的API密钥",
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
if err := db.Where("key_prefix = ? AND status = ?", key[:keyPrefixLen], store.KeyStatusActive).First(&apiKey).Error; err != nil {
|
||||
unauthorized(c, "无效的API密钥")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify full key hash
|
||||
keyHash := store.HashAPIKey(authToken)
|
||||
if apiKey.KeyHash != keyHash {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": map[string]interface{}{
|
||||
"message": "无效的API密钥",
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
if apiKey.KeyHash != store.HashAPIKey(key) {
|
||||
unauthorized(c, "无效的API密钥")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,11 +46,20 @@ func AuthLLM(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// extractAPIKey extracts the API key from the Authorization header
|
||||
func extractAPIKey(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
// extractAPIKey 从 Authorization 头取 Bearer token,兼容无 "Bearer " 前缀的直传。
|
||||
func extractAPIKey(auth string) string {
|
||||
auth = strings.TrimSpace(auth)
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return auth[7:]
|
||||
return strings.TrimSpace(auth[len("Bearer "):])
|
||||
}
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
|
||||
// Initialize health checker and start periodic checks
|
||||
healthChecker := channel.NewHealthChecker(channelDAO, channelSvc)
|
||||
go healthChecker.StartPeriodicCheck(ctx, 5*time.Minute)
|
||||
go healthChecker.StartPeriodicCheck(ctx)
|
||||
|
||||
// Initialize usage recorder and start background worker
|
||||
usageRecorder := usage.NewRecorder(usageDAO, dailyDAO)
|
||||
@@ -60,7 +60,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
// Initialize gateway
|
||||
gateway := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
|
||||
gateway.SetChannelService(channelSvc)
|
||||
|
||||
gateway.SetUsageRecorder(usageRecorder)
|
||||
// Initialize API handler
|
||||
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.POST("/keys/batch/:option", apiHandler.BatchApiKeys)
|
||||
|
||||
// Channel management
|
||||
// Channel management (legacy endpoints)
|
||||
apiGroup.GET("/channels", apiHandler.ListChannels)
|
||||
apiGroup.POST("/channels", apiHandler.CreateChannel)
|
||||
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.POST("/channels/:id/models", apiHandler.BindChannelModels)
|
||||
|
||||
// Model management
|
||||
// Model management (legacy endpoints)
|
||||
apiGroup.GET("/models", apiHandler.ListModels)
|
||||
apiGroup.POST("/models", apiHandler.CreateModel)
|
||||
apiGroup.PUT("/models/:id", apiHandler.UpdateModel)
|
||||
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
|
||||
|
||||
@@ -47,9 +47,9 @@ const iconForType = (type: ToastType) => {
|
||||
const typeClasses = (type: ToastType) => {
|
||||
switch (type) {
|
||||
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':
|
||||
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:
|
||||
return 'border-base-300 bg-base-100 text-base-content';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export type Channel = {
|
||||
name: string
|
||||
provider: string
|
||||
base_url: string
|
||||
base_urls?: Record<string, string>
|
||||
api_key_masked?: string
|
||||
weight: number
|
||||
priority: number
|
||||
timeout_ms: number
|
||||
@@ -31,6 +33,14 @@ export type NewChannelPayload = {
|
||||
formats?: string[]
|
||||
}
|
||||
|
||||
export type ChannelModelBinding = {
|
||||
id: number
|
||||
model_id: number
|
||||
model_name: string
|
||||
upstream_model: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export const useChannelStore = defineStore('channel', () => {
|
||||
const loading = ref(false);
|
||||
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 {
|
||||
loading, error,
|
||||
channel, channels, totalChannels,
|
||||
@@ -134,5 +229,11 @@ export const useChannelStore = defineStore('channel', () => {
|
||||
updateChannel,
|
||||
deleteChannel,
|
||||
batchChannels,
|
||||
testChannel,
|
||||
fetchRemoteModels,
|
||||
fetchChannelModels,
|
||||
addChannelModel,
|
||||
updateChannelModel,
|
||||
deleteChannelModel,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -115,3 +115,59 @@ export type NewUserPayload = {
|
||||
unlimited_quota?: boolean
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
KeyRoundIcon,
|
||||
SettingsIcon,
|
||||
GlobeIcon,
|
||||
BoxesIcon,
|
||||
SlidersHorizontalIcon,
|
||||
} from '@lucide/vue'
|
||||
|
||||
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/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: 'channels', name: 'Channels', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } },
|
||||
{ path: 'channels/view', name: 'ChannelView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } },
|
||||
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/ChannelsView.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[] = [
|
||||
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
||||
{ 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>
|
||||
@@ -87,6 +87,10 @@
|
||||
|
||||
<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="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">
|
||||
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
@@ -94,6 +98,47 @@
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -104,22 +149,59 @@
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
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 { useToast } from '@/composables/toast';
|
||||
|
||||
import { PlusIcon, TrashIcon } from '@lucide/vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const channelStore = useChannelStore();
|
||||
const { setToast } = useToast();
|
||||
const updating = ref(false);
|
||||
const testing = ref(false);
|
||||
const api_key = ref('');
|
||||
const bindings = ref<ChannelModelBinding[]>([]);
|
||||
|
||||
const channelId = computed(() => route.query.id);
|
||||
const ch = computed(() => channelStore.channel);
|
||||
@@ -127,9 +209,16 @@ const ch = computed(() => channelStore.channel);
|
||||
onMounted(async () => {
|
||||
if (channelId.value) {
|
||||
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 = () => {
|
||||
if (!ch.value) return;
|
||||
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 = () => {
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -9,7 +9,7 @@ import path from 'path'
|
||||
// 需要自签名 HTTPS 时设置 VITE_DEV_HTTPS=true
|
||||
const useHttps = process.env.VITE_DEV_HTTPS === 'true'
|
||||
// 后端地址:默认 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/
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user