feat: 添加持久化用量计费与管理面板

- 使用 SQLite 保存关联后的请求生命周期和用量记录
- 支持长上下文阶梯价格和可配置的 Fast 计费倍率
- 添加管理接口和可配置列的用量面板
- 保留失败、取消、重试和 compact 请求,便于计费核对
This commit is contained in:
2026-08-14 21:08:02 +08:00
parent 42be14c8d0
commit 6a461bf1f3
26 changed files with 2809 additions and 31 deletions
+4
View File
@@ -8,3 +8,7 @@
coverage.out
.cache/
/.runtime/
/CLIProxyAPI/
/cpa-plugin-key-billing/
/cpa-usage-keeper/
+1 -1
View File
@@ -3,7 +3,7 @@
当前项目的本质是对 `CLIProxyAPI` 的核心扩展,功能边界会非常收敛
- 更好的统计、展示
- 初期专注于codex
- 初期优先保证codex的正常使用
- 能最简单的管理多用户key
## 参考
+1 -1
View File
@@ -6,4 +6,4 @@ plugins:
enabled: true
priority: 100
codex_only: true
database_path: data/cpa-ext.db
+2 -2
View File
@@ -271,7 +271,7 @@ Reasoning Token 用于后台明细,但若上游语义表明其已经包含在
### 5.3 Fast / priority 2.5×
Fast/priority 是金额倍率,不是另一种余额单位:
Fast/priority 是金额倍率,不是另一种余额单位。OpenAI/Codex 通过 `service_tier=priority` 表达,Anthropic 通过 `speed=fast` 表达
```text
Fast 最终金额 = 基础金额 × 2.5
@@ -286,7 +286,7 @@ effective_service_tier =
否则 standard
```
然后只应用一次倍率。倍率必须保存在账本价格快照中。
两种表达先归一化为一个 `fast_requested` 事实,然后只应用一次倍率。价格配置可以关闭 Fast 加价;关闭后请求仍使用 Fast,但金额不乘倍率。倍率和是否实际应用必须保存在账本价格快照中。
Fast 规则必须是版本化价格政策的一部分,不能依赖管理员每次手工补规则。实现时需要用当前 OpenAI 官方资料再次核对支持模型和倍率。
+2 -2
View File
@@ -221,7 +221,7 @@ Reasoning Token 若已经包含在输出中,不增加第五段价格。后台
## 6. Fast / priority 政策
Fast 是金额倍率,不是独立余额单位。GPT-5.6 Fast/priority 的当前产品政策保存为版本化 `5/2`,只应用一次。
Fast 是金额倍率,不是独立余额单位。GPT-5.6 Fast/priority 的当前产品政策保存为版本化 `5/2`,只应用一次。请求可能使用 OpenAI/Codex 的 `service_tier=priority`,也可能使用 Anthropic 的 `speed=fast`。采集层必须把两种输入和最终上游请求中的 Fast 状态归一化为一个 `fast_requested` 事实。
首先合并请求和响应事实:
@@ -232,7 +232,7 @@ effective_service_tier =
否则 standard
```
然后只对 `effective_service_tier=priority` 应用一次倍率。禁止同时配置:
`effective_service_tier=priority` 或请求 `speed=fast` 都表示 Fast。价格政策中的 `fast_pricing_enabled` 决定是否应用倍率,因此 Fast 请求可以按标准价格结算。禁止同时配置:
- `service_tier=priority × 2.5`;以及
- `response_service_tier=priority × 2.5`
+4 -1
View File
@@ -2,4 +2,7 @@ module cpa-ext
go 1.24
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/mattn/go-sqlite3 v1.14.48
gopkg.in/yaml.v3 v3.0.1
)
+2
View File
@@ -1,3 +1,5 @@
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+58
View File
@@ -0,0 +1,58 @@
// Package collection 定义 CPA 用量观察及其采集服务。
package collection
import "time"
// Record 是当前请求明细功能保存的用量观察。
// 它不包含汇总结果,暂时拿不到的字段使用零值或 NULL。
type Record struct {
RequestID string
ExecutionID string
TraceID string
RequestedAt time.Time
APIKey string
KeyAlias string
Model string
ReasoningEffort string
ServiceTier string
Speed string
Failed bool
ExecutorType string
RequestType string
Endpoint string
InputTokens int64
OutputTokens int64
TotalTokens int64
CachedTokens int64
CacheReadTokens int64
CacheWriteTokens int64
ReasoningTokens int64
TTFT time.Duration
Latency time.Duration
CostMicros *int64
PriceTier string
FastRequested bool
FastPricingApplied bool
PriceMultiplierNumerator int64
PriceMultiplierDenominator int64
ClientIP string
Outcome string
StatusCode int
Error string
}
// RequestRecord 保存一次模型执行的终态,即使没有产生 Usage 也必须存在。
type RequestRecord struct {
RequestID string
TraceID string
RequestedAt time.Time
CompletedAt time.Time
Model string
RequestedModel string
SourceFormat string
Stream bool
Outcome string
StatusCode int
Error string
Endpoint string
}
+36
View File
@@ -0,0 +1,36 @@
package collection
import "context"
// Repository 是采集模块需要的最小持久化接口。
type Repository interface {
Insert(context.Context, Record) error
UpsertRequest(context.Context, RequestRecord) error
ListRecent(context.Context, int) ([]Record, error)
Close() error
}
// Service 负责保存观察和读取请求明细,不包含 SQL 实现。
type Service struct {
repository Repository
}
func NewService(repository Repository) *Service {
return &Service{repository: repository}
}
func (s *Service) Observe(ctx context.Context, record Record) error {
return s.repository.Insert(ctx, record)
}
func (s *Service) ObserveRequest(ctx context.Context, record RequestRecord) error {
return s.repository.UpsertRequest(ctx, record)
}
func (s *Service) Recent(ctx context.Context, limit int) ([]Record, error) {
return s.repository.ListRecent(ctx, limit)
}
func (s *Service) Close() error {
return s.repository.Close()
}
+144
View File
@@ -0,0 +1,144 @@
// Package data 定义 cpa-ext 各模块共享且与 Provider 无关的数据事实。
package data
import (
"errors"
"fmt"
"math"
"strings"
"time"
)
// UsageQuality 表示 Provider Token 数据经过规范化后的可信程度。
// 使用方必须检查该值,不能把所有零值都当成真实测量结果。
type UsageQuality string
const (
UsageQualityComplete UsageQuality = "complete"
UsageQualityNormalized UsageQuality = "normalized"
UsageQualityPartial UsageQuality = "partial"
UsageQualityInconsistent UsageQuality = "inconsistent"
UsageQualityUnclassified UsageQuality = "unclassified"
UsageQualityMissing UsageQuality = "missing"
)
// TokenUsage 保存互不重叠的 Token 分项。每个 Token 只能进入一个分项,
// 防止计价和统计重复计算。
type TokenUsage struct {
UncachedInputTokens int64
CacheReadTokens int64
CacheCreationTokens int64
NonReasoningTokens int64
ReasoningTokens int64
UnclassifiedTokens int64
TotalTokens int64
}
// Usage 是与 Provider 无关的最小用量事实。该类型不保存凭证、认证请求头、
// 请求体或响应内容。
type Usage struct {
Provider string
Model string
RequestedAt time.Time
Generated bool
Failed bool
Quality UsageQuality
Tokens TokenUsage
}
// Validate 检查 Usage 的结构约束,但不会修改观测到的数据。
// inconsistent 类型允许保留互相矛盾的总数和分项,供后续诊断使用。
func (u Usage) Validate() error {
if strings.TrimSpace(u.Provider) == "" {
return errors.New("usage provider is required")
}
if strings.TrimSpace(u.Model) == "" {
return errors.New("usage model is required")
}
if u.RequestedAt.IsZero() {
return errors.New("usage requested time is required")
}
if !u.Quality.valid() {
return fmt.Errorf("unknown usage quality %q", u.Quality)
}
return u.Tokens.validate(u.Quality)
}
func (q UsageQuality) valid() bool {
switch q {
case UsageQualityComplete,
UsageQualityNormalized,
UsageQualityPartial,
UsageQualityInconsistent,
UsageQualityUnclassified,
UsageQualityMissing:
return true
default:
return false
}
}
func (t TokenUsage) validate(quality UsageQuality) error {
counts := []struct {
name string
value int64
}{
{"uncached input tokens", t.UncachedInputTokens},
{"cache read tokens", t.CacheReadTokens},
{"cache creation tokens", t.CacheCreationTokens},
{"non-reasoning output tokens", t.NonReasoningTokens},
{"reasoning tokens", t.ReasoningTokens},
{"unclassified tokens", t.UnclassifiedTokens},
{"total tokens", t.TotalTokens},
}
for _, count := range counts {
if count.value < 0 {
return fmt.Errorf("%s cannot be negative", count.name)
}
}
sum, ok := t.bucketSum()
if !ok {
return errors.New("token bucket sum overflows int64")
}
switch quality {
case UsageQualityMissing:
if sum != 0 || t.TotalTokens != 0 {
return errors.New("missing usage cannot contain token counts")
}
case UsageQualityComplete, UsageQualityNormalized:
if t.UnclassifiedTokens != 0 {
return errors.New("classified usage cannot contain unclassified tokens")
}
if sum != t.TotalTokens {
return errors.New("classified token buckets do not equal total tokens")
}
case UsageQualityPartial, UsageQualityUnclassified:
if sum != t.TotalTokens {
return errors.New("token buckets do not equal total tokens")
}
case UsageQualityInconsistent:
// 矛盾的计数需要作为明确的诊断事实保留下来。
}
return nil
}
func (t TokenUsage) bucketSum() (int64, bool) {
values := [...]int64{
t.UncachedInputTokens,
t.CacheReadTokens,
t.CacheCreationTokens,
t.NonReasoningTokens,
t.ReasoningTokens,
t.UnclassifiedTokens,
}
var sum int64
for _, value := range values {
if value > math.MaxInt64-sum {
return 0, false
}
sum += value
}
return sum, true
}
+146
View File
@@ -0,0 +1,146 @@
package data_test
import (
"math"
"testing"
"time"
"cpa-ext/internal/data"
)
func validUsage() data.Usage {
return data.Usage{
Provider: "codex",
Model: "gpt-5.5",
RequestedAt: time.Date(2026, time.August, 14, 8, 0, 0, 0, time.UTC),
Generated: true,
Quality: data.UsageQualityComplete,
Tokens: data.TokenUsage{
UncachedInputTokens: 10,
CacheReadTokens: 20,
CacheCreationTokens: 30,
NonReasoningTokens: 40,
ReasoningTokens: 50,
TotalTokens: 150,
},
}
}
func TestUsageValidateAcceptsCompleteBreakdown(t *testing.T) {
t.Parallel()
if err := validUsage().Validate(); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
func TestUsageDistinguishesMissingFromMeasuredZero(t *testing.T) {
t.Parallel()
measured := validUsage()
measured.Tokens = data.TokenUsage{}
if err := measured.Validate(); err != nil {
t.Fatalf("measured zero Validate() error = %v", err)
}
missing := measured
missing.Quality = data.UsageQualityMissing
if err := missing.Validate(); err != nil {
t.Fatalf("missing Validate() error = %v", err)
}
missing.Tokens.TotalTokens = 1
if err := missing.Validate(); err == nil {
t.Fatal("missing usage with tokens unexpectedly passed validation")
}
}
func TestUsageValidateRejectsInvalidIdentityAndQuality(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*data.Usage)
}{
{"missing provider", func(u *data.Usage) { u.Provider = " " }},
{"missing model", func(u *data.Usage) { u.Model = "" }},
{"missing requested time", func(u *data.Usage) { u.RequestedAt = time.Time{} }},
{"unknown quality", func(u *data.Usage) { u.Quality = "other" }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
usage := validUsage()
test.mutate(&usage)
if err := usage.Validate(); err == nil {
t.Fatal("Validate() unexpectedly succeeded")
}
})
}
}
func TestUsageValidateRejectsInvalidTokenBreakdown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*data.Usage)
}{
{"negative token", func(u *data.Usage) { u.Tokens.CacheReadTokens = -1 }},
{"wrong total", func(u *data.Usage) { u.Tokens.TotalTokens++ }},
{"unclassified complete", func(u *data.Usage) {
u.Tokens.UnclassifiedTokens = 1
u.Tokens.TotalTokens++
}},
{"overflow", func(u *data.Usage) {
u.Tokens = data.TokenUsage{
UncachedInputTokens: math.MaxInt64,
CacheReadTokens: 1,
TotalTokens: math.MaxInt64,
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
usage := validUsage()
test.mutate(&usage)
if err := usage.Validate(); err == nil {
t.Fatal("Validate() unexpectedly succeeded")
}
})
}
}
func TestUsageValidatePreservesExplicitQualityStates(t *testing.T) {
t.Parallel()
normalized := validUsage()
normalized.Quality = data.UsageQualityNormalized
if err := normalized.Validate(); err != nil {
t.Fatalf("normalized Validate() error = %v", err)
}
partial := validUsage()
partial.Quality = data.UsageQualityPartial
if err := partial.Validate(); err != nil {
t.Fatalf("partial Validate() error = %v", err)
}
unclassified := validUsage()
unclassified.Quality = data.UsageQualityUnclassified
unclassified.Tokens.UnclassifiedTokens = 5
unclassified.Tokens.TotalTokens += 5
if err := unclassified.Validate(); err != nil {
t.Fatalf("unclassified Validate() error = %v", err)
}
inconsistent := validUsage()
inconsistent.Quality = data.UsageQualityInconsistent
inconsistent.Tokens.TotalTokens++
if err := inconsistent.Validate(); err != nil {
t.Fatalf("inconsistent Validate() error = %v", err)
}
}
+161 -13
View File
@@ -1,25 +1,31 @@
package plugin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"cpa-ext/internal/collection"
"cpa-ext/internal/pricing"
"cpa-ext/internal/repository"
)
type App struct {
config atomic.Pointer[Config]
mu sync.Mutex
mu sync.RWMutex
config Config
usage *collection.Service
store *repository.SQLiteUsageRepository
prices map[string]pricing.Policy
closed bool
seen atomic.Uint64
}
func NewApp() *App {
a := &App{}
cfg := defaultConfig()
a.config.Store(&cfg)
return a
return &App{config: defaultConfig(), prices: make(map[string]pricing.Policy)}
}
func (a *App) HandleMethod(method string, request []byte) (response []byte, err error) {
@@ -35,6 +41,12 @@ func (a *App) HandleMethod(method string, request []byte) (response []byte, err
return a.configure(request)
case MethodUsageHandle:
return a.handleUsage(request)
case MethodRequestComplete:
return a.handleRequestComplete(request)
case MethodManagementRegister:
return OKEnvelope(managementRegistration())
case MethodManagementHandle:
return a.handleManagement(request)
case MethodPluginShutdown:
a.Shutdown()
return OKEnvelope(struct{}{})
@@ -58,13 +70,35 @@ func (a *App) configure(raw []byte) ([]byte, error) {
if err != nil {
return nil, err
}
usageRepository, err := repository.OpenSQLiteUsage(cfg.DatabasePath)
if err != nil {
return nil, err
}
nextUsage := collection.NewService(usageRepository)
policies, err := usageRepository.ListPrices(context.Background())
if err != nil {
_ = nextUsage.Close()
return nil, err
}
nextPrices := make(map[string]pricing.Policy, len(policies))
for _, policy := range policies {
nextPrices[policy.Model] = policy
}
a.mu.Lock()
defer a.mu.Unlock()
if a.closed {
_ = nextUsage.Close()
return nil, fmt.Errorf("插件已经关闭")
}
a.config.Store(&cfg)
previousUsage := a.usage
a.config = cfg
a.usage = nextUsage
a.store = usageRepository
a.prices = nextPrices
if previousUsage != nil {
_ = previousUsage.Close()
}
return OKEnvelope(registration(negotiated))
}
@@ -79,9 +113,10 @@ func registration(schemaVersion uint32) Registration {
ConfigFields: []ConfigField{
{Name: "enabled", Type: "boolean", Description: "启用 CPA 扩展。"},
{Name: "codex_only", Type: "boolean", Description: "只接收 Codex/OpenAI 模型的用量事件。"},
{Name: "database_path", Type: "string", Description: "SQLite 数据库文件路径。"},
},
},
Capabilities: Capabilities{UsagePlugin: true},
Capabilities: Capabilities{RequestLifecyclePlugin: true, UsagePlugin: true, ManagementAPI: true},
}
}
@@ -90,21 +125,134 @@ func (a *App) handleUsage(raw []byte) ([]byte, error) {
if err := json.Unmarshal(raw, &record); err != nil {
return nil, fmt.Errorf("解析用量事件: %w", err)
}
cfg := a.config.Load()
if cfg != nil && cfg.accepts(record) {
// The first milestone only proves ingestion. Persistence and aggregation
// belong in a separate package added behind this boundary.
a.mu.RLock()
defer a.mu.RUnlock()
if a.closed {
return nil, fmt.Errorf("插件已经关闭")
}
if a.config.accepts(record) {
if a.usage == nil {
return nil, fmt.Errorf("用量数据库尚未初始化")
}
// CPA wire 类型只存在于适配层,采集模块接收与协议无关的观察值。
observed := collection.Record{
RequestID: record.RequestID,
ExecutionID: record.ExecutionID,
TraceID: record.TraceID,
RequestedAt: record.RequestedAt,
APIKey: record.APIKey,
Model: record.Model,
ReasoningEffort: record.ReasoningEffort,
ServiceTier: record.ServiceTier,
Speed: record.Speed,
Failed: record.Failed,
ExecutorType: record.ExecutorType,
RequestType: usageRequestType(record.Endpoint),
Endpoint: record.Endpoint,
InputTokens: record.Detail.InputTokens,
OutputTokens: record.Detail.OutputTokens,
TotalTokens: record.Detail.TotalTokens,
CachedTokens: record.Detail.CachedTokens,
CacheReadTokens: record.Detail.CacheReadTokens,
CacheWriteTokens: record.Detail.CacheCreationTokens,
ReasoningTokens: record.Detail.ReasoningTokens,
TTFT: record.TTFT,
Latency: record.Latency,
ClientIP: record.ClientIP,
}
if policy, found := a.prices[strings.TrimSpace(record.Model)]; found {
cost, calculateErr := pricing.Calculate(policy, pricing.Usage{
InputTokens: record.Detail.InputTokens, CacheReadTokens: record.Detail.CacheReadTokens,
CacheWriteTokens: record.Detail.CacheCreationTokens, OutputTokens: record.Detail.OutputTokens,
ServiceTier: record.ServiceTier, Speed: record.Speed,
})
if calculateErr == nil {
observed.CostMicros = &cost.CostMicros
observed.PriceTier = cost.PriceTier
observed.FastRequested = cost.FastRequested
observed.FastPricingApplied = cost.FastApplied
observed.PriceMultiplierNumerator = cost.MultiplierNumerator
observed.PriceMultiplierDenominator = cost.MultiplierDenominator
}
}
if err := a.usage.Observe(context.Background(), observed); err != nil {
return nil, err
}
a.seen.Add(1)
}
return OKEnvelope(struct{}{})
}
func (a *App) handleRequestComplete(raw []byte) ([]byte, error) {
var completion RequestCompletion
if err := json.Unmarshal(raw, &completion); err != nil {
return nil, fmt.Errorf("解析请求终态: %w", err)
}
a.mu.RLock()
defer a.mu.RUnlock()
if a.closed {
return nil, fmt.Errorf("插件已经关闭")
}
if a.usage == nil {
return nil, fmt.Errorf("用量数据库尚未初始化")
}
endpoint, _ := completion.Metadata["request_path"].(string)
if err := a.usage.ObserveRequest(context.Background(), collection.RequestRecord{
RequestID: completion.RequestID, TraceID: completion.TraceID,
RequestedAt: completion.StartedAt, CompletedAt: completion.CompletedAt,
Model: completion.Model, RequestedModel: completion.RequestedModel,
SourceFormat: completion.SourceFormat, Stream: completion.Stream,
Outcome: string(completion.Outcome), StatusCode: completion.StatusCode,
Error: completion.Error, Endpoint: strings.TrimSpace(endpoint),
}); err != nil {
return nil, err
}
return OKEnvelope(struct{}{})
}
func usageRequestType(endpoint string) string {
if isCompactEndpoint(endpoint) {
return "JSON"
}
parts := strings.Fields(endpoint)
if len(parts) == 0 {
return ""
}
switch strings.ToUpper(parts[0]) {
case http.MethodPost:
return "SSE"
case http.MethodGet:
return "WS"
default:
return ""
}
}
func isCompactEndpoint(endpoint string) bool {
path := strings.ToLower(strings.TrimSpace(endpoint))
if fields := strings.Fields(path); len(fields) > 1 {
path = fields[len(fields)-1]
}
if query := strings.IndexByte(path, '?'); query >= 0 {
path = path[:query]
}
return strings.HasSuffix(strings.TrimRight(path, "/"), "/responses/compact")
}
func (a *App) Seen() uint64 {
return a.seen.Load()
}
func (a *App) Shutdown() {
a.mu.Lock()
defer a.mu.Unlock()
if a.closed {
return
}
a.closed = true
a.mu.Unlock()
if a.usage != nil {
_ = a.usage.Close()
a.usage = nil
a.store = nil
}
}
+30 -3
View File
@@ -3,6 +3,8 @@ package plugin
import (
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"sync"
"testing"
)
@@ -16,9 +18,14 @@ func lifecycleRequest(t *testing.T, schema uint32, config string) []byte {
return raw
}
func testConfig(t *testing.T, config string) string {
t.Helper()
return fmt.Sprintf("database_path: %q\n%s", filepath.Join(t.TempDir(), "usage.db"), config)
}
func TestRegisterNegotiatesSchemaAndDeclaresOnlyUsage(t *testing.T) {
app := NewApp()
raw, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, 99, "enabled: true\ncodex_only: true\n"))
raw, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, 99, testConfig(t, "enabled: true\ncodex_only: true\n")))
if err != nil {
t.Fatal(err)
}
@@ -33,7 +40,7 @@ func TestRegisterNegotiatesSchemaAndDeclaresOnlyUsage(t *testing.T) {
if err := json.Unmarshal(env.Result, &got); err != nil {
t.Fatal(err)
}
if got.SchemaVersion != SchemaVersion || !got.Capabilities.UsagePlugin {
if got.SchemaVersion != SchemaVersion || !got.Capabilities.RequestLifecyclePlugin || !got.Capabilities.UsagePlugin || !got.Capabilities.ManagementAPI {
t.Fatalf("unexpected registration: %+v", got)
}
}
@@ -53,7 +60,7 @@ func TestLifecycleConfigYAMLUsesBase64WireEncoding(t *testing.T) {
func TestReconfigureIsAtomicAndFiltersUsage(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, "enabled: true\ncodex_only: true\n")); err != nil {
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: true\n"))); err != nil {
t.Fatal(err)
}
for _, record := range []UsageRecord{{Provider: "codex", Model: "gpt-5.5"}, {Provider: "gemini", Model: "gemini-pro"}} {
@@ -77,6 +84,9 @@ func TestReconfigureIsAtomicAndFiltersUsage(t *testing.T) {
func TestConcurrentUsage(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: true\n"))); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(UsageRecord{Provider: "codex", Model: "gpt-5.5"})
var wg sync.WaitGroup
for range 100 {
@@ -107,3 +117,20 @@ func TestUnknownMethodReturnsErrorEnvelope(t *testing.T) {
t.Fatalf("unexpected envelope: %s", raw)
}
}
func TestUsageRequestTypeMatchesKeeperEndpointSemantics(t *testing.T) {
tests := []struct {
endpoint string
want string
}{
{endpoint: "POST /v1/responses", want: "SSE"},
{endpoint: "POST /v1/chat/completions", want: "SSE"},
{endpoint: "GET /v1/responses", want: "WS"},
{endpoint: "/v1/responses", want: ""},
}
for _, test := range tests {
if got := usageRequestType(test.endpoint); got != test.want {
t.Fatalf("usageRequestType(%q) = %q, want %q", test.endpoint, got, test.want)
}
}
}
+7 -3
View File
@@ -8,12 +8,13 @@ import (
)
type Config struct {
Enabled bool `yaml:"enabled"`
CodexOnly bool `yaml:"codex_only"`
Enabled bool `yaml:"enabled"`
CodexOnly bool `yaml:"codex_only"`
DatabasePath string `yaml:"database_path"`
}
func defaultConfig() Config {
return Config{Enabled: true, CodexOnly: true}
return Config{Enabled: true, CodexOnly: true, DatabasePath: "data/cpa-ext.db"}
}
func decodeConfig(raw []byte) (Config, error) {
@@ -24,6 +25,9 @@ func decodeConfig(raw []byte) (Config, error) {
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return Config{}, fmt.Errorf("解析插件配置: %w", err)
}
if strings.TrimSpace(cfg.DatabasePath) == "" {
return Config{}, fmt.Errorf("database_path 不能为空")
}
return cfg, nil
}
+210
View File
@@ -0,0 +1,210 @@
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,
}
}
+385
View File
@@ -0,0 +1,385 @@
package plugin
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
)
func managementCall(t *testing.T, app *App, method, path string) ManagementResponse {
return managementCallBody(t, app, method, path, nil)
}
func managementCallBody(t *testing.T, app *App, method, path string, body []byte) ManagementResponse {
t.Helper()
request, err := json.Marshal(ManagementRequest{Method: method, Path: path, Body: body})
if err != nil {
t.Fatal(err)
}
raw, err := app.HandleMethod(MethodManagementHandle, request)
if err != nil {
t.Fatal(err)
}
var envelope Envelope
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatal(err)
}
var response ManagementResponse
if err := json.Unmarshal(envelope.Result, &response); err != nil {
t.Fatal(err)
}
return response
}
func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) {
raw, err := NewApp().HandleMethod(MethodManagementRegister, nil)
if err != nil {
t.Fatal(err)
}
var envelope Envelope
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatal(err)
}
var registration ManagementRegistrationResponse
if err := json.Unmarshal(envelope.Result, &registration); err != nil {
t.Fatal(err)
}
if len(registration.Routes) != 4 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routePrices {
t.Fatalf("unexpected management routes: %+v", registration.Routes)
}
if len(registration.Resources) != 1 || registration.Resources[0].Path != resourceBase+resourceUI {
t.Fatalf("unexpected resource routes: %+v", registration.Resources)
}
}
func TestPriceManagementCalculatesUsageWithLongContextAndFastPolicy(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: true\n"))); err != nil {
t.Fatal(err)
}
priceBody := []byte(`{
"model":"gpt-5.6-sol",
"base":{"input_per_1m":"2.5","cache_read_per_1m":"0.25","cache_write_per_1m":"3.125","output_per_1m":"15"},
"long_context":{"threshold_input_tokens":272000,"comparison":"gt","input_per_1m":"5","cache_read_per_1m":"0.5","cache_write_per_1m":"6.25","output_per_1m":"22.5"},
"fast_pricing_enabled":true,
"fast_multiplier":"2.5"
}`)
response := managementCallBody(t, app, http.MethodPut, managementBase+routePrices, priceBody)
if response.StatusCode != http.StatusOK {
t.Fatalf("put price status = %d, body = %s", response.StatusCode, response.Body)
}
record := UsageRecord{
Provider: "codex", Model: "gpt-5.6-sol", ServiceTier: "priority", RequestedAt: time.Now(),
Detail: UsageDetail{InputTokens: 272_001, OutputTokens: 10_000, TotalTokens: 282_001},
}
raw, _ := json.Marshal(record)
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
t.Fatal(err)
}
usageResponse := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
var payload usageListResponse
if err := json.Unmarshal(usageResponse.Body, &payload); err != nil {
t.Fatal(err)
}
got := payload.Records[0]
if !got.CostAvailable || got.CostUSD == nil || *got.CostUSD != 3.962513 || got.PriceTier != "long_context" || !got.FastRequested || !got.FastPricingApplied {
t.Fatalf("unexpected priced usage: %+v", got)
}
pricesResponse := managementCall(t, app, http.MethodGet, managementBase+routePrices)
if pricesResponse.StatusCode != http.StatusOK || !strings.Contains(string(pricesResponse.Body), `"fast_multiplier":"2.5"`) {
t.Fatalf("unexpected prices response: %d %s", pricesResponse.StatusCode, pricesResponse.Body)
}
}
func TestFastRequestCanUseStandardPricing(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: true\n"))); err != nil {
t.Fatal(err)
}
priceBody := []byte(`{"model":"gpt-5.6-sol","base":{"input_per_1m":"2.5","cache_read_per_1m":"0.25","cache_write_per_1m":"3.125","output_per_1m":"15"},"fast_pricing_enabled":false,"fast_multiplier":"2.5"}`)
if response := managementCallBody(t, app, http.MethodPut, managementBase+routePrices, priceBody); response.StatusCode != http.StatusOK {
t.Fatalf("put price status = %d, body = %s", response.StatusCode, response.Body)
}
record := UsageRecord{Provider: "codex", Model: "gpt-5.6-sol", Speed: "fast", RequestedAt: time.Now(), Detail: UsageDetail{InputTokens: 100_000, TotalTokens: 100_000}}
raw, _ := json.Marshal(record)
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
t.Fatal(err)
}
response := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
t.Fatal(err)
}
got := payload.Records[0]
if got.CostUSD == nil || *got.CostUSD != 0.25 || !got.FastRequested || got.FastPricingApplied {
t.Fatalf("unexpected standard-priced Fast usage: %+v", got)
}
}
func TestCanceledRequestWithoutUsageRemainsVisible(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
startedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
completion := RequestCompletion{
RequestID: "request-canceled", TraceID: "trace-canceled", SourceFormat: "openai-response",
Model: "deepseek-v4-flash", RequestedModel: "deepseek-flash", Stream: true,
Outcome: RequestCompletionCanceled, StatusCode: 499, Error: "context canceled",
StartedAt: startedAt, CompletedAt: startedAt.Add(2 * time.Second),
Metadata: map[string]any{"request_path": "/v1/responses"},
}
raw, _ := json.Marshal(completion)
if _, err := app.HandleMethod(MethodRequestComplete, raw); err != nil {
t.Fatal(err)
}
response := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Records) != 1 {
t.Fatalf("records = %d, want 1", len(payload.Records))
}
got := payload.Records[0]
if got.RequestID != "request-canceled" || got.TraceID != "trace-canceled" || got.Outcome != "canceled" || !got.Failed || got.StatusCode != 499 || got.RequestType != "SSE" || got.CostAvailable {
t.Fatalf("unexpected canceled request: %+v", got)
}
}
func TestCompactUsageIsPricedAndUsesJSONMetrics(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
priceBody := []byte(`{"model":"deepseek-v4-flash","base":{"input_per_1m":"2.5","cache_read_per_1m":"0.25","cache_write_per_1m":"3.125","output_per_1m":"15"},"fast_pricing_enabled":true,"fast_multiplier":"2.5"}`)
if response := managementCallBody(t, app, http.MethodPut, managementBase+routePrices, priceBody); response.StatusCode != http.StatusOK {
t.Fatalf("put price status = %d, body = %s", response.StatusCode, response.Body)
}
record := UsageRecord{
RequestID: "compact-success", ExecutionID: "compact-attempt", Provider: "openai",
Model: "deepseek-v4-flash", Endpoint: "POST /v1/responses/compact", RequestedAt: time.Now(),
Latency: time.Second, TTFT: 200 * time.Millisecond,
Detail: UsageDetail{InputTokens: 1_000, OutputTokens: 100, TotalTokens: 1_100},
}
raw, _ := json.Marshal(record)
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
t.Fatal(err)
}
response := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Records) != 1 {
t.Fatalf("records = %d, want 1", len(payload.Records))
}
got := payload.Records[0]
if got.RequestType != "JSON" || got.Endpoint != "POST /v1/responses/compact" || got.TTFTMilliseconds != 0 || got.SpeedTPS != nil {
t.Fatalf("unexpected compact metadata: %+v", got)
}
if !got.CostAvailable || got.CostUSD == nil || *got.CostUSD != 0.004 {
t.Fatalf("unexpected compact cost: %+v", got)
}
}
func TestCompactFailureAndCancellationRemainVisible(t *testing.T) {
for _, test := range []struct {
name string
outcome RequestCompletionOutcome
statusCode int
}{
{name: "failed", outcome: RequestCompletionFailed, statusCode: 500},
{name: "canceled", outcome: RequestCompletionCanceled, statusCode: 499},
} {
t.Run(test.name, func(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
startedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
completion := RequestCompletion{
RequestID: "compact-" + test.name, Model: "deepseek-v4-flash", Stream: false,
Outcome: test.outcome, StatusCode: test.statusCode, Error: test.name,
StartedAt: startedAt, CompletedAt: startedAt.Add(time.Second),
Metadata: map[string]any{"request_path": "/v1/responses/compact"},
}
raw, _ := json.Marshal(completion)
if _, err := app.HandleMethod(MethodRequestComplete, raw); err != nil {
t.Fatal(err)
}
response := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Records) != 1 {
t.Fatalf("records = %d, want 1", len(payload.Records))
}
got := payload.Records[0]
if got.Outcome != string(test.outcome) || !got.Failed || got.StatusCode != test.statusCode || got.RequestType != "JSON" || got.Endpoint != "/v1/responses/compact" || got.SpeedTPS != nil || got.CostAvailable {
t.Fatalf("unexpected compact terminal record: %+v", got)
}
})
}
}
func TestUsageAndLifecycleMergeInEitherOrderAndDeduplicateExecution(t *testing.T) {
for _, usageFirst := range []bool{true, false} {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
startedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
usage := UsageRecord{
RequestID: "request-1", ExecutionID: "attempt-1", TraceID: "trace-1",
Provider: "openai", Model: "deepseek-v4-flash", RequestedAt: startedAt,
Endpoint: "POST /v1/responses", Detail: UsageDetail{InputTokens: 10, TotalTokens: 10},
}
completion := RequestCompletion{
RequestID: "request-1", TraceID: "trace-1", Model: "deepseek-v4-flash",
Outcome: RequestCompletionFailed, StatusCode: 500, Error: "upstream failed",
StartedAt: startedAt, CompletedAt: startedAt.Add(time.Second),
}
usageRaw, _ := json.Marshal(usage)
completionRaw, _ := json.Marshal(completion)
calls := []struct {
method string
raw []byte
}{{MethodUsageHandle, usageRaw}, {MethodRequestComplete, completionRaw}}
if !usageFirst {
calls[0], calls[1] = calls[1], calls[0]
}
for _, call := range calls {
if _, err := app.HandleMethod(call.method, call.raw); err != nil {
t.Fatal(err)
}
}
// 重复的 Usage 回调不能生成第二条执行记录。
if _, err := app.HandleMethod(MethodUsageHandle, usageRaw); err != nil {
t.Fatal(err)
}
response := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Records) != 1 || payload.Records[0].ExecutionID != "attempt-1" || payload.Records[0].Outcome != "failed" || payload.Records[0].StatusCode != 500 {
t.Fatalf("usageFirst=%v records=%+v", usageFirst, payload.Records)
}
}
}
func TestRetriedExecutionsRemainSeparate(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: false\n"))); err != nil {
t.Fatal(err)
}
startedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
for index, failed := range []bool{true, false} {
usage := UsageRecord{
RequestID: "request-retry", ExecutionID: fmt.Sprintf("attempt-%d", index+1),
Provider: "openai", Model: "deepseek-v4-flash", RequestedAt: startedAt.Add(time.Duration(index) * time.Second),
Failed: failed, Detail: UsageDetail{InputTokens: int64(10 + index), TotalTokens: int64(10 + index)},
}
raw, _ := json.Marshal(usage)
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
t.Fatal(err)
}
}
completion := RequestCompletion{RequestID: "request-retry", Outcome: RequestCompletionSucceeded, StartedAt: startedAt, CompletedAt: startedAt.Add(2 * time.Second)}
raw, _ := json.Marshal(completion)
if _, err := app.HandleMethod(MethodRequestComplete, raw); err != nil {
t.Fatal(err)
}
response := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Records) != 2 || payload.Records[0].ExecutionID != "attempt-2" || payload.Records[0].Failed || payload.Records[1].ExecutionID != "attempt-1" || !payload.Records[1].Failed {
t.Fatalf("unexpected retry records: %+v", payload.Records)
}
}
func TestUsageManagementResponseContainsDisplayFields(t *testing.T) {
app := NewApp()
if _, err := app.HandleMethod(MethodPluginRegister, lifecycleRequest(t, SchemaVersion, testConfig(t, "enabled: true\ncodex_only: true\n"))); err != nil {
t.Fatal(err)
}
record := UsageRecord{
Provider: "codex",
APIKey: "test-key",
Model: "gpt-5.5",
ReasoningEffort: "high",
ServiceTier: "priority",
ExecutorType: "CodexExecutor",
Endpoint: "POST /v1/responses",
ClientIP: "192.0.2.10",
RequestedAt: time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC),
Latency: 1500 * time.Millisecond,
TTFT: 250 * time.Millisecond,
Detail: UsageDetail{
InputTokens: 10,
OutputTokens: 5,
TotalTokens: 15,
CachedTokens: 4,
CacheReadTokens: 4,
CacheCreationTokens: 1,
ReasoningTokens: 2,
},
}
raw, _ := json.Marshal(record)
if _, err := app.HandleMethod(MethodUsageHandle, raw); err != nil {
t.Fatal(err)
}
response := managementCall(t, app, http.MethodGet, managementBase+routeUsage)
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.StatusCode, response.Body)
}
var payload usageListResponse
if err := json.Unmarshal(response.Body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Records) != 1 {
t.Fatalf("records = %d, want 1", len(payload.Records))
}
got := payload.Records[0]
if got.APIKey != "test-key" || got.Model != "gpt-5.5" || got.ReasoningEffort != "high" || got.ServiceTier != "priority" || got.ExecutorType != "CodexExecutor" {
t.Fatalf("unexpected usage identity fields: %+v", got)
}
if got.RequestType != "SSE" || got.Endpoint != "POST /v1/responses" || got.ClientIP != "192.0.2.10" {
t.Fatalf("unexpected request metadata: %+v", got)
}
if got.TotalTokens != 15 || got.TTFTMilliseconds != 250 || got.CacheReadTokens != 4 || got.CacheWriteTokens != 1 {
t.Fatalf("unexpected usage counters: %+v", got)
}
if got.SpeedTPS == nil || *got.SpeedTPS != 4 || got.CacheRate == nil || *got.CacheRate != 40 {
t.Fatalf("unexpected derived display fields: %+v", got)
}
if got.KeyAlias != "" || got.CostUSD != nil || got.CostAvailable {
t.Fatalf("unexpected usage item: %+v", got)
}
}
func TestUsageResourceServesTablePage(t *testing.T) {
response := managementCall(t, NewApp(), http.MethodGet, resourceBase+resourceUI)
page := string(response.Body)
if response.StatusCode != http.StatusOK || !strings.Contains(page, "最近用量记录") {
t.Fatalf("unexpected UI response: status=%d", response.StatusCode)
}
for _, column := range []string{"Key / 别名", "推理强度", "生成速度", "缓存写入", "总成本", "客户端 IP"} {
if !strings.Contains(page, column) {
t.Fatalf("UI does not contain column %q", column)
}
}
if !strings.Contains(page, `return "compact"`) || !strings.Contains(page, "isCompactEndpoint(record.endpoint)") {
t.Fatal("UI does not contain compact display rules")
}
}
+222
View File
@@ -0,0 +1,222 @@
package plugin
import (
"context"
"encoding/json"
"errors"
"math/big"
"net/http"
"strconv"
"strings"
"cpa-ext/internal/pricing"
)
type priceRatesDTO struct {
InputPer1M string `json:"input_per_1m"`
CacheReadPer1M string `json:"cache_read_per_1m"`
CacheWritePer1M string `json:"cache_write_per_1m"`
OutputPer1M string `json:"output_per_1m"`
}
type longContextDTO struct {
ThresholdInputTokens int64 `json:"threshold_input_tokens"`
Comparison string `json:"comparison"`
priceRatesDTO
}
type priceDTO struct {
Model string `json:"model"`
Base priceRatesDTO `json:"base"`
LongContext *longContextDTO `json:"long_context,omitempty"`
FastPricingEnabled bool `json:"fast_pricing_enabled"`
FastMultiplier string `json:"fast_multiplier"`
}
type priceDeleteRequest struct {
Model string `json:"model"`
}
func (a *App) listPrices() ManagementResponse {
a.mu.RLock()
defer a.mu.RUnlock()
if a.store == nil {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
policies, err := a.store.ListPrices(context.Background())
if err != nil {
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
prices := make([]priceDTO, 0, len(policies))
for _, policy := range policies {
prices = append(prices, policyToDTO(policy))
}
return jsonManagementResponse(http.StatusOK, map[string]any{"prices": prices})
}
func (a *App) putPrice(body []byte) ManagementResponse {
var dto priceDTO
if err := decodeJSONBody(body, &dto); err != nil {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": err.Error()}})
}
policy, err := dtoToPolicy(dto)
if err != nil {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": err.Error()}})
}
a.mu.Lock()
defer a.mu.Unlock()
if a.store == nil {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
if err := a.store.UpsertPrice(context.Background(), policy); err != nil {
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
a.prices[policy.Model] = policy
if _, err := a.store.BackfillMissingCosts(context.Background(), policy); err != nil {
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
return jsonManagementResponse(http.StatusOK, policyToDTO(policy))
}
func (a *App) deletePrice(body []byte) ManagementResponse {
var request priceDeleteRequest
if err := decodeJSONBody(body, &request); err != nil {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": err.Error()}})
}
request.Model = strings.TrimSpace(request.Model)
if request.Model == "" {
return jsonManagementResponse(http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "invalid_price", "message": "model 不能为空"}})
}
a.mu.Lock()
defer a.mu.Unlock()
if a.store == nil {
return jsonManagementResponse(http.StatusServiceUnavailable, map[string]any{"error": map[string]string{"code": "database_unavailable", "message": "价格数据库尚未初始化"}})
}
if err := a.store.DeletePrice(context.Background(), request.Model); err != nil {
return jsonManagementResponse(http.StatusInternalServerError, map[string]any{"error": map[string]string{"code": "database_error", "message": err.Error()}})
}
delete(a.prices, request.Model)
return jsonManagementResponse(http.StatusOK, map[string]any{"deleted": request.Model})
}
func dtoToPolicy(dto priceDTO) (pricing.Policy, error) {
base, err := dtoRates(dto.Base)
if err != nil {
return pricing.Policy{}, err
}
multiplierText := strings.TrimSpace(dto.FastMultiplier)
if multiplierText == "" {
multiplierText = "2.5"
}
multiplier, err := parseRatio(multiplierText)
if err != nil {
return pricing.Policy{}, errors.New("fast_multiplier 必须是正数,最多保留六位小数")
}
policy := pricing.Policy{Model: strings.TrimSpace(dto.Model), Base: base, FastPricingEnabled: dto.FastPricingEnabled, FastMultiplier: multiplier}
if dto.LongContext != nil {
rates, ratesErr := dtoRates(dto.LongContext.priceRatesDTO)
if ratesErr != nil {
return pricing.Policy{}, ratesErr
}
policy.LongContext = &pricing.LongContext{ThresholdInputTokens: dto.LongContext.ThresholdInputTokens, Comparison: dto.LongContext.Comparison, Rates: rates}
}
if err := policy.Validate(); err != nil {
return pricing.Policy{}, err
}
return policy, nil
}
func dtoRates(dto priceRatesDTO) (pricing.Rates, error) {
values := []*int64{new(int64), new(int64), new(int64), new(int64)}
texts := []string{dto.InputPer1M, dto.CacheReadPer1M, dto.CacheWritePer1M, dto.OutputPer1M}
for index, text := range texts {
value, err := parseDecimalMicros(text)
if err != nil {
return pricing.Rates{}, errors.New("价格必须是非负数字,最多保留六位小数")
}
*values[index] = value
}
return pricing.Rates{InputMicrosPer1M: *values[0], CacheReadMicrosPer1M: *values[1], CacheWriteMicrosPer1M: *values[2], OutputMicrosPer1M: *values[3]}, nil
}
func policyToDTO(policy pricing.Policy) priceDTO {
dto := priceDTO{Model: policy.Model, Base: ratesToDTO(policy.Base), FastPricingEnabled: policy.FastPricingEnabled, FastMultiplier: formatRatio(policy.FastMultiplier)}
if policy.LongContext != nil {
dto.LongContext = &longContextDTO{ThresholdInputTokens: policy.LongContext.ThresholdInputTokens, Comparison: policy.LongContext.Comparison, priceRatesDTO: ratesToDTO(policy.LongContext.Rates)}
}
return dto
}
func ratesToDTO(rates pricing.Rates) priceRatesDTO {
return priceRatesDTO{InputPer1M: formatMicros(rates.InputMicrosPer1M), CacheReadPer1M: formatMicros(rates.CacheReadMicrosPer1M), CacheWritePer1M: formatMicros(rates.CacheWriteMicrosPer1M), OutputPer1M: formatMicros(rates.OutputMicrosPer1M)}
}
func parseDecimalMicros(text string) (int64, error) {
text = strings.TrimSpace(text)
if text == "" || strings.HasPrefix(text, "-") || strings.Count(text, ".") > 1 {
return 0, errors.New("invalid decimal")
}
parts := strings.SplitN(text, ".", 2)
if parts[0] == "" {
parts[0] = "0"
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
}
if len(fraction) > 6 {
return 0, errors.New("too many decimal places")
}
for len(fraction) < 6 {
fraction += "0"
}
whole := new(big.Int)
if _, ok := whole.SetString(parts[0]+fraction, 10); !ok || !whole.IsInt64() {
return 0, errors.New("invalid decimal")
}
return whole.Int64(), nil
}
func parseRatio(text string) (pricing.Ratio, error) {
numerator, err := parseDecimalMicros(text)
if err != nil || numerator <= 0 {
return pricing.Ratio{}, errors.New("invalid ratio")
}
denominator := int64(1_000_000)
divisor := gcd(numerator, denominator)
return pricing.Ratio{Numerator: numerator / divisor, Denominator: denominator / divisor}, nil
}
func gcd(a, b int64) int64 {
for b != 0 {
a, b = b, a%b
}
return a
}
func formatMicros(value int64) string {
whole := value / 1_000_000
fraction := strconv.FormatInt(value%1_000_000+1_000_000, 10)[1:]
fraction = strings.TrimRight(fraction, "0")
if fraction == "" {
return strconv.FormatInt(whole, 10)
}
return strconv.FormatInt(whole, 10) + "." + fraction
}
func formatRatio(value pricing.Ratio) string {
if value.Denominator == 0 {
return ""
}
rational := new(big.Rat).SetFrac(big.NewInt(value.Numerator), big.NewInt(value.Denominator))
return strings.TrimRight(strings.TrimRight(rational.FloatString(6), "0"), ".")
}
func decodeJSONBody(body []byte, target any) error {
decoder := json.NewDecoder(strings.NewReader(string(body)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
return nil
}
+73 -5
View File
@@ -3,6 +3,7 @@ package plugin
import (
"encoding/json"
"net/http"
"net/url"
"time"
)
@@ -14,10 +15,13 @@ const (
)
const (
MethodPluginRegister = "plugin.register"
MethodPluginReconfigure = "plugin.reconfigure"
MethodPluginShutdown = "plugin.shutdown"
MethodUsageHandle = "usage.handle"
MethodPluginRegister = "plugin.register"
MethodPluginReconfigure = "plugin.reconfigure"
MethodPluginShutdown = "plugin.shutdown"
MethodRequestComplete = "request.complete"
MethodUsageHandle = "usage.handle"
MethodManagementRegister = "management.register"
MethodManagementHandle = "management.handle"
)
type Envelope struct {
@@ -61,14 +65,77 @@ type ConfigField struct {
}
type Capabilities struct {
UsagePlugin bool `json:"usage_plugin"`
RequestLifecyclePlugin bool `json:"request_lifecycle_plugin"`
UsagePlugin bool `json:"usage_plugin"`
ManagementAPI bool `json:"management_api"`
}
type RequestCompletionOutcome string
const (
RequestCompletionSucceeded RequestCompletionOutcome = "succeeded"
RequestCompletionFailed RequestCompletionOutcome = "failed"
RequestCompletionRejected RequestCompletionOutcome = "rejected"
RequestCompletionCanceled RequestCompletionOutcome = "canceled"
)
// RequestCompletion 是一次模型执行的终态,Token 由 UsageRecord 单独补充。
type RequestCompletion struct {
RequestID string
TraceID string
SourceFormat string
Model string
RequestedModel string
Stream bool
Outcome RequestCompletionOutcome
StatusCode int
Error string
StartedAt time.Time
CompletedAt time.Time
Metadata map[string]any
}
type ManagementRegistrationResponse struct {
Routes []ManagementRoute `json:"routes,omitempty"`
Resources []ResourceRoute `json:"resources,omitempty"`
}
type ManagementRoute struct {
Method string `json:"Method"`
Path string `json:"Path"`
Description string `json:"Description,omitempty"`
}
type ResourceRoute struct {
Path string `json:"Path"`
Menu string `json:"Menu,omitempty"`
Description string `json:"Description,omitempty"`
}
type ManagementRequest struct {
Method string `json:"Method"`
Path string `json:"Path"`
Headers http.Header `json:"Headers"`
Query url.Values `json:"Query"`
Body []byte `json:"Body"`
}
type ManagementResponse struct {
StatusCode int `json:"StatusCode,omitempty"`
Headers http.Header `json:"Headers,omitempty"`
Body []byte `json:"Body,omitempty"`
}
// UsageRecord mirrors CLIProxyAPI sdk/pluginapi. Keep it in sync with the
// target host because exported field names are part of the JSON wire contract.
type UsageRecord struct {
Provider string
RequestID string
ExecutionID string
TraceID string
ExecutorType string
Endpoint string
ClientIP string
Model string
Alias string
APIKey string
@@ -78,6 +145,7 @@ type UsageRecord struct {
Source string
ReasoningEffort string
ServiceTier string
Speed string
Generate bool
RequestedAt time.Time
Latency time.Duration
+163
View File
@@ -0,0 +1,163 @@
// Package pricing implements deterministic model-price resolution and cost calculation.
package pricing
import (
"errors"
"fmt"
"math/big"
"strings"
)
const perMillion = int64(1_000_000)
// Rates stores micro-USD rates per one million tokens.
type Rates struct {
InputMicrosPer1M int64
CacheReadMicrosPer1M int64
CacheWriteMicrosPer1M int64
OutputMicrosPer1M int64
}
// Ratio stores a multiplier exactly. Fast pricing is represented as 5/2.
type Ratio struct {
Numerator int64
Denominator int64
}
// LongContext replaces all four rates after the input threshold is reached.
type LongContext struct {
ThresholdInputTokens int64
Comparison string
Rates Rates
}
// Policy is the complete pricing configuration for one exact model name.
type Policy struct {
Model string
Base Rates
LongContext *LongContext
FastPricingEnabled bool
FastMultiplier Ratio
}
// Usage contains token buckets required by pricing. InputTokens includes cache buckets.
type Usage struct {
InputTokens int64
CacheReadTokens int64
CacheWriteTokens int64
OutputTokens int64
ServiceTier string
Speed string
}
// Result records the applied policy facts together with the final amount.
type Result struct {
CostMicros int64
PriceTier string
FastRequested bool
FastApplied bool
MultiplierNumerator int64
MultiplierDenominator int64
}
// Validate rejects incomplete or ambiguous policies before they enter the runtime catalog.
func (p Policy) Validate() error {
if strings.TrimSpace(p.Model) == "" {
return errors.New("model is required")
}
if err := p.Base.validate(); err != nil {
return fmt.Errorf("base rates: %w", err)
}
if p.FastMultiplier.Numerator <= 0 || p.FastMultiplier.Denominator <= 0 {
return errors.New("fast multiplier must be positive")
}
if p.LongContext != nil {
if p.LongContext.ThresholdInputTokens < 0 {
return errors.New("long-context threshold cannot be negative")
}
if p.LongContext.Comparison != "gt" && p.LongContext.Comparison != "gte" {
return errors.New("long-context comparison must be gt or gte")
}
if err := p.LongContext.Rates.validate(); err != nil {
return fmt.Errorf("long-context rates: %w", err)
}
}
return nil
}
func (r Rates) validate() error {
values := [...]int64{r.InputMicrosPer1M, r.CacheReadMicrosPer1M, r.CacheWriteMicrosPer1M, r.OutputMicrosPer1M}
for _, value := range values {
if value < 0 {
return errors.New("rates cannot be negative")
}
}
return nil
}
// Calculate selects the long-context tier first, then applies Fast once to the total.
func Calculate(policy Policy, usage Usage) (Result, error) {
if err := policy.Validate(); err != nil {
return Result{}, err
}
if usage.InputTokens < 0 || usage.CacheReadTokens < 0 || usage.CacheWriteTokens < 0 || usage.OutputTokens < 0 {
return Result{}, errors.New("token counts cannot be negative")
}
if usage.CacheReadTokens > usage.InputTokens-usage.CacheWriteTokens {
return Result{}, errors.New("cache token counts exceed input tokens")
}
rates := policy.Base
tier := "base"
if long := policy.LongContext; long != nil && thresholdMatched(usage.InputTokens, long.ThresholdInputTokens, long.Comparison) {
rates = long.Rates
tier = "long_context"
}
multiplier := Ratio{Numerator: 1, Denominator: 1}
fastRequested := isFast(usage.ServiceTier, usage.Speed)
fastApplied := policy.FastPricingEnabled && fastRequested
if fastApplied {
multiplier = policy.FastMultiplier
}
uncachedInput := usage.InputTokens - usage.CacheReadTokens - usage.CacheWriteTokens
numerator := new(big.Int)
addCostTerm(numerator, uncachedInput, rates.InputMicrosPer1M)
addCostTerm(numerator, usage.CacheReadTokens, rates.CacheReadMicrosPer1M)
addCostTerm(numerator, usage.CacheWriteTokens, rates.CacheWriteMicrosPer1M)
addCostTerm(numerator, usage.OutputTokens, rates.OutputMicrosPer1M)
numerator.Mul(numerator, big.NewInt(multiplier.Numerator))
denominator := big.NewInt(perMillion * multiplier.Denominator)
// The complete request is rounded once using round-half-up.
numerator.Add(numerator, new(big.Int).Quo(new(big.Int).Set(denominator), big.NewInt(2)))
numerator.Quo(numerator, denominator)
if !numerator.IsInt64() {
return Result{}, errors.New("calculated cost exceeds int64")
}
return Result{
CostMicros: numerator.Int64(),
PriceTier: tier,
FastRequested: fastRequested,
FastApplied: fastApplied,
MultiplierNumerator: multiplier.Numerator,
MultiplierDenominator: multiplier.Denominator,
}, nil
}
func isFast(serviceTier, speed string) bool {
tier := strings.ToLower(strings.TrimSpace(serviceTier))
return tier == "priority" || tier == "fast" || strings.EqualFold(strings.TrimSpace(speed), "fast")
}
func thresholdMatched(input, threshold int64, comparison string) bool {
if comparison == "gte" {
return input >= threshold
}
return input > threshold
}
func addCostTerm(total *big.Int, tokens, rate int64) {
term := new(big.Int).Mul(big.NewInt(tokens), big.NewInt(rate))
total.Add(total, term)
}
+91
View File
@@ -0,0 +1,91 @@
package pricing_test
import (
"testing"
"cpa-ext/internal/pricing"
)
func testPolicy() pricing.Policy {
return pricing.Policy{
Model: "gpt-5.6-sol",
Base: pricing.Rates{
InputMicrosPer1M: 2_500_000,
CacheReadMicrosPer1M: 250_000,
CacheWriteMicrosPer1M: 3_125_000,
OutputMicrosPer1M: 15_000_000,
},
LongContext: &pricing.LongContext{
ThresholdInputTokens: 272_000,
Comparison: "gt",
Rates: pricing.Rates{
InputMicrosPer1M: 5_000_000,
CacheReadMicrosPer1M: 500_000,
CacheWriteMicrosPer1M: 6_250_000,
OutputMicrosPer1M: 22_500_000,
},
},
FastPricingEnabled: true,
FastMultiplier: pricing.Ratio{Numerator: 5, Denominator: 2},
}
}
func TestCalculateUsesEachTokenBucketOnce(t *testing.T) {
result, err := pricing.Calculate(testPolicy(), pricing.Usage{
InputTokens: 1_000_000, CacheReadTokens: 400_000, CacheWriteTokens: 100_000, OutputTokens: 500_000,
})
if err != nil {
t.Fatal(err)
}
// The request is above the threshold, so all buckets use long-context rates.
if result.CostMicros != 14_575_000 || result.PriceTier != "long_context" || result.FastApplied {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestCalculateCanDisableFastPricingWithoutChangingRequestMode(t *testing.T) {
policy := testPolicy()
policy.FastPricingEnabled = false
result, err := pricing.Calculate(policy, pricing.Usage{InputTokens: 100_000, OutputTokens: 10_000, ServiceTier: "priority"})
if err != nil {
t.Fatal(err)
}
if result.CostMicros != 400_000 || !result.FastRequested || result.FastApplied || result.MultiplierNumerator != 1 || result.MultiplierDenominator != 1 {
t.Fatalf("unexpected disabled Fast pricing result: %+v", result)
}
}
func TestCalculateRecognizesPassedFastSpeed(t *testing.T) {
result, err := pricing.Calculate(testPolicy(), pricing.Usage{InputTokens: 100_000, Speed: "fast"})
if err != nil {
t.Fatal(err)
}
if !result.FastRequested || !result.FastApplied || result.CostMicros != 625_000 {
t.Fatalf("unexpected speed=fast result: %+v", result)
}
}
func TestCalculateUsesStrictThresholdAndAppliesFastOnce(t *testing.T) {
policy := testPolicy()
base, err := pricing.Calculate(policy, pricing.Usage{InputTokens: 272_000, OutputTokens: 10_000, ServiceTier: "priority"})
if err != nil {
t.Fatal(err)
}
if base.PriceTier != "base" || base.CostMicros != 2_075_000 || !base.FastApplied || base.MultiplierNumerator != 5 || base.MultiplierDenominator != 2 {
t.Fatalf("unexpected threshold result: %+v", base)
}
long, err := pricing.Calculate(policy, pricing.Usage{InputTokens: 272_001, OutputTokens: 10_000, ServiceTier: "priority"})
if err != nil {
t.Fatal(err)
}
if long.PriceTier != "long_context" || long.CostMicros != 3_962_513 {
t.Fatalf("unexpected long-context result: %+v", long)
}
}
func TestCalculateRejectsOverlappingCacheCounts(t *testing.T) {
_, err := pricing.Calculate(testPolicy(), pricing.Usage{InputTokens: 10, CacheReadTokens: 8, CacheWriteTokens: 3})
if err == nil {
t.Fatal("overlapping cache counts unexpectedly accepted")
}
}
+178
View File
@@ -0,0 +1,178 @@
package repository
import (
"context"
"fmt"
"time"
"cpa-ext/internal/pricing"
)
// ListPrices returns every configured exact-model policy.
func (r *SQLiteUsageRepository) ListPrices(ctx context.Context) ([]pricing.Policy, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT model, input_rate_micros, cache_read_rate_micros, cache_write_rate_micros, output_rate_micros,
long_context_enabled, long_context_threshold, long_context_comparison,
long_input_rate_micros, long_cache_read_rate_micros, long_cache_write_rate_micros, long_output_rate_micros,
fast_pricing_enabled, fast_multiplier_numerator, fast_multiplier_denominator
FROM model_prices ORDER BY model`)
if err != nil {
return nil, fmt.Errorf("查询模型价格: %w", err)
}
defer rows.Close()
var policies []pricing.Policy
for rows.Next() {
var policy pricing.Policy
var longEnabled bool
var long pricing.LongContext
if err := rows.Scan(
&policy.Model,
&policy.Base.InputMicrosPer1M, &policy.Base.CacheReadMicrosPer1M,
&policy.Base.CacheWriteMicrosPer1M, &policy.Base.OutputMicrosPer1M,
&longEnabled, &long.ThresholdInputTokens, &long.Comparison,
&long.Rates.InputMicrosPer1M, &long.Rates.CacheReadMicrosPer1M,
&long.Rates.CacheWriteMicrosPer1M, &long.Rates.OutputMicrosPer1M,
&policy.FastPricingEnabled, &policy.FastMultiplier.Numerator, &policy.FastMultiplier.Denominator,
); err != nil {
return nil, fmt.Errorf("读取模型价格: %w", err)
}
if longEnabled {
policy.LongContext = &long
}
policies = append(policies, policy)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历模型价格: %w", err)
}
return policies, nil
}
// UpsertPrice atomically replaces one model policy.
func (r *SQLiteUsageRepository) UpsertPrice(ctx context.Context, policy pricing.Policy) error {
if err := policy.Validate(); err != nil {
return err
}
longEnabled := policy.LongContext != nil
long := pricing.LongContext{Comparison: "gt"}
if policy.LongContext != nil {
long = *policy.LongContext
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO model_prices (
model, input_rate_micros, cache_read_rate_micros, cache_write_rate_micros, output_rate_micros,
long_context_enabled, long_context_threshold, long_context_comparison,
long_input_rate_micros, long_cache_read_rate_micros, long_cache_write_rate_micros, long_output_rate_micros,
fast_pricing_enabled, fast_multiplier_numerator, fast_multiplier_denominator, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(model) DO UPDATE SET
input_rate_micros=excluded.input_rate_micros,
cache_read_rate_micros=excluded.cache_read_rate_micros,
cache_write_rate_micros=excluded.cache_write_rate_micros,
output_rate_micros=excluded.output_rate_micros,
long_context_enabled=excluded.long_context_enabled,
long_context_threshold=excluded.long_context_threshold,
long_context_comparison=excluded.long_context_comparison,
long_input_rate_micros=excluded.long_input_rate_micros,
long_cache_read_rate_micros=excluded.long_cache_read_rate_micros,
long_cache_write_rate_micros=excluded.long_cache_write_rate_micros,
long_output_rate_micros=excluded.long_output_rate_micros,
fast_pricing_enabled=excluded.fast_pricing_enabled,
fast_multiplier_numerator=excluded.fast_multiplier_numerator,
fast_multiplier_denominator=excluded.fast_multiplier_denominator,
updated_at=excluded.updated_at`,
policy.Model,
policy.Base.InputMicrosPer1M, policy.Base.CacheReadMicrosPer1M,
policy.Base.CacheWriteMicrosPer1M, policy.Base.OutputMicrosPer1M,
longEnabled, long.ThresholdInputTokens, long.Comparison,
long.Rates.InputMicrosPer1M, long.Rates.CacheReadMicrosPer1M,
long.Rates.CacheWriteMicrosPer1M, long.Rates.OutputMicrosPer1M,
policy.FastPricingEnabled, policy.FastMultiplier.Numerator, policy.FastMultiplier.Denominator,
time.Now().UTC().Format(time.RFC3339Nano),
)
if err != nil {
return fmt.Errorf("保存模型价格: %w", err)
}
return nil
}
// BackfillMissingCosts 只补算尚未定价的历史记录,已经保存的账单金额不会随价格修改而变化。
func (r *SQLiteUsageRepository) BackfillMissingCosts(ctx context.Context, policy pricing.Policy) (int64, error) {
if err := policy.Validate(); err != nil {
return 0, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, input_tokens, cache_read_tokens, cache_write_tokens, output_tokens, service_tier, speed
FROM usage_records
WHERE model = ? AND cost_micros IS NULL`, policy.Model)
if err != nil {
return 0, fmt.Errorf("查询待补算用量: %w", err)
}
type pendingCost struct {
id int64
inputTokens int64
cacheReadTokens int64
cacheWriteTokens int64
outputTokens int64
serviceTier, speed string
}
var pending []pendingCost
for rows.Next() {
var item pendingCost
if err := rows.Scan(&item.id, &item.inputTokens, &item.cacheReadTokens, &item.cacheWriteTokens, &item.outputTokens, &item.serviceTier, &item.speed); err != nil {
_ = rows.Close()
return 0, fmt.Errorf("读取待补算用量: %w", err)
}
pending = append(pending, item)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return 0, fmt.Errorf("遍历待补算用量: %w", err)
}
if err := rows.Close(); err != nil {
return 0, fmt.Errorf("关闭待补算查询: %w", err)
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("开始补算事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
var updated int64
for _, item := range pending {
result, calculateErr := pricing.Calculate(policy, pricing.Usage{
InputTokens: item.inputTokens, CacheReadTokens: item.cacheReadTokens,
CacheWriteTokens: item.cacheWriteTokens, OutputTokens: item.outputTokens,
ServiceTier: item.serviceTier, Speed: item.speed,
})
if calculateErr != nil {
continue
}
change, updateErr := tx.ExecContext(ctx, `
UPDATE usage_records
SET cost_micros = ?, price_tier = ?, fast_requested = ?, fast_pricing_applied = ?,
price_multiplier_numerator = ?, price_multiplier_denominator = ?
WHERE id = ? AND cost_micros IS NULL`,
result.CostMicros, result.PriceTier, result.FastRequested, result.FastApplied,
result.MultiplierNumerator, result.MultiplierDenominator, item.id)
if updateErr != nil {
return 0, fmt.Errorf("补算用量价格: %w", updateErr)
}
count, countErr := change.RowsAffected()
if countErr != nil {
return 0, fmt.Errorf("读取补算数量: %w", countErr)
}
updated += count
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交补算事务: %w", err)
}
return updated, nil
}
func (r *SQLiteUsageRepository) DeletePrice(ctx context.Context, model string) error {
if _, err := r.db.ExecContext(ctx, `DELETE FROM model_prices WHERE model = ?`, model); err != nil {
return fmt.Errorf("删除模型价格: %w", err)
}
return nil
}
@@ -0,0 +1,88 @@
package repository_test
import (
"context"
"path/filepath"
"testing"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/pricing"
"cpa-ext/internal/repository"
)
func TestSQLitePricingRoundTripAndDelete(t *testing.T) {
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
policy := pricing.Policy{
Model: "gpt-5.6-sol",
Base: pricing.Rates{InputMicrosPer1M: 2_500_000, CacheReadMicrosPer1M: 250_000, CacheWriteMicrosPer1M: 3_125_000, OutputMicrosPer1M: 15_000_000},
LongContext: &pricing.LongContext{ThresholdInputTokens: 272_000, Comparison: "gt", Rates: pricing.Rates{InputMicrosPer1M: 5_000_000, OutputMicrosPer1M: 22_500_000}},
FastPricingEnabled: true,
FastMultiplier: pricing.Ratio{Numerator: 5, Denominator: 2},
}
if err := store.UpsertPrice(context.Background(), policy); err != nil {
t.Fatal(err)
}
prices, err := store.ListPrices(context.Background())
if err != nil {
t.Fatal(err)
}
if len(prices) != 1 || prices[0].Model != policy.Model || prices[0].LongContext == nil || !prices[0].FastPricingEnabled || prices[0].FastMultiplier != policy.FastMultiplier {
t.Fatalf("unexpected prices: %+v", prices)
}
if err := store.DeletePrice(context.Background(), policy.Model); err != nil {
t.Fatal(err)
}
prices, err = store.ListPrices(context.Background())
if err != nil || len(prices) != 0 {
t.Fatalf("prices after delete: %+v, %v", prices, err)
}
}
func TestBackfillMissingCostsDoesNotRewriteExistingCost(t *testing.T) {
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
missing := collection.Record{RequestedAt: time.Now(), Model: "deepseek-v4-flash", InputTokens: 1_000_000, OutputTokens: 100_000}
existingCost := int64(7)
priced := collection.Record{RequestedAt: time.Now(), Model: "deepseek-v4-flash", InputTokens: 1_000_000, CostMicros: &existingCost}
if err := store.Insert(context.Background(), missing); err != nil {
t.Fatal(err)
}
if err := store.Insert(context.Background(), priced); err != nil {
t.Fatal(err)
}
policy := pricing.Policy{
Model: "deepseek-v4-flash", Base: pricing.Rates{InputMicrosPer1M: 2_500_000, OutputMicrosPer1M: 15_000_000},
FastMultiplier: pricing.Ratio{Numerator: 5, Denominator: 2},
}
updated, err := store.BackfillMissingCosts(context.Background(), policy)
if err != nil {
t.Fatal(err)
}
if updated != 1 {
t.Fatalf("updated = %d, want 1", updated)
}
records, err := store.ListRecent(context.Background(), 10)
if err != nil {
t.Fatal(err)
}
if len(records) != 2 || records[0].CostMicros == nil || records[1].CostMicros == nil {
t.Fatalf("unexpected records: %+v", records)
}
got := map[int64]int{}
for _, record := range records {
got[*record.CostMicros]++
}
if got[4_000_000] != 1 || got[7] != 1 {
t.Fatalf("costs = %#v", got)
}
}
+309
View File
@@ -0,0 +1,309 @@
// Package repository 实现 cpa-ext 的本地持久化。
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"cpa-ext/internal/collection"
_ "github.com/mattn/go-sqlite3"
)
const usageSchema = `
CREATE TABLE IF NOT EXISTS usage_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL DEFAULT '',
execution_id TEXT NOT NULL DEFAULT '',
trace_id TEXT NOT NULL DEFAULT '',
requested_at TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
key_alias TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
reasoning_effort TEXT NOT NULL DEFAULT '',
service_tier TEXT NOT NULL DEFAULT '',
speed TEXT NOT NULL DEFAULT '',
failed INTEGER NOT NULL,
executor_type TEXT NOT NULL DEFAULT '',
request_type TEXT NOT NULL DEFAULT '',
endpoint TEXT NOT NULL DEFAULT '',
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0,
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
ttft_ns INTEGER NOT NULL DEFAULT 0,
latency_ns INTEGER NOT NULL DEFAULT 0,
cost_micros INTEGER,
price_tier TEXT NOT NULL DEFAULT '',
fast_requested INTEGER NOT NULL DEFAULT 0,
fast_pricing_applied INTEGER NOT NULL DEFAULT 0,
price_multiplier_numerator INTEGER NOT NULL DEFAULT 1,
price_multiplier_denominator INTEGER NOT NULL DEFAULT 1,
client_ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_usage_records_requested_at ON usage_records(requested_at DESC, id DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_records_execution_id ON usage_records(execution_id) WHERE execution_id <> '';
CREATE TABLE IF NOT EXISTS request_records (
request_id TEXT PRIMARY KEY,
trace_id TEXT NOT NULL DEFAULT '',
requested_at TEXT NOT NULL,
completed_at TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
requested_model TEXT NOT NULL DEFAULT '',
source_format TEXT NOT NULL DEFAULT '',
stream INTEGER NOT NULL DEFAULT 0,
outcome TEXT NOT NULL,
status_code INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
endpoint TEXT NOT NULL DEFAULT '',
latency_ns INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_request_records_requested_at ON request_records(requested_at DESC);
CREATE TABLE IF NOT EXISTS model_prices (
model TEXT PRIMARY KEY,
input_rate_micros INTEGER NOT NULL,
cache_read_rate_micros INTEGER NOT NULL,
cache_write_rate_micros INTEGER NOT NULL,
output_rate_micros INTEGER NOT NULL,
long_context_enabled INTEGER NOT NULL DEFAULT 0,
long_context_threshold INTEGER NOT NULL DEFAULT 0,
long_context_comparison TEXT NOT NULL DEFAULT 'gt',
long_input_rate_micros INTEGER NOT NULL DEFAULT 0,
long_cache_read_rate_micros INTEGER NOT NULL DEFAULT 0,
long_cache_write_rate_micros INTEGER NOT NULL DEFAULT 0,
long_output_rate_micros INTEGER NOT NULL DEFAULT 0,
fast_pricing_enabled INTEGER NOT NULL DEFAULT 0,
fast_multiplier_numerator INTEGER NOT NULL DEFAULT 5,
fast_multiplier_denominator INTEGER NOT NULL DEFAULT 2,
updated_at TEXT NOT NULL
);
`
// SQLiteUsageRepository 使用单写连接保存全部用量记录。
type SQLiteUsageRepository struct {
db *sql.DB
}
func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) {
dsn, err := sqliteDSN(databasePath)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("打开 SQLite: %w", err)
}
// 当前插件只需要一个同步 writer,避免并发写争用。
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("连接 SQLite: %w", err)
}
if _, err := db.Exec(usageSchema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("初始化 SQLite schema: %w", err)
}
return &SQLiteUsageRepository{db: db}, nil
}
func sqliteDSN(databasePath string) (string, error) {
databasePath = strings.TrimSpace(databasePath)
if databasePath == "" {
return "", errors.New("database_path 不能为空")
}
if databasePath == ":memory:" {
return "file:cpa-ext-memory?mode=memory&cache=shared&_busy_timeout=5000&_foreign_keys=on", nil
}
if strings.HasPrefix(databasePath, "file:") {
separator := "?"
if strings.Contains(databasePath, "?") {
separator = "&"
}
return databasePath + separator + "_busy_timeout=5000&_foreign_keys=on&_journal_mode=WAL&_synchronous=NORMAL", nil
}
absolutePath, err := filepath.Abs(databasePath)
if err != nil {
return "", fmt.Errorf("解析 database_path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
return "", fmt.Errorf("创建数据库目录: %w", err)
}
fileURL := (&url.URL{Scheme: "file", Path: filepath.ToSlash(absolutePath)}).String()
return fileURL + "?_busy_timeout=5000&_foreign_keys=on&_journal_mode=WAL&_synchronous=NORMAL", nil
}
func (r *SQLiteUsageRepository) Insert(ctx context.Context, record collection.Record) error {
_, err := r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO usage_records (
request_id, execution_id, trace_id, requested_at, api_key, key_alias, model, reasoning_effort, service_tier, speed,
failed, executor_type, request_type, endpoint, input_tokens, output_tokens, reasoning_tokens,
cached_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
ttft_ns, latency_ns, cost_micros, price_tier, fast_requested, fast_pricing_applied,
price_multiplier_numerator, price_multiplier_denominator, client_ip
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.RequestID, record.ExecutionID, record.TraceID,
record.RequestedAt.UTC().Format(time.RFC3339Nano), record.APIKey, record.KeyAlias,
record.Model, record.ReasoningEffort, record.ServiceTier, record.Speed, record.Failed,
record.ExecutorType, record.RequestType, record.Endpoint, record.InputTokens, record.OutputTokens,
record.ReasoningTokens, record.CachedTokens, record.CacheReadTokens,
record.CacheWriteTokens, record.TotalTokens, int64(record.TTFT), int64(record.Latency),
record.CostMicros, record.PriceTier, record.FastRequested, record.FastPricingApplied,
record.PriceMultiplierNumerator, record.PriceMultiplierDenominator, record.ClientIP,
)
if err != nil {
return fmt.Errorf("写入用量记录: %w", err)
}
return nil
}
func (r *SQLiteUsageRepository) UpsertRequest(ctx context.Context, record collection.RequestRecord) error {
requestID := strings.TrimSpace(record.RequestID)
if requestID == "" {
return errors.New("request_id 不能为空")
}
requestedAt := record.RequestedAt
completedAt := record.CompletedAt
if requestedAt.IsZero() {
requestedAt = completedAt
}
if completedAt.IsZero() {
completedAt = requestedAt
}
latency := completedAt.Sub(requestedAt)
if latency < 0 {
latency = 0
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO request_records (
request_id, trace_id, requested_at, completed_at, model, requested_model,
source_format, stream, outcome, status_code, error, endpoint, latency_ns
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO UPDATE SET
trace_id=excluded.trace_id,
requested_at=excluded.requested_at,
completed_at=excluded.completed_at,
model=excluded.model,
requested_model=excluded.requested_model,
source_format=excluded.source_format,
stream=excluded.stream,
outcome=excluded.outcome,
status_code=excluded.status_code,
error=excluded.error,
endpoint=excluded.endpoint,
latency_ns=excluded.latency_ns`,
requestID, record.TraceID, requestedAt.UTC().Format(time.RFC3339Nano),
completedAt.UTC().Format(time.RFC3339Nano), record.Model, record.RequestedModel,
record.SourceFormat, record.Stream, record.Outcome, record.StatusCode,
record.Error, record.Endpoint, int64(latency),
)
if err != nil {
return fmt.Errorf("写入请求终态: %w", err)
}
return nil
}
func (r *SQLiteUsageRepository) ListRecent(ctx context.Context, limit int) ([]collection.Record, error) {
if limit < 1 {
return []collection.Record{}, nil
}
rows, err := r.db.QueryContext(ctx, `
WITH combined AS (
SELECT
u.request_id, u.execution_id, COALESCE(NULLIF(u.trace_id, ''), r.trace_id, '') AS trace_id,
u.requested_at, u.api_key, u.key_alias, u.model, u.reasoning_effort, u.service_tier, u.speed,
CASE
WHEN u.failed = 1 THEN 1
WHEN r.outcome IN ('failed', 'rejected', 'canceled') THEN 1
ELSE 0
END AS failed,
u.executor_type,
CASE WHEN r.request_id IS NOT NULL THEN CASE WHEN r.stream THEN 'SSE' ELSE 'JSON' END ELSE u.request_type END AS request_type,
COALESCE(NULLIF(u.endpoint, ''), r.endpoint, '') AS endpoint,
u.input_tokens, u.output_tokens, u.reasoning_tokens, u.cached_tokens,
u.cache_read_tokens, u.cache_write_tokens, u.total_tokens,
u.ttft_ns, u.latency_ns, u.cost_micros, u.price_tier, u.fast_requested,
u.fast_pricing_applied, u.price_multiplier_numerator, u.price_multiplier_denominator,
u.client_ip, COALESCE(r.outcome, '') AS outcome, COALESCE(r.status_code, 0) AS status_code,
COALESCE(r.error, '') AS error
FROM usage_records u
LEFT JOIN request_records r ON r.request_id = u.request_id
UNION ALL
SELECT
r.request_id, '', r.trace_id, r.requested_at, '', '',
COALESCE(NULLIF(r.model, ''), r.requested_model), '', '', '',
CASE WHEN r.outcome = 'succeeded' THEN 0 ELSE 1 END,
'', CASE WHEN r.stream THEN 'SSE' ELSE 'JSON' END, r.endpoint,
0, 0, 0, 0, 0, 0, 0, 0, r.latency_ns, NULL, '', 0, 0, 1, 1, '',
r.outcome, r.status_code, r.error
FROM request_records r
WHERE NOT EXISTS (SELECT 1 FROM usage_records u WHERE u.request_id = r.request_id)
)
SELECT request_id, execution_id, trace_id, requested_at, api_key, key_alias, model,
reasoning_effort, service_tier, speed, failed, executor_type, request_type, endpoint,
input_tokens, output_tokens, reasoning_tokens, cached_tokens, cache_read_tokens,
cache_write_tokens, total_tokens, ttft_ns, latency_ns, cost_micros, price_tier,
fast_requested, fast_pricing_applied, price_multiplier_numerator,
price_multiplier_denominator, client_ip, outcome, status_code, error
FROM combined
ORDER BY requested_at DESC
LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("查询最近用量记录: %w", err)
}
defer rows.Close()
records := make([]collection.Record, 0, limit)
for rows.Next() {
var record collection.Record
var requestedAt string
var failed bool
var ttftNS, latencyNS int64
var cost sql.NullInt64
if err := rows.Scan(
&record.RequestID, &record.ExecutionID, &record.TraceID,
&requestedAt, &record.APIKey, &record.KeyAlias, &record.Model,
&record.ReasoningEffort, &record.ServiceTier, &record.Speed, &failed, &record.ExecutorType,
&record.RequestType, &record.Endpoint, &record.InputTokens, &record.OutputTokens,
&record.ReasoningTokens, &record.CachedTokens, &record.CacheReadTokens,
&record.CacheWriteTokens, &record.TotalTokens, &ttftNS, &latencyNS,
&cost, &record.PriceTier, &record.FastRequested, &record.FastPricingApplied,
&record.PriceMultiplierNumerator, &record.PriceMultiplierDenominator, &record.ClientIP,
&record.Outcome, &record.StatusCode, &record.Error,
); err != nil {
return nil, fmt.Errorf("读取用量记录: %w", err)
}
record.RequestedAt, err = time.Parse(time.RFC3339Nano, requestedAt)
if err != nil {
return nil, fmt.Errorf("解析用量时间: %w", err)
}
record.Failed = failed
record.TTFT = time.Duration(ttftNS)
record.Latency = time.Duration(latencyNS)
if cost.Valid {
value := cost.Int64
record.CostMicros = &value
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历用量记录: %w", err)
}
return records, nil
}
func (r *SQLiteUsageRepository) Close() error {
return r.db.Close()
}
+94
View File
@@ -0,0 +1,94 @@
package repository_test
import (
"context"
"fmt"
"path/filepath"
"testing"
"time"
"cpa-ext/internal/collection"
"cpa-ext/internal/repository"
)
func TestSQLiteUsagePersistsAllRecordsAndLimitsQueries(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "usage.db")
store, err := repository.OpenSQLiteUsage(databasePath)
if err != nil {
t.Fatal(err)
}
requestedAt := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
for index := 1; index <= 1002; index++ {
record := collection.Record{
RequestedAt: requestedAt.Add(time.Duration(index) * time.Second),
Model: fmt.Sprintf("model-%d", index),
TotalTokens: int64(index),
}
if err := store.Insert(context.Background(), record); err != nil {
t.Fatalf("insert record %d: %v", index, err)
}
}
recent, err := store.ListRecent(context.Background(), 1000)
if err != nil {
t.Fatal(err)
}
if len(recent) != 1000 || recent[0].Model != "model-1002" || recent[999].Model != "model-3" {
t.Fatalf("unexpected recent records: len=%d first=%q last=%q", len(recent), recent[0].Model, recent[len(recent)-1].Model)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
reopened, err := repository.OpenSQLiteUsage(databasePath)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
all, err := reopened.ListRecent(context.Background(), 2000)
if err != nil {
t.Fatal(err)
}
if len(all) != 1002 {
t.Fatalf("persisted records = %d, want 1002", len(all))
}
}
func TestSQLiteUsageRoundTripsNullableCostAndDurations(t *testing.T) {
store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
cost := int64(125_000)
want := collection.Record{
RequestedAt: time.Date(2026, 8, 14, 12, 0, 0, 123, time.FixedZone("CST", 8*60*60)),
APIKey: "key",
Model: "model",
Failed: true,
RequestType: "SSE",
Endpoint: "POST /v1/responses",
InputTokens: 10,
CacheReadTokens: 8,
TTFT: 250 * time.Millisecond,
Latency: 2 * time.Second,
CostMicros: &cost,
PriceTier: "base",
PriceMultiplierNumerator: 1,
PriceMultiplierDenominator: 1,
ClientIP: "192.0.2.10",
}
if err := store.Insert(context.Background(), want); err != nil {
t.Fatal(err)
}
records, err := store.ListRecent(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
got := records[0]
if !got.RequestedAt.Equal(want.RequestedAt) || got.APIKey != want.APIKey || got.RequestType != want.RequestType || got.Endpoint != want.Endpoint || got.ClientIP != want.ClientIP || got.TTFT != want.TTFT || got.Latency != want.Latency || got.CostMicros == nil || *got.CostMicros != cost || got.PriceTier != "base" {
t.Fatalf("round trip mismatch: %+v", got)
}
}
+12
View File
@@ -0,0 +1,12 @@
// Package web 提供插件管理页面的静态资源。
package web
import _ "embed"
//go:embed ui.html
var uiHTML []byte
// UI 返回只读的管理页面内容。
func UI() []byte {
return uiHTML
}
+386
View File
@@ -0,0 +1,386 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CPA 用量记录</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; padding: 24px; background: #111827; color: #e5e7eb; }
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
h1 { margin: 0; font-size: 20px; }
.tools { display: flex; align-items: center; gap: 8px; }
input, select, button { border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; background: #1f2937; color: inherit; }
button { cursor: pointer; }
details { position: relative; }
summary { list-style: none; cursor: pointer; border: 1px solid #374151; border-radius: 6px; padding: 8px 10px; background: #1f2937; }
summary::-webkit-details-marker { display: none; }
.column-options { position: absolute; z-index: 10; right: 0; top: calc(100% + 6px); display: grid; grid-template-columns: repeat(2, max-content); gap: 8px 18px; padding: 12px; border: 1px solid #374151; border-radius: 6px; background: #1f2937; box-shadow: 0 12px 30px #0008; }
.column-options label { display: flex; align-items: center; gap: 6px; }
.status { min-height: 24px; color: #9ca3af; }
.pricing { position: static; margin-bottom: 16px; }
.pricing > summary { display: inline-block; }
.price-panel { margin-top: 10px; padding: 14px; border: 1px solid #374151; border-radius: 8px; background: #172033; }
.price-grid { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)); gap: 10px; }
.price-grid label { display: grid; gap: 5px; color: #9ca3af; font-size: 12px; }
.price-grid .check { display: flex; align-items: center; gap: 7px; color: #e5e7eb; }
.price-grid .check input { width: auto; }
.price-actions { display: flex; gap: 8px; margin-top: 12px; }
.price-list { display: grid; gap: 6px; margin-top: 14px; }
.price-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 10px; border: 1px solid #374151; border-radius: 6px; }
.price-row small { color: #9ca3af; }
.hidden { display: none; }
.table-wrap { overflow: auto; border: 1px solid #374151; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; white-space: nowrap; font-size: 13px; }
th, td { padding: 9px 10px; border-bottom: 1px solid #273244; text-align: right; }
th { position: sticky; top: 0; background: #1f2937; color: #9ca3af; font-weight: 600; }
th.left, td.left { text-align: left; }
tr:last-child td { border-bottom: 0; }
.ok { color: #34d399; }
.failed { color: #f87171; }
@media (max-width: 900px) { .price-grid { grid-template-columns: repeat(2, minmax(130px, 1fr)); } }
@media (max-width: 720px) { body { padding: 12px; } header { align-items: stretch; flex-direction: column; } .price-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<h1>最近用量记录</h1>
<div class="tools">
<input id="key" type="password" autocomplete="off" placeholder="管理密钥">
<details>
<summary>选择列</summary>
<div id="column-options" class="column-options"></div>
</details>
<button id="refresh" type="button">刷新</button>
</div>
</header>
<details class="pricing">
<summary>价格设置</summary>
<div class="price-panel">
<div class="price-grid">
<label>模型<input id="price-model" placeholder="gpt-5.6-sol"></label>
<label>输入 ($/1M)<input id="price-input" inputmode="decimal"></label>
<label>缓存读取 ($/1M)<input id="price-cache-read" inputmode="decimal"></label>
<label>缓存写入 ($/1M)<input id="price-cache-write" inputmode="decimal"></label>
<label>输出 ($/1M)<input id="price-output" inputmode="decimal"></label>
<label class="check"><input id="long-enabled" type="checkbox">启用长上下文价格</label>
<label class="long-field hidden">输入门槛<input id="long-threshold" type="number" min="0" step="1"></label>
<label class="long-field hidden">门槛比较<select id="long-comparison"><option value="gt">大于</option><option value="gte">大于等于</option></select></label>
<label class="long-field hidden">长上下文输入<input id="long-input" inputmode="decimal"></label>
<label class="long-field hidden">长上下文缓存读取<input id="long-cache-read" inputmode="decimal"></label>
<label class="long-field hidden">长上下文缓存写入<input id="long-cache-write" inputmode="decimal"></label>
<label class="long-field hidden">长上下文输出<input id="long-output" inputmode="decimal"></label>
<label class="check"><input id="fast-pricing-enabled" type="checkbox">Fast 使用倍率计价</label>
<label>Fast 倍率<input id="fast-multiplier" inputmode="decimal" value="2.5"></label>
</div>
<div class="price-actions"><button id="save-price" type="button">保存价格</button><button id="clear-price" type="button">清空表单</button></div>
<div id="price-status" class="status"></div>
<div id="price-list" class="price-list"></div>
</div>
</details>
<div id="status" class="status"></div>
<div class="table-wrap">
<table>
<thead><tr id="headers"></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<script>
const API = "/v0/management/plugins/cpa-ext/usage";
const PRICE_API = "/v0/management/plugins/cpa-ext/prices";
const keyInput = document.querySelector("#key");
const statusNode = document.querySelector("#status");
const rowsNode = document.querySelector("#rows");
const headersNode = document.querySelector("#headers");
const columnOptionsNode = document.querySelector("#column-options");
const columnStoreKey = "cpa-ext:usage-columns";
const priceStatusNode = document.querySelector("#price-status");
const priceListNode = document.querySelector("#price-list");
let currentRecords = [];
let lastSignature = "";
let loading = false;
let currentPrices = [];
function storedPanelKey() {
const prefix = "enc::v1::";
const salt = "cli-proxy-api-webui::secure-storage";
let raw = localStorage.getItem("cli-proxy-auth");
if (!raw) return "";
if (raw.startsWith(prefix)) {
const secret = new TextEncoder().encode(salt + "|" + location.host + "|" + navigator.userAgent);
const encoded = atob(raw.slice(prefix.length));
const bytes = new Uint8Array(encoded.length);
for (let i = 0; i < encoded.length; i++) bytes[i] = encoded.charCodeAt(i) ^ secret[i % secret.length];
raw = new TextDecoder().decode(bytes);
}
try {
const value = JSON.parse(raw);
return value?.state?.managementKey || "";
} catch (_) { return ""; }
}
function duration(value) {
return value > 0 ? value.toLocaleString() + " ms" : "-";
}
function number(value) {
return Number.isFinite(value) ? value.toLocaleString() : "-";
}
function percent(value) {
return Number.isFinite(value) ? value.toFixed(2) + "%" : "-";
}
function speed(value) {
return Number.isFinite(value) ? value.toFixed(1) + " tok/s" : "-";
}
function cost(record) {
return record.cost_available && Number.isFinite(record.cost_usd) ? "$" + record.cost_usd.toFixed(4) : "-";
}
function isCompactEndpoint(value) {
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "").split("?", 1)[0].replace(/\/+$/, "");
return path.endsWith("/responses/compact");
}
function endpoint(value) {
const path = String(value || "").replace(/^\s*(GET|POST|PUT|PATCH|DELETE)\s+/i, "");
if (isCompactEndpoint(path)) return "compact";
if (path.endsWith("/chat/completions")) return "chat";
if (path.endsWith("/responses")) return "responses";
return path.replace(/^\/v1\//, "") || "-";
}
function result(record) {
if (record.outcome === "canceled") return "已取消";
if (record.outcome === "rejected") return "已拒绝";
if (record.failed || record.outcome === "failed") return record.status_code ? `失败 (${record.status_code})` : "失败";
return "成功";
}
const columns = [
{ id: "time", label: "时间", align: "left", value: record => new Date(record.requested_at).toLocaleString() },
{ id: "key", label: "Key / 别名", align: "left", value: record => record.key_alias || record.api_key || "-" },
{ id: "model", label: "模型", align: "left", value: record => record.model || "-" },
{ id: "reasoning_effort", label: "推理强度", align: "left", value: record => record.reasoning_effort || "-" },
{ id: "service_tier", label: "模式", align: "left", value: record => String(record.speed || "").toLowerCase() === "fast" ? "fast" : (record.service_tier || "-") },
{ id: "result", label: "结果", align: "left", value: record => result(record) },
{ id: "request_type", label: "类型", align: "left", value: record => record.request_type || "-" },
{ id: "endpoint", label: "端点", align: "left", value: record => endpoint(record.endpoint) },
{ id: "ttft", label: "首字延迟", value: record => isCompactEndpoint(record.endpoint) ? "-" : duration(record.ttft_ms) },
{ id: "speed", label: "生成速度", value: record => isCompactEndpoint(record.endpoint) ? "-" : speed(record.speed_tps) },
{ id: "input", label: "输入", value: record => number(record.input_tokens) },
{ id: "output", label: "输出", value: record => number(record.output_tokens) },
{ id: "reasoning", label: "推理", value: record => number(record.reasoning_tokens) },
{ id: "cache_read", label: "缓存读取", value: record => number(record.cache_read_tokens) },
{ id: "cache_write", label: "缓存写入", value: record => number(record.cache_write_tokens) },
{ id: "cache_rate", label: "缓存率", value: record => percent(record.cache_rate) },
{ id: "total", label: "Token 总数", value: record => number(record.total_tokens) },
{ id: "cost", label: "总成本", value: record => cost(record) },
{ id: "client_ip", label: "客户端 IP", align: "left", value: record => record.client_ip || "-" }
];
function loadVisibleColumns() {
try {
const saved = JSON.parse(localStorage.getItem(columnStoreKey));
if (Array.isArray(saved)) {
const valid = saved.filter(id => columns.some(column => column.id === id));
if (valid.length) return new Set(valid);
}
} catch (_) {}
return new Set(columns.map(column => column.id));
}
let visibleColumns = loadVisibleColumns();
function renderColumnControls() {
headersNode.replaceChildren(...columns.filter(column => visibleColumns.has(column.id)).map(column => {
const header = document.createElement("th");
header.textContent = column.label;
if (column.align) header.className = column.align;
return header;
}));
columnOptionsNode.replaceChildren(...columns.map(column => {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.checked = visibleColumns.has(column.id);
input.addEventListener("change", () => {
if (!input.checked && visibleColumns.size === 1) {
input.checked = true;
return;
}
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
try { localStorage.setItem(columnStoreKey, JSON.stringify(columns.filter(item => visibleColumns.has(item.id)).map(item => item.id))); } catch (_) {}
renderColumnControls();
render(currentRecords);
});
label.append(input, column.label);
return label;
}));
}
function render(records) {
currentRecords = records;
const activeColumns = columns.filter(column => visibleColumns.has(column.id));
rowsNode.replaceChildren(...records.map(record => {
const row = document.createElement("tr");
activeColumns.forEach(column => {
const cell = document.createElement("td");
cell.textContent = column.value(record);
if (column.align) cell.classList.add(column.align);
if (column.id === "result") cell.classList.add(record.failed ? "failed" : "ok");
row.appendChild(cell);
});
return row;
}));
}
function authHeaders(json = false) {
const headers = { Authorization: "Bearer " + keyInput.value.trim() };
if (json) headers["Content-Type"] = "application/json";
return headers;
}
function setLongFields(enabled) {
document.querySelectorAll(".long-field").forEach(node => node.classList.toggle("hidden", !enabled));
}
function clearPriceForm() {
["price-model", "price-input", "price-cache-read", "price-cache-write", "price-output", "long-threshold", "long-input", "long-cache-read", "long-cache-write", "long-output"].forEach(id => document.querySelector("#" + id).value = "");
document.querySelector("#long-enabled").checked = false;
document.querySelector("#long-comparison").value = "gt";
document.querySelector("#fast-pricing-enabled").checked = false;
document.querySelector("#fast-multiplier").value = "2.5";
setLongFields(false);
}
function fillPriceForm(price) {
document.querySelector("#price-model").value = price.model;
document.querySelector("#price-input").value = price.base.input_per_1m;
document.querySelector("#price-cache-read").value = price.base.cache_read_per_1m;
document.querySelector("#price-cache-write").value = price.base.cache_write_per_1m;
document.querySelector("#price-output").value = price.base.output_per_1m;
const long = price.long_context;
document.querySelector("#long-enabled").checked = Boolean(long);
document.querySelector("#long-threshold").value = long?.threshold_input_tokens ?? "";
document.querySelector("#long-comparison").value = long?.comparison || "gt";
document.querySelector("#long-input").value = long?.input_per_1m ?? "";
document.querySelector("#long-cache-read").value = long?.cache_read_per_1m ?? "";
document.querySelector("#long-cache-write").value = long?.cache_write_per_1m ?? "";
document.querySelector("#long-output").value = long?.output_per_1m ?? "";
document.querySelector("#fast-pricing-enabled").checked = price.fast_pricing_enabled;
document.querySelector("#fast-multiplier").value = price.fast_multiplier || "2.5";
setLongFields(Boolean(long));
}
function pricePayload() {
const value = id => document.querySelector("#" + id).value.trim();
const payload = {
model: value("price-model"),
base: { input_per_1m: value("price-input"), cache_read_per_1m: value("price-cache-read"), cache_write_per_1m: value("price-cache-write"), output_per_1m: value("price-output") },
fast_pricing_enabled: document.querySelector("#fast-pricing-enabled").checked,
fast_multiplier: value("fast-multiplier")
};
if (document.querySelector("#long-enabled").checked) {
payload.long_context = {
threshold_input_tokens: Number(value("long-threshold")), comparison: value("long-comparison"),
input_per_1m: value("long-input"), cache_read_per_1m: value("long-cache-read"), cache_write_per_1m: value("long-cache-write"), output_per_1m: value("long-output")
};
}
return payload;
}
function renderPrices() {
priceListNode.replaceChildren(...currentPrices.map(price => {
const row = document.createElement("div");
row.className = "price-row";
const text = document.createElement("div");
const model = document.createElement("div");
model.textContent = price.model;
const detail = document.createElement("small");
detail.textContent = `输入 $${price.base.input_per_1m},缓存读 $${price.base.cache_read_per_1m},缓存写 $${price.base.cache_write_per_1m},输出 $${price.base.output_per_1m}${price.long_context ? ",含长上下文" : ""}${price.fast_pricing_enabled ? "Fast ×" + price.fast_multiplier : ""}`;
text.append(model, detail);
const actions = document.createElement("div");
const edit = document.createElement("button");
edit.type = "button"; edit.textContent = "编辑"; edit.addEventListener("click", () => fillPriceForm(price));
const remove = document.createElement("button");
remove.type = "button"; remove.textContent = "删除"; remove.addEventListener("click", () => deletePrice(price.model));
actions.append(edit, remove); row.append(text, actions); return row;
}));
}
async function loadPrices() {
if (!keyInput.value.trim()) return;
try {
const response = await fetch(PRICE_API, { headers: authHeaders() });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
currentPrices = payload.prices || [];
renderPrices();
priceStatusNode.textContent = currentPrices.length ? `已配置 ${currentPrices.length} 个模型` : "尚未配置模型价格";
} catch (error) { priceStatusNode.textContent = "读取价格失败: " + error.message; }
}
async function savePrice() {
try {
const response = await fetch(PRICE_API, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(pricePayload()) });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
priceStatusNode.textContent = "价格已保存";
await loadPrices();
} catch (error) { priceStatusNode.textContent = "保存失败: " + error.message; }
}
async function deletePrice(model) {
try {
const response = await fetch(PRICE_API, { method: "DELETE", headers: authHeaders(true), body: JSON.stringify({ model }) });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
if (document.querySelector("#price-model").value.trim() === model) clearPriceForm();
await loadPrices();
} catch (error) { priceStatusNode.textContent = "删除失败: " + error.message; }
}
async function load(manual = false) {
if (loading) return;
const key = keyInput.value.trim();
if (!key) {
statusNode.textContent = "请输入管理密钥";
return;
}
loading = true;
if (manual) statusNode.textContent = "正在读取";
try {
const response = await fetch(API, { headers: authHeaders() });
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error?.message || "HTTP " + response.status);
const records = payload.records || [];
const signature = JSON.stringify(records);
if (signature !== lastSignature) {
lastSignature = signature;
render(records);
}
statusNode.textContent = `显示 ${payload.records?.length || 0} 条,最多展示 ${payload.retained}`;
} catch (error) {
statusNode.textContent = "读取失败: " + error.message;
} finally {
loading = false;
}
}
keyInput.value = storedPanelKey() || sessionStorage.getItem("cpa-ext:management-key") || "";
keyInput.addEventListener("change", () => sessionStorage.setItem("cpa-ext:management-key", keyInput.value.trim()));
document.querySelector("#refresh").addEventListener("click", () => load(true));
document.querySelector("#long-enabled").addEventListener("change", event => setLongFields(event.target.checked));
document.querySelector("#save-price").addEventListener("click", savePrice);
document.querySelector("#clear-price").addEventListener("click", clearPriceForm);
document.addEventListener("visibilitychange", () => { if (!document.hidden) load(); });
renderColumnControls();
load();
loadPrices();
setInterval(() => { if (!document.hidden) load(); }, 3000);
</script>
</body>
</html>