后端 - 新增 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 端口
201 lines
4.7 KiB
Go
201 lines
4.7 KiB
Go
package usage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"opencatd-open/internal/dao"
|
|
"opencatd-open/internal/store"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Event represents a usage event to be recorded
|
|
type Event struct {
|
|
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
|
|
type Recorder struct {
|
|
usageDAO *dao.UsageDAO
|
|
dailyDAO *dao.DailyUsageDAO
|
|
ch chan Event
|
|
batchSize int
|
|
flushInterval time.Duration
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewRecorder creates a new usage recorder
|
|
func NewRecorder(usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Recorder {
|
|
return &Recorder{
|
|
usageDAO: usageDAO,
|
|
dailyDAO: dailyDAO,
|
|
ch: make(chan Event, 10000),
|
|
batchSize: 100,
|
|
flushInterval: 5 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Start starts the recorder's background workers
|
|
func (r *Recorder) Start(ctx context.Context) {
|
|
r.wg.Add(1)
|
|
go r.processLoop(ctx)
|
|
}
|
|
|
|
// Stop gracefully stops the recorder
|
|
func (r *Recorder) Stop() {
|
|
close(r.ch)
|
|
r.wg.Wait()
|
|
}
|
|
|
|
// Record queues a usage event for async recording
|
|
func (r *Recorder) Record(event Event) {
|
|
select {
|
|
case r.ch <- event:
|
|
default:
|
|
log.Printf("Usage channel full, dropping event for user %d model %s", event.UserID, event.ModelName)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) processLoop(ctx context.Context) {
|
|
defer r.wg.Done()
|
|
|
|
batch := make([]Event, 0, r.batchSize)
|
|
ticker := time.NewTicker(r.flushInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
if len(batch) > 0 {
|
|
r.flush(batch)
|
|
}
|
|
return
|
|
case event, ok := <-r.ch:
|
|
if !ok {
|
|
if len(batch) > 0 {
|
|
r.flush(batch)
|
|
}
|
|
return
|
|
}
|
|
batch = append(batch, event)
|
|
if len(batch) >= r.batchSize {
|
|
r.flush(batch)
|
|
batch = make([]Event, 0, r.batchSize)
|
|
}
|
|
case <-ticker.C:
|
|
if len(batch) > 0 {
|
|
r.flush(batch)
|
|
batch = make([]Event, 0, r.batchSize)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) flush(events []Event) {
|
|
if len(events) == 0 {
|
|
return
|
|
}
|
|
|
|
// Batch create usage logs
|
|
logs := make([]*store.UsageLog, 0, len(events))
|
|
|
|
for _, e := range events {
|
|
status := store.UsageStatusSuccess
|
|
if e.IsError {
|
|
status = store.UsageStatusError
|
|
}
|
|
if e.IsCanceled {
|
|
status = store.UsageStatusCanceled
|
|
}
|
|
|
|
var errCode *string
|
|
if e.ErrorCode != "" {
|
|
errCode = &e.ErrorCode
|
|
}
|
|
|
|
log := &store.UsageLog{
|
|
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)
|
|
}
|
|
|
|
// Write to database
|
|
if err := r.usageDAO.BatchCreate(context.Background(), logs); err != nil {
|
|
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))
|
|
}
|