Files
2026-06-22 08:55:57 +08:00

397 lines
13 KiB
Go

// usage.go implements ACP session cost accounting:
// - ExtractUsage: a tolerant parser pulling per-turn billable token usage out
// of agent messages (session/prompt RESPONSE result.usage, snake/camel) and
// opportunistic cost out of usage_update notifications.
// - usageAccumulator: applies one parsed turn — prices it (ModelPriceLookup or
// agent self-reported cost fallback), persists ledger + running totals via
// the repo, and reports a budget breach when a cap is now exceeded.
//
// Billing source of truth (spec §7 risk #1): claude-agent-acp returns billable
// usage in the session/prompt response ({stopReason, usage:{input_tokens,...}}).
// The ACP usage_update notification carries a context-window GAUGE (used/size),
// NOT a per-turn delta — summing it would massively overcount, so it is used
// only for the optional cost{amount} fallback, never for token deltas.
package acp
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
)
// ParsedUsage is one extracted turn. HasUsage=false means no billable usage was
// present (ordinary chunk / malformed payload) and the caller must skip it.
type ParsedUsage struct {
PromptTokens int
CompletionTokens int
ThinkingTokens int
// CostUSD is the agent-self-reported cost (usage_update.cost.amount), used as
// a pricing fallback when no model price is configured. nil = not reported.
CostUSD *float64
HasUsage bool
}
// ModelPrice is the per-million-token price triple for a model.
type ModelPrice struct {
PromptPerM float64
CompletionPerM float64
ThinkingPerM float64
Found bool
}
// ModelPriceLookup resolves a model_id to its prices. Implemented in app.go by
// an adapter over the chat endpoint/model service.
type ModelPriceLookup interface {
PriceForModel(ctx context.Context, modelID uuid.UUID) (ModelPrice, error)
}
// UsageAccumulator applies parsed turns to one session: persists the ledger +
// running totals and reports budget breaches.
type UsageAccumulator interface {
// Observe applies one parsed turn. sourceEventID (>0) de-dups via the ledger
// unique index. Returns a non-empty breach reason when a cap is now exceeded.
Observe(ctx context.Context, p ParsedUsage, sourceEventID int64) (breach BreachReason, breached bool)
}
// usageSink is fed raw messages from relay.reader after persistence; it extracts
// usage and forwards to the UsageAccumulator. Separated from the accumulator so
// the relay does not depend on pricing/repo details.
type usageSink interface {
Observe(ctx context.Context, msg *Message, eventID int64)
}
// accumulatorSink adapts a UsageAccumulator to the relay's usageSink: it parses
// each agent->client message and, on a real billable turn, forwards to Observe.
// A breach invokes onBreach exactly once on a detached goroutine (so the relay
// reader never blocks on sup.Kill).
type accumulatorSink struct {
acc UsageAccumulator
onBreach func(reason BreachReason)
fired sync.Once
log *slog.Logger
}
// newAccumulatorSink builds the relay usage sink.
func newAccumulatorSink(acc UsageAccumulator, onBreach func(reason BreachReason), log *slog.Logger) *accumulatorSink {
if log == nil {
log = slog.Default()
}
return &accumulatorSink{acc: acc, onBreach: onBreach, log: log}
}
func (s *accumulatorSink) Observe(ctx context.Context, msg *Message, eventID int64) {
if s == nil || s.acc == nil {
return
}
p := ExtractUsage(msg)
if !p.HasUsage {
return
}
breach, ok := s.acc.Observe(ctx, p, eventID)
if !ok || s.onBreach == nil {
return
}
// Fire the kill exactly once, detached so the reader keeps draining.
s.fired.Do(func() {
reason := breach
go s.onBreach(reason)
})
}
// ===== ExtractUsage =====
// promptResponseUsage matches both snake_case (claude-agent-acp stable) and
// camelCase (unstable v2 Usage) usage objects on a session/prompt response.
type promptResponseResult struct {
StopReason string `json:"stopReason"`
Usage *struct {
// snake_case (stable)
InputTokens *int `json:"input_tokens"`
OutputTokens *int `json:"output_tokens"`
// camelCase (unstable v2)
InputTokensCamel *int `json:"inputTokens"`
OutputTokensCamel *int `json:"outputTokens"`
ThoughtTokens *int `json:"thoughtTokens"`
CachedReadTokens *int `json:"cachedReadTokens"`
CachedWriteTokens *int `json:"cachedWriteTokens"`
// thinking_tokens snake fallback
ThinkingTokens *int `json:"thinking_tokens"`
} `json:"usage"`
}
// usageUpdateParams matches a session/update notification carrying usage_update.
type usageUpdateParams struct {
Update *struct {
SessionUpdate string `json:"sessionUpdate"`
Cost *struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"cost"`
} `json:"update"`
}
// ExtractUsage is the tolerant entry point. It inspects an agent->client message
// for billable usage. Never panics on garbage — returns HasUsage=false instead.
func ExtractUsage(msg *Message) ParsedUsage {
if msg == nil {
return ParsedUsage{}
}
// 1) session/prompt RESPONSE result.usage — the reliable billable source.
if len(msg.Result) > 0 {
if p, ok := parsePromptResponseUsage(msg.Result); ok {
return p
}
}
// 2) usage_update notification — opportunistic cost only (used/size is a
// context-window gauge, NOT a billable token delta; do not sum it).
if msg.Method == "session/update" && len(msg.Params) > 0 {
if p, ok := parseUsageUpdateCost(msg.Params); ok {
return p
}
}
return ParsedUsage{}
}
func parsePromptResponseUsage(result json.RawMessage) (ParsedUsage, bool) {
var r promptResponseResult
if err := json.Unmarshal(result, &r); err != nil || r.Usage == nil {
return ParsedUsage{}, false
}
u := r.Usage
prompt := firstNonNil(u.InputTokens, u.InputTokensCamel)
completion := firstNonNil(u.OutputTokens, u.OutputTokensCamel)
thinking := firstNonNil(u.ThoughtTokens, u.ThinkingTokens)
// cachedReadTokens/cachedWriteTokens are reported but not separately priced;
// fold cached writes into prompt-equivalent only if no input token present.
if prompt == 0 && completion == 0 && thinking == 0 {
// No billable token signal in the usage object.
return ParsedUsage{}, false
}
return ParsedUsage{
PromptTokens: prompt,
CompletionTokens: completion,
ThinkingTokens: thinking,
HasUsage: true,
}, true
}
func parseUsageUpdateCost(params json.RawMessage) (ParsedUsage, bool) {
var p usageUpdateParams
if err := json.Unmarshal(params, &p); err != nil || p.Update == nil {
return ParsedUsage{}, false
}
if p.Update.SessionUpdate != "usage_update" || p.Update.Cost == nil {
return ParsedUsage{}, false
}
amt := p.Update.Cost.Amount
if amt <= 0 {
return ParsedUsage{}, false
}
return ParsedUsage{CostUSD: &amt, HasUsage: true}, true
}
func firstNonNil(a, b *int) int {
if a != nil {
return *a
}
if b != nil {
return *b
}
return 0
}
// ===== usageAccumulator (per-session) =====
// usageRepo is the narrow repo surface the accumulator needs.
type usageRepo interface {
InsertSessionUsage(ctx context.Context, r *SessionUsageRecord) (bool, error)
AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error)
SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error)
}
// pgUsageAccumulator is the concrete per-session accumulator. Thread-safe: the
// relay reader is single-goroutine today, but Observe is guarded so future
// concurrent callers (or tests) stay correct.
type pgUsageAccumulator struct {
repo usageRepo
prices ModelPriceLookup
log *slog.Logger
now func() time.Time
mu sync.Mutex
dedup map[int64]struct{}
sessionID uuid.UUID
userID uuid.UUID
projectID uuid.UUID
agentKindID uuid.UUID
modelID *uuid.UUID
startedAt time.Time
caps BudgetCaps
// projectBudgetUSD (>0) enforces a cumulative per-project ACP spend soft cap
// over [projectSince, now]; 0 disables it.
projectBudgetUSD float64
projectSince time.Time
}
// AccumulatorParams seeds a per-session accumulator.
type AccumulatorParams struct {
Repo usageRepo
Prices ModelPriceLookup
Log *slog.Logger
SessionID uuid.UUID
UserID uuid.UUID
ProjectID uuid.UUID
AgentKindID uuid.UUID
ModelID *uuid.UUID
StartedAt time.Time
Caps BudgetCaps
// ProjectBudgetUSD, when >0, enforces a per-project soft cap on cumulative
// ACP spend since ProjectSince.
ProjectBudgetUSD float64
ProjectSince time.Time
Now func() time.Time
}
func newUsageAccumulator(p AccumulatorParams) *pgUsageAccumulator {
log := p.Log
if log == nil {
log = slog.Default()
}
now := p.Now
if now == nil {
now = time.Now
}
since := p.ProjectSince
if since.IsZero() {
since = now().Add(-30 * 24 * time.Hour)
}
return &pgUsageAccumulator{
repo: p.Repo,
prices: p.Prices,
log: log,
now: now,
dedup: map[int64]struct{}{},
sessionID: p.SessionID,
userID: p.UserID,
projectID: p.ProjectID,
agentKindID: p.AgentKindID,
modelID: p.ModelID,
startedAt: p.StartedAt,
caps: p.Caps,
projectBudgetUSD: p.ProjectBudgetUSD,
projectSince: since,
}
}
func (a *pgUsageAccumulator) Observe(ctx context.Context, p ParsedUsage, sourceEventID int64) (BreachReason, bool) {
if !p.HasUsage {
return "", false
}
a.mu.Lock()
defer a.mu.Unlock()
// In-memory de-dup guard (DB unique index is the durable de-dup).
if sourceEventID > 0 {
if _, seen := a.dedup[sourceEventID]; seen {
return "", false
}
}
cost := a.price(ctx, p)
// Persist ledger row first (idempotent per source_event_id), then accumulate
// session totals only when the ledger row was actually inserted (no re-add on
// a duplicate event).
var srcID *int64
if sourceEventID > 0 {
v := sourceEventID
srcID = &v
}
inserted, err := a.repo.InsertSessionUsage(ctx, &SessionUsageRecord{
SessionID: a.sessionID,
UserID: a.userID,
ProjectID: a.projectID,
AgentKindID: a.agentKindID,
ModelID: a.modelID,
PromptTokens: int64(p.PromptTokens),
CompletionTokens: int64(p.CompletionTokens),
ThinkingTokens: int64(p.ThinkingTokens),
CostUSD: cost,
SourceEventID: srcID,
})
if err != nil {
a.log.Error("acp.usage.insert_ledger", "session_id", a.sessionID, "err", err.Error())
return "", false
}
if !inserted {
// Duplicate event id — already accounted.
if sourceEventID > 0 {
a.dedup[sourceEventID] = struct{}{}
}
return "", false
}
if sourceEventID > 0 {
a.dedup[sourceEventID] = struct{}{}
}
totPrompt, totCompletion, totThinking, totCost, err := a.repo.AddSessionUsageTotals(
ctx, a.sessionID,
int64(p.PromptTokens), int64(p.CompletionTokens), int64(p.ThinkingTokens), cost)
if err != nil {
a.log.Error("acp.usage.add_totals", "session_id", a.sessionID, "err", err.Error())
return "", false
}
return a.checkBreach(ctx, totPrompt, totCompletion, totThinking, totCost)
}
// price computes the turn cost: prefer the configured model price; otherwise
// fall back to the agent-self-reported cost; otherwise 0 (unpriced).
func (a *pgUsageAccumulator) price(ctx context.Context, p ParsedUsage) float64 {
if a.modelID != nil && a.prices != nil {
if mp, err := a.prices.PriceForModel(ctx, *a.modelID); err == nil && mp.Found {
return (float64(p.PromptTokens)*mp.PromptPerM +
float64(p.CompletionTokens)*mp.CompletionPerM +
float64(p.ThinkingTokens)*mp.ThinkingPerM) / 1_000_000
}
}
if p.CostUSD != nil {
return *p.CostUSD
}
return 0
}
func (a *pgUsageAccumulator) checkBreach(ctx context.Context, totPrompt, totCompletion, totThinking int64, totCost float64) (BreachReason, bool) {
// Wall-clock: enforced here too (the reaper is the periodic backstop).
if a.caps.MaxWallClockSeconds != nil {
elapsed := a.now().Sub(a.startedAt)
if elapsed >= time.Duration(*a.caps.MaxWallClockSeconds)*time.Second {
return BreachWallClock, true
}
}
if a.caps.MaxTokens != nil {
if totPrompt+totCompletion+totThinking >= *a.caps.MaxTokens {
return BreachTokens, true
}
}
if a.caps.MaxCostUSD != nil && totCost >= *a.caps.MaxCostUSD {
return BreachCost, true
}
// Per-project soft cap (advisory kill, slight overshoot tolerated under
// concurrency — spec §7 risk). Only queried when a project budget is set.
if a.projectBudgetUSD > 0 {
if projCost, err := a.repo.SumProjectCostUSD(ctx, a.projectID, a.projectSince); err == nil {
if projCost >= a.projectBudgetUSD {
return BreachCost, true
}
} else {
a.log.Warn("acp.usage.project_cost_check", "project_id", a.projectID, "err", err.Error())
}
}
return "", false
}