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

297 lines
9.0 KiB
Go

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 {
mu sync.RWMutex
config Config
usage *collection.Service
store *repository.SQLiteUsageRepository
prices map[string]pricing.Policy
host HostCaller
closed bool
seen atomic.Uint64
pending sync.Map
scopes sync.Map
observedUpstreams sync.Map
}
func NewApp() *App {
return &App{config: defaultConfig(), prices: make(map[string]pricing.Policy)}
}
func (a *App) HandleMethod(method string, request []byte) (response []byte, err error) {
defer func() {
if recovered := recover(); recovered != nil {
response = nil
err = fmt.Errorf("插件处理 %s 时发生异常: %v", method, recovered)
}
}()
switch method {
case MethodPluginRegister, MethodPluginReconfigure:
return a.configure(request)
case MethodUsageHandle:
return a.handleUsage(request)
case MethodFrontendIdentifier:
return a.frontendIdentifier()
case MethodFrontendAuthenticate:
return a.authenticate(request)
case MethodSchedulerPick:
return a.pickScheduler(request)
case MethodRequestBefore:
return a.interceptRequest(request, false)
case MethodRequestAfter:
return a.interceptRequest(request, true)
case MethodRequestComplete:
return a.handleRequestComplete(request)
case MethodManagementRegister:
return OKEnvelope(managementRegistration())
case MethodManagementHandle:
return a.handleManagement(request)
case MethodPluginShutdown:
a.Shutdown()
return OKEnvelope(struct{}{})
default:
return ErrorEnvelope("unknown_method", "不支持的插件方法: "+method, http.StatusNotFound), nil
}
}
func (a *App) configure(raw []byte) ([]byte, error) {
var req LifecycleRequest
if len(raw) > 0 {
if err := json.Unmarshal(raw, &req); err != nil {
return nil, fmt.Errorf("解析生命周期请求: %w", err)
}
}
if req.SchemaVersion == 0 {
req.SchemaVersion = 1
}
negotiated := min(req.SchemaVersion, SchemaVersion)
cfg, err := decodeConfig(req.ConfigYAML)
if err != nil {
return nil, err
}
usageRepository, err := repository.OpenSQLiteUsage(cfg.DatabasePath)
if err != nil {
return nil, err
}
if _, err := usageRepository.BootstrapManagedKey(context.Background(), cfg.BootstrapName, cfg.BootstrapKey); err != nil {
_ = usageRepository.Close()
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("插件已经关闭")
}
previousUsage := a.usage
a.config = cfg
a.usage = nextUsage
a.store = usageRepository
a.prices = nextPrices
if previousUsage != nil {
_ = previousUsage.Close()
}
return OKEnvelope(registration(negotiated))
}
func registration(schemaVersion uint32) Registration {
return Registration{
SchemaVersion: schemaVersion,
Metadata: Metadata{
Name: PluginName,
Version: Version,
Author: "cpa-ext",
GitHubRepository: "https://git.pchuan.top/agent/cpa-plugin",
ConfigFields: []ConfigField{
{Name: "enabled", Type: "boolean", Description: "启用 CPA 扩展。"},
{Name: "codex_only", Type: "boolean", Description: "只接收 Codex/OpenAI 模型的用量事件。"},
{Name: "database_path", Type: "string", Description: "SQLite 数据库文件路径。"},
{Name: "bootstrap_name", Type: "string", Description: "首次启动时现有 Key 的名称。"},
{Name: "bootstrap_key", Type: "string", Description: "首次启动时导入的现有下游 Key。"},
},
},
Capabilities: Capabilities{
FrontendAuthProvider: true, FrontendAuthProviderExclusive: true,
Scheduler: true, RequestInterceptor: true,
RequestLifecyclePlugin: true, UsagePlugin: true, ManagementAPI: true,
},
}
}
func (a *App) handleUsage(raw []byte) ([]byte, error) {
var record UsageRecord
if err := json.Unmarshal(raw, &record); err != nil {
return nil, fmt.Errorf("解析用量事件: %w", err)
}
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,
AuthID: record.AuthID,
AuthIndex: record.AuthIndex,
AuthType: record.AuthType,
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 key, keyErr := a.store.ManagedKeyByCredential(context.Background(), record.APIKey); keyErr == nil {
observed.ManagedKeyID = key.ID
observed.KeyAlias = key.Name
}
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)
managedKeyID := metadataString(completion.Metadata, callerScopeMetadata)
if key, keyErr := a.store.ManagedKeyByReference(context.Background(), managedKeyID); keyErr == nil {
managedKeyID = key.ID
}
if pending, ok := a.pending.LoadAndDelete(completion.RequestID); managedKeyID == "" && ok {
managedKeyID, _ = pending.(string)
}
if err := a.usage.ObserveRequest(context.Background(), collection.RequestRecord{
ManagedKeyID: managedKeyID, 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
if a.usage != nil {
_ = a.usage.Close()
a.usage = nil
a.store = nil
}
}