Files
kandev/apps/backend/internal/task/usage/build.go
T
Henry 4c0d92154b feat(backend): add per-run cost/token ledger for task usage (#2960)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-23 22:15:19 +00:00

163 lines
5.3 KiB
Go

package usage
import (
"context"
"math"
"time"
commoncosts "github.com/kandev/kandev/internal/common/costs"
"github.com/kandev/kandev/internal/task/models"
)
// Cost-source labels recorded on task_usage_events.cost_source (AC-6, AC-7,
// AC-8).
const (
CostSourceProviderReported = "provider_reported"
CostSourceModelsDevList = "models_dev_list"
CostSourceUnpriced = "unpriced"
)
// pricingLookupTimeout is AC-8's writer-owned deadline on a pricing call,
// independent of and much shorter than the lookup implementation's own
// HTTP timeout, so a pricing-catalogue outage degrades to an unpriced row
// instead of stalling the single serial worker.
const pricingLookupTimeout = 2 * time.Second
// PricingLookup is the narrow interface the writer declares for pricing
// resolution (AC-26): a single call returning both the rates and the
// catalogue version they came from, so a background catalogue refresh
// between two separate calls can never pair one snapshot's rates with
// another snapshot's version. No internal/office/** type crosses this
// boundary - production wiring adapts whatever concrete lookup it uses
// (e.g. modelsdev.Client) to this shape outside this package.
type PricingLookup interface {
LookupForModelWithVersion(ctx context.Context, modelID string) (pricing commoncosts.ModelPricing, version string, ok bool)
}
// buildRow constructs a fully-populated task_usage_events row from a
// validated payload (AC-2, AC-5, AC-6..AC-9, AC-23, AC-30, AC-31, AC-35).
// occurredAt is supplied by the caller so it is acquired exactly once, on
// the worker, immediately before the first repository attempt.
func (w *Writer) buildRow(ctx context.Context, p *usageEventPayload, occurredAt time.Time) *models.TaskUsageEvent {
usage := p.Usage
if usage == nil {
usage = &promptUsagePayload{}
}
event := &models.TaskUsageEvent{
UsageEventID: p.UsageEventID,
TaskID: p.TaskID,
SessionID: p.SessionID,
TurnID: p.TurnID,
AgentProfileID: p.AgentProfileID,
AgentType: p.AgentType,
Model: p.Model,
Provider: resolveProvider(p.AgentType, p.AgentID, p.Model),
Estimated: usage.Estimated,
ContractVersion: ContractVersion,
OccurredAt: occurredAt,
CreatedAt: occurredAt,
}
event.TokensIn = usage.InputTokens
cachedRead := usage.CachedReadTokens
cachedWrite := usage.CachedWriteTokens
thought := usage.ThoughtTokens
event.TokensCachedRead = &cachedRead
event.TokensCachedWrite = &cachedWrite
event.TokensThought = &thought
if usage.OutputTokensPresent {
out := usage.OutputTokens
event.TokensOut = &out
}
var ok bool
event.TokensTotal, ok = sumTokenCounts(
event.TokensIn, cachedRead, cachedWrite, optionalInt64(event.TokensOut), thought,
)
if !ok {
return nil
}
w.resolveCost(ctx, event, usage)
return event
}
// resolveProvider derives event.Provider (AC-31): the first non-empty of
// the CLI-id mapping applied to agent_type, the same mapping applied to
// agent_id, a model-prefix match, then "". Office's resolveProvider applies
// the same two extracted helpers in the same order, so the two writers
// label the same event identically.
func resolveProvider(agentType, agentID, model string) string {
if provider := commoncosts.ProviderFromCLI(agentType); provider != "" {
return provider
}
if provider := commoncosts.ProviderFromCLI(agentID); provider != "" {
return provider
}
return commoncosts.ProviderForModel(model)
}
// resolveCost implements AC-6/AC-7/AC-8/AC-9's three-way cost-resolution
// ladder: a provider-reported cost is recorded verbatim (including an
// explicit zero); otherwise a models.dev catalogue lookup within
// pricingLookupTimeout computes cost_subcents from a single pricing
// snapshot; otherwise the row is still recorded, unpriced.
func (w *Writer) resolveCost(ctx context.Context, event *models.TaskUsageEvent, usage *promptUsagePayload) {
if usage.ProviderReportedCostPresent {
event.CostSubcents = usage.ProviderReportedCostSubcents
event.CostSource = CostSourceProviderReported
return
}
if w.pricing == nil {
event.CostSource = CostSourceUnpriced
return
}
lookupCtx, cancel := context.WithTimeout(ctx, pricingLookupTimeout)
defer cancel()
pricing, version, ok := w.pricing.LookupForModelWithVersion(lookupCtx, event.Model)
if !ok {
event.CostSource = CostSourceUnpriced
return
}
cost, ok := commoncosts.CalculateCostSubcentsChecked(
event.TokensIn, optionalInt64(event.TokensCachedRead), optionalInt64(event.TokensCachedWrite),
optionalInt64(event.TokensOut), pricing,
)
if !ok {
event.CostSource = CostSourceUnpriced
return
}
event.CostSubcents = cost
event.CostSource = CostSourceModelsDevList
event.RateInputPerMillion = int64Ptr(pricing.InputPerMillion)
event.RateCachedReadPerMillion = int64Ptr(pricing.CachedReadPerMillion)
event.RateCachedWritePerMillion = int64Ptr(pricing.CachedWritePerMillion)
event.RateOutputPerMillion = int64Ptr(pricing.OutputPerMillion)
event.PricingCatalogVersion = version
}
func optionalInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func sumTokenCounts(values ...int64) (int64, bool) {
var total int64
for _, value := range values {
if value < 0 || total > math.MaxInt64-value {
return 0, false
}
total += value
}
return total, true
}
func int64Ptr(v int64) *int64 {
return &v
}