Files
cpa-plugin/internal/plugin/management.go
T
chuan 6a461bf1f3 feat: 添加持久化用量计费与管理面板
- 使用 SQLite 保存关联后的请求生命周期和用量记录
- 支持长上下文阶梯价格和可配置的 Fast 计费倍率
- 添加管理接口和可配置列的用量面板
- 保留失败、取消、重试和 compact 请求,便于计费核对
2026-08-14 21:08:02 +08:00

211 lines
7.3 KiB
Go

package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"cpa-ext/internal/web"
)
const (
managementBase = "/v0/management/plugins/" + PluginName
resourceBase = "/v0/resource/plugins/" + PluginName
routeUsage = "/usage"
routePrices = "/prices"
resourceUI = "/ui"
)
func managementRegistration() ManagementRegistrationResponse {
return ManagementRegistrationResponse{
Routes: []ManagementRoute{
{Method: http.MethodGet, Path: managementBase + routeUsage, Description: "查看最近的用量记录。"},
{Method: http.MethodGet, Path: managementBase + routePrices, Description: "查看模型价格。"},
{Method: http.MethodPut, Path: managementBase + routePrices, Description: "保存模型价格。"},
{Method: http.MethodDelete, Path: managementBase + routePrices, Description: "删除模型价格。"},
},
Resources: []ResourceRoute{
{Path: resourceBase + resourceUI, Menu: "用量记录", Description: "查看 CPA 最近收到的请求用量。"},
},
}
}
func (a *App) handleManagement(raw []byte) ([]byte, error) {
var req ManagementRequest
if err := json.Unmarshal(raw, &req); err != nil {
return nil, fmt.Errorf("解析管理请求: %w", err)
}
path := strings.TrimRight(req.Path, "/")
if req.Method == http.MethodGet && path == resourceBase+resourceUI {
return OKEnvelope(ManagementResponse{
StatusCode: http.StatusOK,
Headers: http.Header{
"Content-Type": []string{"text/html; charset=utf-8"},
"Cache-Control": []string{"no-store"},
},
Body: web.UI(),
})
}
if req.Method == http.MethodGet && path == managementBase+routeUsage {
return OKEnvelope(a.usageResponse())
}
if path == managementBase+routePrices {
switch req.Method {
case http.MethodGet:
return OKEnvelope(a.listPrices())
case http.MethodPut:
return OKEnvelope(a.putPrice(req.Body))
case http.MethodDelete:
return OKEnvelope(a.deletePrice(req.Body))
}
}
return OKEnvelope(jsonManagementResponse(http.StatusNotFound, map[string]any{
"error": map[string]string{
"code": "not_found",
"message": "管理路由不存在: " + req.Method + " " + req.Path,
},
}))
}
type usageListResponse struct {
Records []usageListItem `json:"records"`
Retained int `json:"retained"`
}
// usageListItem 是请求明细表的稳定接口。当前回调拿不到的字段保留为空值。
type usageListItem struct {
RequestID string `json:"request_id"`
ExecutionID string `json:"execution_id"`
TraceID string `json:"trace_id"`
RequestedAt time.Time `json:"requested_at"`
APIKey string `json:"api_key"`
KeyAlias string `json:"key_alias"`
Model string `json:"model"`
ReasoningEffort string `json:"reasoning_effort"`
ServiceTier string `json:"service_tier"`
Speed string `json:"speed"`
Failed bool `json:"failed"`
Outcome string `json:"outcome"`
StatusCode int `json:"status_code"`
Error string `json:"error"`
ExecutorType string `json:"executor_type"`
RequestType string `json:"request_type"`
Endpoint string `json:"endpoint"`
TTFTMilliseconds int64 `json:"ttft_ms"`
SpeedTPS *float64 `json:"speed_tps"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
CacheRate *float64 `json:"cache_rate"`
TotalTokens int64 `json:"total_tokens"`
CostUSD *float64 `json:"cost_usd"`
CostAvailable bool `json:"cost_available"`
PriceTier string `json:"price_tier"`
FastRequested bool `json:"fast_requested"`
FastPricingApplied bool `json:"fast_pricing_applied"`
ClientIP string `json:"client_ip"`
}
func (a *App) usageResponse() ManagementResponse {
a.mu.RLock()
defer a.mu.RUnlock()
if a.usage == nil {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{
"error": map[string]string{"code": "database_unavailable", "message": "用量数据库尚未初始化"},
})
}
records, err := a.usage.Recent(context.Background(), 1000)
if err != nil {
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{
"error": map[string]string{"code": "database_error", "message": err.Error()},
})
}
items := make([]usageListItem, 0, len(records))
for _, record := range records {
ttftMilliseconds := record.TTFT.Milliseconds()
speedTPS := usageSpeed(record.OutputTokens, record.TTFT, record.Latency)
if isCompactEndpoint(record.Endpoint) {
// Compact 返回的是一次性 JSON,首字延迟和生成速度没有可比较的含义。
ttftMilliseconds = 0
speedTPS = nil
}
items = append(items, usageListItem{
RequestID: record.RequestID,
ExecutionID: record.ExecutionID,
TraceID: record.TraceID,
RequestedAt: record.RequestedAt,
APIKey: record.APIKey,
KeyAlias: record.KeyAlias,
Model: record.Model,
ReasoningEffort: record.ReasoningEffort,
ServiceTier: record.ServiceTier,
Speed: record.Speed,
Failed: record.Failed,
Outcome: record.Outcome,
StatusCode: record.StatusCode,
Error: record.Error,
ExecutorType: record.ExecutorType,
RequestType: record.RequestType,
Endpoint: record.Endpoint,
TTFTMilliseconds: ttftMilliseconds,
SpeedTPS: speedTPS,
InputTokens: record.InputTokens,
OutputTokens: record.OutputTokens,
ReasoningTokens: record.ReasoningTokens,
CacheReadTokens: record.CacheReadTokens,
CacheWriteTokens: record.CacheWriteTokens,
CacheRate: usageCacheRate(record.CacheReadTokens, record.InputTokens),
TotalTokens: record.TotalTokens,
CostUSD: microsToUSD(record.CostMicros),
CostAvailable: record.CostMicros != nil,
PriceTier: record.PriceTier,
FastRequested: record.FastRequested,
FastPricingApplied: record.FastPricingApplied,
ClientIP: record.ClientIP,
})
}
return jsonManagementResponse(http.StatusOK, usageListResponse{Records: items, Retained: 1000})
}
func microsToUSD(micros *int64) *float64 {
if micros == nil {
return nil
}
value := float64(*micros) / 1_000_000
return &value
}
func usageSpeed(outputTokens int64, ttft, latency time.Duration) *float64 {
if outputTokens <= 0 || ttft <= 0 || latency <= ttft {
return nil
}
value := float64(outputTokens) / (latency - ttft).Seconds()
return &value
}
func usageCacheRate(cacheReadTokens, inputTokens int64) *float64 {
if inputTokens <= 0 {
return nil
}
value := float64(cacheReadTokens) / float64(inputTokens) * 100
return &value
}
func jsonManagementResponse(status int, value any) ManagementResponse {
body, err := json.Marshal(value)
if err != nil {
body = []byte(`{"error":{"code":"marshal_error","message":"无法生成响应"}}`)
status = http.StatusInternalServerError
}
return ManagementResponse{
StatusCode: status,
Headers: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
Body: body,
}
}