Files
cpa-plugin/internal/plugin/management.go
T

427 lines
17 KiB
Go

package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"billing/internal/collection"
"billing/internal/web"
)
const (
managementBase = "/v0/management/plugins/" + PluginName
resourceBase = "/v0/resource/plugins/" + PluginName
routeUsage = "/usage"
routeUsageSummary = "/usage-summary"
routePrices = "/prices"
routePriceImport = "/prices/import"
routeCatalog = "/price-catalog"
routeCatalogRefresh = "/price-catalog/refresh"
routeCatalogApply = "/price-catalog/apply"
routeKeys = "/keys"
routeKeyStats = "/key-stats"
routeUpstreams = "/upstreams"
routeModels = "/model-suggestions"
routeBillingReset = "/billing-reset"
routeBillingLedger = "/billing-ledger"
routeEvents = "/events"
resourceUI = "/ui"
)
var resourceAssets = []string{
"/ui-config.js",
"/app/main.js",
"/app/core/runtime.js",
"/app/core/shared.js",
"/app/features/keys.js",
"/app/features/pricing.js",
"/app/features/usage.js",
"/app/features/logs.js",
"/styles/base.css",
"/styles/keys.css",
"/styles/layout.css",
"/styles/pricing.css",
"/styles/responsive.css",
"/styles/usage.css",
}
func managementRegistration() ManagementRegistrationResponse {
registration := ManagementRegistrationResponse{
Routes: []ManagementRoute{
{Method: http.MethodGet, Path: managementBase + routeUsage, Description: "查看最近的用量记录。"},
{Method: http.MethodGet, Path: managementBase + routeUsageSummary, Description: "查看用户与每日用量汇总。"},
{Method: http.MethodGet, Path: managementBase + routePrices, Description: "查看模型价格。"},
{Method: http.MethodPut, Path: managementBase + routePrices, Description: "保存模型价格。"},
{Method: http.MethodDelete, Path: managementBase + routePrices, Description: "删除模型价格。"},
{Method: http.MethodPost, Path: managementBase + routePriceImport, Description: "从 models.dev 导入参考价格。"},
{Method: http.MethodGet, Path: managementBase + routeCatalog, Description: "搜索 models.dev 参考价格。"},
{Method: http.MethodPost, Path: managementBase + routeCatalogRefresh, Description: "刷新 models.dev 价格目录并预览变化。"},
{Method: http.MethodPost, Path: managementBase + routeCatalogApply, Description: "确认应用参考价格变化。"},
{Method: http.MethodGet, Path: managementBase + routeKeys, Description: "查看下游 Key。"},
{Method: http.MethodPost, Path: managementBase + routeKeys, Description: "创建下游 Key。"},
{Method: http.MethodPatch, Path: managementBase + routeKeys, Description: "更新下游 Key。"},
{Method: http.MethodDelete, Path: managementBase + routeKeys, Description: "归档下游 Key。"},
{Method: http.MethodGet, Path: managementBase + routeKeyStats, Description: "查看 Key 统计。"},
{Method: http.MethodGet, Path: managementBase + routeUpstreams, Description: "同步并查看上游账号。"},
{Method: http.MethodGet, Path: managementBase + routeModels, Description: "查看模型建议。"},
{Method: http.MethodPost, Path: managementBase + routeBillingReset, Description: "立即重置 Key 额度。"},
{Method: http.MethodGet, Path: managementBase + routeBillingLedger, Description: "查看 Key 额度账目。"},
{Method: http.MethodGet, Path: managementBase + routeEvents, Description: "查看业务事件日志。"},
},
Resources: []ResourceRoute{
{Path: resourceBase + resourceUI, Menu: "用量记录", Description: "查看 CPA 最近收到的请求用量。"},
},
}
for _, path := range resourceAssets {
registration.Resources = append(registration.Resources, ResourceRoute{Path: resourceBase + path})
}
return registration
}
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 {
if strings.TrimSpace(req.Query.Get("view")) != "" {
return OKEnvelope(a.readOnlyResponse(req.Query))
}
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 {
name := strings.TrimPrefix(path, resourceBase+"/")
if body, contentType, found := web.Asset(name); found {
return OKEnvelope(staticResourceResponse(contentType, body))
}
}
if req.Method == http.MethodGet && path == managementBase+routeUsage {
return OKEnvelope(a.usageResponse(req.Query))
}
if req.Method == http.MethodGet && path == managementBase+routeUsageSummary {
return OKEnvelope(a.usageDashboardResponse())
}
if path == managementBase+routePrices {
switch req.Method {
case http.MethodGet:
return OKEnvelope(a.listPrices())
case http.MethodPut:
return OKEnvelope(a.auditFailedManagement(a.putPrice(req.Body), priceEvent(req.Body, "保存")))
case http.MethodDelete:
return OKEnvelope(a.auditFailedManagement(a.deletePrice(req.Body), priceEvent(req.Body, "删除")))
}
}
if req.Method == http.MethodPost && path == managementBase+routePriceImport {
return OKEnvelope(a.auditFailedManagement(a.importCatalogPrice(req.Body), priceEvent(req.Body, "导入")))
}
if req.Method == http.MethodGet && path == managementBase+routeCatalog {
return OKEnvelope(a.searchPriceCatalog(req.Query))
}
if req.Method == http.MethodPost && path == managementBase+routeCatalogRefresh {
return OKEnvelope(a.refreshPriceCatalog())
}
if req.Method == http.MethodPost && path == managementBase+routeCatalogApply {
return OKEnvelope(a.auditFailedManagement(a.applyCatalogChanges(req.Body), "管理员批量更新模型价格"))
}
if path == managementBase+routeKeys {
switch req.Method {
case http.MethodGet:
return OKEnvelope(a.listManagedKeys(req.Query.Get("include_archived") == "1"))
case http.MethodPost:
return OKEnvelope(a.auditFailedManagement(a.createManagedKey(req.Body), keyEvent(req.Body, "创建")))
case http.MethodPatch:
return OKEnvelope(a.auditFailedManagement(a.updateManagedKey(req.Body), keyEvent(req.Body, "修改")))
case http.MethodDelete:
return OKEnvelope(a.auditFailedManagement(a.archiveManagedKey(req.Body), keyEvent(req.Body, "归档")))
}
}
if req.Method == http.MethodGet && path == managementBase+routeKeyStats {
return OKEnvelope(a.managedKeyStats(strings.TrimSpace(req.Query.Get("id"))))
}
if req.Method == http.MethodGet && path == managementBase+routeUpstreams {
return OKEnvelope(a.upstreamAccounts())
}
if req.Method == http.MethodGet && path == managementBase+routeModels {
return OKEnvelope(a.modelSuggestions())
}
if req.Method == http.MethodPost && path == managementBase+routeBillingReset {
return OKEnvelope(a.auditFailedManagement(a.resetManagedKeyBilling(req.Body), billingResetEvent(req.Body)))
}
if req.Method == http.MethodGet && path == managementBase+routeBillingLedger {
return OKEnvelope(a.managedKeyLedger(req.Query))
}
if req.Method == http.MethodGet && path == managementBase+routeEvents {
return OKEnvelope(a.businessEvents(req.Query))
}
return OKEnvelope(jsonManagementResponse(http.StatusNotFound, map[string]any{
"error": map[string]string{
"code": "not_found",
"message": "管理路由不存在: " + req.Method + " " + req.Path,
},
}))
}
func staticResourceResponse(contentType string, body []byte) ManagementResponse {
return ManagementResponse{
StatusCode: http.StatusOK,
Headers: http.Header{
"Content-Type": []string{contentType},
"Cache-Control": []string{"no-store"},
},
Body: body,
}
}
type usageListResponse struct {
Records []usageListItem `json:"records"`
Pagination usagePaginationResult `json:"pagination"`
}
type usagePaginationResult struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
PreviousCursor string `json:"previous_cursor,omitempty"`
NextCursor string `json:"next_cursor,omitempty"`
}
// 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"`
AuthID string `json:"auth_id"`
AuthIndex string `json:"auth_index"`
AuthType string `json:"auth_type"`
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(values url.Values) ManagementResponse {
query, err := parseUsageQuery(values)
if err != nil {
return managementError(http.StatusBadRequest, "invalid_query", err.Error())
}
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": "用量数据库尚未初始化"},
})
}
page, err := a.usage.Query(context.Background(), query)
if err != nil {
status := http.StatusInternalServerError
code := "database_error"
if strings.Contains(err.Error(), "cursor") || strings.Contains(err.Error(), "page_size") || strings.Contains(err.Error(), "from") || strings.Contains(err.Error(), "result") {
status = http.StatusBadRequest
code = "invalid_query"
}
return jsonManagementResponse(status, map[string]any{
"error": map[string]string{"code": code, "message": err.Error()},
})
}
items := make([]usageListItem, 0, len(page.Records))
for _, record := range page.Records {
items = append(items, usageItem(record))
}
return jsonManagementResponse(http.StatusOK, usageListResponse{Records: items, Pagination: usagePaginationResult{
Page: page.Page, PageSize: page.PageSize, Total: page.Total, TotalPages: page.TotalPages,
PreviousCursor: page.PreviousCursor, NextCursor: page.NextCursor,
}})
}
func parseUsageQuery(values url.Values) (collection.UsageQuery, error) {
query := collection.UsageQuery{Page: 1, PageSize: 100, Cursor: strings.TrimSpace(values.Get("cursor")), KeyID: values.Get("key_id"), Model: values.Get("model"), Result: values.Get("result"), AuthID: values.Get("auth_id"), Endpoint: values.Get("endpoint"), RequestID: values.Get("request_id")}
if raw := strings.TrimSpace(values.Get("page")); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
return query, fmt.Errorf("page 必须是正整数")
}
query.Page = value
}
if raw := strings.TrimSpace(values.Get("page_size")); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > 100 {
return query, fmt.Errorf("page_size 必须在 1 到 100 之间")
}
query.PageSize = value
}
for name, target := range map[string]**time.Time{"from": &query.From, "to": &query.To} {
raw := strings.TrimSpace(values.Get(name))
if raw == "" {
continue
}
parsed, err := time.Parse(time.RFC3339Nano, raw)
if err != nil {
return query, fmt.Errorf("%s 必须是 RFC3339 时间", name)
}
*target = &parsed
}
return query, nil
}
type usageSummaryDTO struct {
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
CostUSD float64 `json:"cost_usd"`
}
func usageSummaryItem(summary collection.UsageSummary) usageSummaryDTO {
return usageSummaryDTO{Requests: summary.Requests, InputTokens: summary.InputTokens, OutputTokens: summary.OutputTokens, TotalTokens: summary.TotalTokens, CostUSD: float64(summary.CostMicros) / 1_000_000}
}
func (a *App) usageDashboardResponse() ManagementResponse {
a.mu.RLock()
defer a.mu.RUnlock()
if a.usage == nil {
return managementError(http.StatusServiceUnavailable, "database_unavailable", "用量数据库尚未初始化")
}
location, _ := time.LoadLocation("Asia/Shanghai")
dashboard, err := a.usage.Dashboard(context.Background(), time.Now().In(location), 7)
if err != nil {
return managementError(http.StatusInternalServerError, "database_error", err.Error())
}
users := make([]map[string]any, 0, len(dashboard.Users))
for _, user := range dashboard.Users {
userDays := make([]map[string]any, 0, len(user.Days))
for _, day := range user.Days {
userDays = append(userDays, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens, "cost_usd": float64(day.CostMicros) / 1_000_000})
}
users = append(users, map[string]any{"key_id": user.KeyID, "key_alias": user.KeyAlias, "today": usageSummaryItem(user.Today), "days": userDays, "last_used_at": user.LastUsedAt})
}
days := make([]map[string]any, 0, len(dashboard.Days))
for _, day := range dashboard.Days {
days = append(days, map[string]any{"date": day.Date.Format("2006-01-02"), "total_tokens": day.TotalTokens, "cost_usd": float64(day.CostMicros) / 1_000_000})
}
return jsonManagementResponse(http.StatusOK, map[string]any{"today": usageSummaryItem(dashboard.Today), "users": users, "days": days})
}
func usageItem(record collection.Record) usageListItem {
ttftMilliseconds := record.TTFT.Milliseconds()
speedTPS := usageSpeed(record.OutputTokens, record.TTFT, record.Latency)
if isCompactEndpoint(record.Endpoint) {
// Compact 返回的是一次性 JSON,首字延迟和生成速度没有可比较的含义。
ttftMilliseconds = 0
speedTPS = nil
}
return usageListItem{
RequestID: record.RequestID,
ExecutionID: record.ExecutionID,
TraceID: record.TraceID,
RequestedAt: record.RequestedAt,
APIKey: record.APIKey,
KeyAlias: record.KeyAlias,
AuthID: record.AuthID,
AuthIndex: record.AuthIndex,
AuthType: record.AuthType,
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,
}
}
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,
}
}