Files
kandev/apps/backend/internal/office/repository/sqlite/costs.go
T
2026-08-24 11:09:03 +00:00

330 lines
13 KiB
Go

package sqlite
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"github.com/kandev/kandev/internal/office/models"
)
// ErrDuplicateUsageEvent is returned by CreateCostEvent when the row's
// UsageEventID collides with an existing one. The real duplicate source is
// not bus redelivery — neither the memory nor the NATS event bus redelivers
// (see internal/events/bus/{memory,nats}.go) — it is the SAME underlying
// completion frame reaching the publisher twice, e.g. a reconnecting WS
// client replaying a buffered stream event (see
// orchestrator.usageEventIDFor's doc comment for how the id is derived to
// collide in exactly that case). Either way this must not double-count
// cost; callers treat this as an idempotent no-op, not a failure, but still
// get a distinct signal to attribute to writer health rather than to
// "written".
var ErrDuplicateUsageEvent = errors.New("duplicate usage_event_id")
// usageEventIndexName is the partial unique index enforcing at most one row
// per non-NULL usage_event_id (docs/specs/office/requirements/costs.md).
const usageEventIndexName = "uniq_office_cost_usage_event"
// sqliteUsageEventViolationMessage is the substring go-sqlite3 puts in a
// UNIQUE-constraint error for this index. SQLite exposes no typed access to
// the violated index's name, only the column ("UNIQUE constraint failed:
// office_cost_events.usage_event_id"), which is unique to this index in
// this table.
const sqliteUsageEventViolationMessage = "UNIQUE constraint failed: office_cost_events.usage_event_id"
// isUsageEventUniqueViolation reports whether err is a violation of
// uniq_office_cost_usage_event specifically, not any unique violation. On
// PostgreSQL it inspects the typed pgconn.PgError's constraint name; on
// SQLite (no typed access to the constraint name) it matches the
// column-list message documented above. Mirrors
// internal/task/repository/sqlite/task_external_id.go's
// isExternalIDUniqueViolation.
func isUsageEventUniqueViolation(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505" && pgErr.ConstraintName == usageEventIndexName
}
return strings.Contains(err.Error(), sqliteUsageEventViolationMessage)
}
// CreateCostEvent records a new cost event. A UsageEventID collision
// (redelivery of the same prompt-usage event) is reported as
// ErrDuplicateUsageEvent rather than the raw driver error, so callers can
// treat it as an idempotent no-op. Office's cost subscriber no longer
// shares this insert with a task_sessions rollup write (that pairing now
// happens entirely inside internal/task/usage's writer via its own
// insertUsageEventAndRollup — docs/specs/task-cost-ledger/spec.md AC-10,
// AC-21), so this always executes against r.db directly with no
// transaction parameter to plumb through.
func (r *Repository) CreateCostEvent(ctx context.Context, event *models.CostEvent) error {
if event.ID == "" {
event.ID = uuid.New().String()
}
event.CreatedAt = time.Now().UTC()
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO office_cost_events (
id, session_id, task_id, agent_profile_id, project_id,
model, provider, tokens_in, tokens_cached_in,
tokens_cached_read, tokens_cached_write, tokens_out,
cost_subcents, estimated, turn_id, usage_event_id, cost_source,
rate_input_per_million, rate_cached_read_per_million,
rate_cached_write_per_million, rate_output_per_million,
pricing_catalog_version, cost_contract_version,
occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`), event.ID, event.SessionID, event.TaskID, event.AgentProfileID,
event.ProjectID, event.Model, event.Provider, event.TokensIn,
event.TokensCachedIn, event.TokensCachedRead, event.TokensCachedWrite,
event.TokensOut, event.CostSubcents, event.Estimated,
event.TurnID, event.UsageEventID, event.CostSource,
event.RateInputPerMillion, event.RateCachedReadPerMillion,
event.RateCachedWritePerMillion, event.RateOutputPerMillion,
event.PricingCatalogVersion, event.CostContractVersion,
event.OccurredAt, event.CreatedAt)
if err != nil && isUsageEventUniqueViolation(err) {
return ErrDuplicateUsageEvent
}
return err
}
// ListCostEvents returns cost events filtered by workspace (via task join), ordered by time.
func (r *Repository) ListCostEvents(ctx context.Context, workspaceID string) ([]*models.CostEvent, error) {
var events []*models.CostEvent
err := r.ro.SelectContext(ctx, &events, r.ro.Rebind(`
SELECT e.* FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
WHERE t.workspace_id = ?
ORDER BY e.occurred_at DESC
`), workspaceID)
if err != nil {
return nil, err
}
if events == nil {
events = []*models.CostEvent{}
}
return events, nil
}
// GetCostsByAgent returns aggregated costs grouped by agent, filtered by workspace.
// group_label resolves to the agent profile's display name; empty when the
// profile row has been deleted.
func (r *Repository) GetCostsByAgent(ctx context.Context, workspaceID string) ([]*models.CostBreakdown, error) {
var results []*models.CostBreakdown
err := r.ro.SelectContext(ctx, &results, r.ro.Rebind(`
SELECT e.agent_profile_id AS group_key,
COALESCE(MAX(NULLIF(ap.name, '')), '') AS group_label,
SUM(e.cost_subcents) AS total_subcents,
COUNT(*) AS count
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
LEFT JOIN agent_profiles ap ON ap.id = e.agent_profile_id
WHERE t.workspace_id = ?
GROUP BY e.agent_profile_id
`), workspaceID)
if err != nil {
return nil, err
}
if results == nil {
results = []*models.CostBreakdown{}
}
return results, nil
}
// GetCostsByProject returns aggregated costs grouped by project, filtered by
// workspace. Attribution is live: it groups by the task's *current*
// project_id, not the office_cost_events.project_id snapshot written at
// usage-event time. Unlike agent attribution (see GetCostsByAgent, and
// costContractVersion's doc comment in prompt_usage_cost.go), a task's
// project is an organisational grouping the user is expected to change
// after the work is done — reassigning a finished task must move its whole
// cost history onto the new project immediately, not strand it as
// "unassigned". group_label resolves to the project name; empty when
// project_id is unset or the project row has been deleted.
func (r *Repository) GetCostsByProject(ctx context.Context, workspaceID string) ([]*models.CostBreakdown, error) {
var results []*models.CostBreakdown
err := r.ro.SelectContext(ctx, &results, r.ro.Rebind(`
SELECT COALESCE(t.project_id, '') AS group_key,
COALESCE(MAX(NULLIF(op.name, '')), '') AS group_label,
SUM(e.cost_subcents) AS total_subcents,
COUNT(*) AS count
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
LEFT JOIN office_projects op ON op.id = t.project_id
WHERE t.workspace_id = ?
GROUP BY COALESCE(t.project_id, '')
`), workspaceID)
if err != nil {
return nil, err
}
if results == nil {
results = []*models.CostBreakdown{}
}
return results, nil
}
// GetCostsByModel returns aggregated costs grouped by (provider, model).
// The label prefixes the model with a friendly brand name derived from the
// provider column ("Claude - default", "OpenAI - gpt-5.4-mini") so a row
// like claude-acp's logical alias "default" is unambiguous. group_key uses
// "provider:model" to keep rows for the same model name under different
// providers distinct (e.g. anthropic:claude-sonnet-4 vs google:claude-sonnet-4
// — unlikely in practice but cheap to handle).
func (r *Repository) GetCostsByModel(ctx context.Context, workspaceID string) ([]*models.CostBreakdown, error) {
var results []*models.CostBreakdown
err := r.ro.SelectContext(ctx, &results, r.ro.Rebind(`
SELECT
COALESCE(NULLIF(e.provider, ''), '') || ':' || COALESCE(NULLIF(e.model, ''), '') AS group_key,
CASE COALESCE(NULLIF(e.provider, ''), '')
WHEN '' THEN COALESCE(NULLIF(e.model, ''), '')
WHEN 'anthropic' THEN 'Claude - ' || COALESCE(NULLIF(e.model, ''), '(unknown)')
WHEN 'openai' THEN 'OpenAI - ' || COALESCE(NULLIF(e.model, ''), '(unknown)')
WHEN 'google' THEN 'Gemini - ' || COALESCE(NULLIF(e.model, ''), '(unknown)')
ELSE e.provider || ' - ' || COALESCE(NULLIF(e.model, ''), '(unknown)')
END AS group_label,
SUM(e.cost_subcents) AS total_subcents,
COUNT(*) AS count
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
WHERE t.workspace_id = ?
GROUP BY e.provider, e.model
`), workspaceID)
if err != nil {
return nil, err
}
if results == nil {
results = []*models.CostBreakdown{}
}
return results, nil
}
// GetCostsByProvider returns aggregated costs grouped by provider so users
// can see spend per vendor (Claude / OpenAI / Gemini). Provider is whatever
// the office cost subscriber resolved at write time — usually derived from
// the CLI id (claude-acp → anthropic, codex-acp → openai, ...). Empty
// provider values bucket under "(unknown)" so the row is still surfaced
// rather than silently dropped.
func (r *Repository) GetCostsByProvider(ctx context.Context, workspaceID string) ([]*models.CostBreakdown, error) {
var results []*models.CostBreakdown
err := r.ro.SelectContext(ctx, &results, r.ro.Rebind(`
SELECT
COALESCE(NULLIF(e.provider, ''), 'unknown') AS group_key,
CASE COALESCE(NULLIF(e.provider, ''), 'unknown')
WHEN 'anthropic' THEN 'Claude'
WHEN 'openai' THEN 'OpenAI'
WHEN 'google' THEN 'Gemini'
WHEN 'unknown' THEN '(unknown)'
ELSE e.provider
END AS group_label,
SUM(e.cost_subcents) AS total_subcents,
COUNT(*) AS count
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
WHERE t.workspace_id = ?
GROUP BY COALESCE(NULLIF(e.provider, ''), 'unknown')
`), workspaceID)
if err != nil {
return nil, err
}
if results == nil {
results = []*models.CostBreakdown{}
}
return results, nil
}
// SumCosts returns the total cost in subcents (hundredths of a cent) for a
// workspace using SQL aggregation.
func (r *Repository) SumCosts(ctx context.Context, workspaceID string) (int64, error) {
var total int64
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT COALESCE(SUM(e.cost_subcents), 0)
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
WHERE t.workspace_id = ?
`), workspaceID).Scan(&total)
return total, err
}
// SumCostsSince returns the workspace's total cost in subcents incurred at or
// after `since`. A zero `since` returns the lifetime total (same as SumCosts).
// Used by period-aware budget rollups.
func (r *Repository) SumCostsSince(ctx context.Context, workspaceID string, since time.Time) (int64, error) {
if since.IsZero() {
return r.SumCosts(ctx, workspaceID)
}
var total int64
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT COALESCE(SUM(e.cost_subcents), 0)
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
WHERE t.workspace_id = ? AND e.occurred_at >= ?
`), workspaceID, since.UTC()).Scan(&total)
return total, err
}
// GetCostForAgent returns the total cost in subcents for a specific agent instance.
func (r *Repository) GetCostForAgent(ctx context.Context, agentID string) (int64, error) {
var total int64
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT COALESCE(SUM(cost_subcents), 0)
FROM office_cost_events
WHERE agent_profile_id = ?
`), agentID).Scan(&total)
return total, err
}
// GetCostForAgentSince returns the agent's total cost in subcents incurred at
// or after `since`. Zero `since` is the lifetime total.
func (r *Repository) GetCostForAgentSince(ctx context.Context, agentID string, since time.Time) (int64, error) {
if since.IsZero() {
return r.GetCostForAgent(ctx, agentID)
}
var total int64
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT COALESCE(SUM(cost_subcents), 0)
FROM office_cost_events
WHERE agent_profile_id = ? AND occurred_at >= ?
`), agentID, since.UTC()).Scan(&total)
return total, err
}
// GetCostForProject returns the total cost in subcents for a specific
// project. Like GetCostsByProject, this joins to tasks and uses the task's
// current project_id rather than the office_cost_events.project_id
// snapshot, so a project budget (costs/budgets.go) counts the same set of
// events as the "cost by project" panel — see GetCostsByProject's doc
// comment for why project attribution is live.
func (r *Repository) GetCostForProject(ctx context.Context, projectID string) (int64, error) {
var total int64
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT COALESCE(SUM(e.cost_subcents), 0)
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
WHERE t.project_id = ?
`), projectID).Scan(&total)
return total, err
}
// GetCostForProjectSince returns the project's total cost in subcents
// incurred at or after `since`. Zero `since` is the lifetime total.
func (r *Repository) GetCostForProjectSince(ctx context.Context, projectID string, since time.Time) (int64, error) {
if since.IsZero() {
return r.GetCostForProject(ctx, projectID)
}
var total int64
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(`
SELECT COALESCE(SUM(e.cost_subcents), 0)
FROM office_cost_events e
JOIN tasks t ON t.id = e.task_id
WHERE t.project_id = ? AND e.occurred_at >= ?
`), projectID, since.UTC()).Scan(&total)
return total, err
}