330 lines
13 KiB
Go
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
|
|
}
|