Files
kandev/apps/backend/internal/task/usage/decode.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

67 lines
3.1 KiB
Go

// Package usage subscribes to the session-prompt-usage bus subject and
// records an append-only task_usage_events ledger row per observed event
// (docs/specs/task-cost-ledger/spec.md). It must not import any
// internal/office/** package (AC-25): Office records its own, separate
// cost_events ledger off the same bus subject, and this package is a
// sibling consumer, not a dependent.
package usage
import (
"encoding/json"
"github.com/kandev/kandev/internal/events/bus"
)
// usageEventPayload mirrors lifecycle.SessionPromptUsageEventPayload's JSON
// shape field-for-field (AC-25). It is declared locally rather than
// importing that type because internal/agent/runtime/lifecycle imports
// internal/task/models, so importing it here would point a dependency edge
// back up the stack the wrong way; the wire contract is the JSON, not a
// shared Go type.
type usageEventPayload struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
AgentID string `json:"agent_id"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
AgentType string `json:"agent_type,omitempty"`
Model string `json:"model,omitempty"`
Usage *promptUsagePayload `json:"usage"`
Timestamp string `json:"timestamp"`
TurnID string `json:"turn_id,omitempty"`
UsageEventID string `json:"usage_event_id,omitempty"`
}
// promptUsagePayload mirrors streams.PromptUsage's JSON shape (AC-30).
// OutputTokensPresent is always serialized by the producer, which is what
// makes an unsampled output-token count distinguishable from a measured
// zero; CachedReadTokens, CachedWriteTokens and ThoughtTokens are plain
// omitempty ints, so for those three an absent field and a measured zero
// are indistinguishable on the wire by design (AC-30).
type promptUsagePayload struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
OutputTokensPresent bool `json:"output_tokens_present"`
CachedReadTokens int64 `json:"cached_read_tokens,omitempty"`
CachedWriteTokens int64 `json:"cached_write_tokens,omitempty"`
ThoughtTokens int64 `json:"thought_tokens,omitempty"`
TotalTokens int64 `json:"total_tokens,omitempty"`
ProviderReportedCostSubcents int64 `json:"provider_reported_cost_subcents,omitempty"`
ProviderReportedCostPresent bool `json:"provider_reported_cost_present,omitempty"`
Estimated bool `json:"estimated,omitempty"`
}
// decodePayload extracts a typed usageEventPayload from a bus event via a
// JSON marshal/unmarshal round trip (mirrors office/service.decodeEventData
// for the same wire event, without importing that unexported helper).
func decodePayload(event *bus.Event) (*usageEventPayload, error) {
raw, err := json.Marshal(event.Data)
if err != nil {
return nil, err
}
var payload usageEventPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
return &payload, nil
}