// 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 }