Files
multica/server/pkg/db/generated/runtime_usage.sql.go
Bohan Jiang ffa8e16369 MUL-5228 fix(usage): bill Grok at xAI's reported cost, fix $0 resumed sessions (#5841)
* fix(agent): attribute Grok usage from the turn's own model id

A resumed Grok session with no configured model recorded its entire spend
under the model id "unknown", which matches no pricing row — so the task
reported $0 cost instead of its real spend.

grok.go only learned the model from the session handshake, and ACP's
`session/load` carries no model id (only `session/new` does). When neither
the agent nor MULTICA_GROK_MODEL pins a model, `daemon.go` legitimately
passes an empty model, leaving nothing to attribute the usage to.

Every Grok turn stamps `result._meta.modelId` with what it actually billed
against. Parse it in the shared ACP result parser and use it as the fallback
in grok.go. Other ACP backends are untouched — they keep whatever the
handshake gave them.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* fix(metrics): price the Grok catalog in server-side cost metrics

server/internal/metrics/pricing.go carried no Grok rows at all, so
RecordLLMUsage took the unpriced branch for every Grok turn: llm_cost_usd
reported zero Grok spend while the tokens accumulated in
llm_unpriced_tokens. Internal cost monitoring simply could not see Grok.

Add the six SKUs xAI publishes rates for, mirroring the frontend table in
packages/views/runtimes/utils.ts. Aliases are anchored exact matches like
the gpt-5.6 rows, so `grok-composer-*` (in the catalog, absent from the
price sheet) stays unmapped instead of inheriting a guessed rate.

Short-context tier on purpose: xAI bills a request at 2x once its prompt
reaches 200K tokens, but a usage record aggregates every model call in a
turn and cannot say which tier an individual request hit.

A regression test re-derives the cost of a real grok 0.2.106 turn from the
table and checks it against the costUsdTicks xAI returned for that turn.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): scope the Grok cost claim to what was actually fixed

The v0.4.9 entry promised "accurate cost" in all four languages, but the
fix corrected catalog pricing and cached-input double-counting — it did not
implement xAI's 2x long-context tier, so a turn whose requests reach 200K
prompt tokens still under-reports by up to 50%. Say what was fixed instead.

Also correct two stale claims in the pricing comment: the daemon tags usage
rows with the runtime provider `grok`, not `xai` (the bare `grok-*` keys are
what make them resolve), and record why thresholding the long-context tier
on an aggregated row would be worse than not pricing it at all.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* feat(usage): carry the provider's own cost through to the usage record

Cost has always been derived client-side as tokens x a static rate, which
cannot express request-level pricing rules. xAI bills a Grok request at 2x
once its prompt reaches 200K tokens, and a task_usage row aggregates every
model call in a turn — so the stored token counts genuinely cannot say which
tier any individual request hit. Thresholding on the aggregate would be worse
than the status quo: it turns a bounded 50% under-estimate into an unbounded
over-estimate for turns made of many short requests.

Grok already reports what it charged, per turn, in `_meta.usage.costUsdTicks`.
Parse it, carry it through agent -> daemon -> API, and store it on task_usage
as a nullable BIGINT of 1e-10 USD ticks (integer, so sub-cent turns stay exact
end to end). NULL means the provider reported no cost — every pre-existing row
and every provider that doesn't return one. No backfill: there is no
authoritative figure to recover for those, and inventing one is the guess this
removes.

A single hourly bucket can mix rows that carry a cost with rows that don't, so
task_usage_hourly gains both halves: `cost_usd_ticks` sums the authoritative
side, and `uncosted_*_tokens` carry exactly the tokens that still need a
rate-table estimate. Consumers report authoritative + estimate(uncosted),
which degrades to today's behaviour when nothing in the bucket is
authoritative. The existing token columns keep covering every row, so token
displays are untouched. The new columns are additive with defaults, so the
unique key, the dirty-queue shape, and migration 102's triggers are unaffected.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* feat(usage): prefer the provider's own cost over the rate table

With the authoritative figure now stored, both cost consumers use it: the
usage dashboard (estimateCost / estimateCostBreakdown) and the server-side
llm_cost_usd metric. Each reports `authoritative + estimate(uncosted tokens)`,
so a row or bucket that mixes priced and unpriced sources stays whole.

The static rate tables remain, but for Grok they are now a fallback — they
still price usage recorded by a daemon too old to report cost, and every
provider that reports none. Custom pricing overrides likewise apply only to
the estimated half: they are a user's guess at a rate, and the authoritative
half is not a guess. A model with no rate-table row but a provider-reported
cost now also drops out of the "unmapped models" banner, since asking the user
to supply a rate for it would invite overriding a real bill.

llm_cost_usd is labelled by token_type and the provider reports one number per
turn, so the charge is distributed across the buckets in the rate table's own
proportions. Only the total is authoritative; the split stays an estimate,
which is why this scales the existing buckets rather than inventing a label.
estimateCostBreakdown does the same, keeping the stacked chart summing to the
headline figure instead of silently under-drawing every Grok row.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): say Grok cost now follows xAI's actual charge

The earlier wording scoped the claim down to catalog pricing and cached input
because the long-context tier was still unhandled. It is handled now — the
cost comes from what xAI charged for the turn — so the entry can say so.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* fix(usage): keep the provider's cost when the model has no rate row

Both cost consumers bailed out before reading the authoritative figure when
the rate table had no row for the model. A `grok-composer-*` turn — in the
Grok Build catalog, absent from xAI's price sheet — was therefore reported as
$0 spend even though xAI told us exactly what it charged.

Worse on the client: estimateCost returned the real cost while
estimateCostBreakdown returned zeros, so the headline and the stacked chart
disagreed on precisely the rows whose cost is exact — and the unmapped-models
banner was (correctly) hidden, so nothing explained the discrepancy.

Handle the charge before the rate lookup in both places. Without rates there
is nothing to split a total by, so it lands whole in the `input` bucket, the
same fallback distributeAuthoritativeCost already uses when it has no shape to
scale. Tokens with no rate keep going to llm_unpriced_tokens: "unpriced"
describes the rate table, not the money.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

* perf(usage): drop the historical rewrite from the cost-split migration

Migration 213 rewrote every existing task_usage_hourly row to seed the
uncosted counters. That is a full-table UPDATE inside a schema migration —
lock time, WAL and bloat all scaling with table size — for rows this issue
explicitly does not care about.

Deleting the UPDATE alone would have zeroed historical cost: with
`NOT NULL DEFAULT 0`, an untouched row asserts "nothing here needs
estimating", so every pre-split bucket would report $0 until the rollup
happened to touch it. Make the uncosted columns nullable with no default
instead. NULL means "never recomputed since the split existed", readers
COALESCE it to the row's own token total ("estimate all of it"), and the
pre-split behaviour is preserved exactly — with nothing to seed, so no
rewrite. A bare ADD COLUMN is metadata-only, so this is now fast DDL.

Rows heal into the split naturally as the rollup recomputes their buckets.

Verified on a fresh database: a legacy-shaped row reads back as its full
tokens to estimate, and a group mixing legacy and post-split buckets sums to
the authoritative cost plus both rows' estimable tokens.

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-24 01:42:08 +08:00

314 lines
12 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: runtime_usage.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getRuntimeTaskHourlyActivity = `-- name: GetRuntimeTaskHourlyActivity :many
SELECT EXTRACT(HOUR FROM started_at AT TIME ZONE $2::text)::int AS hour,
COUNT(*)::int AS count
FROM agent_task_queue
WHERE runtime_id = $1 AND started_at IS NOT NULL
GROUP BY hour
ORDER BY hour
`
type GetRuntimeTaskHourlyActivityParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Tz string `json:"tz"`
}
type GetRuntimeTaskHourlyActivityRow struct {
Hour int32 `json:"hour"`
Count int32 `json:"count"`
}
// Hour-of-day distribution for queue starts. Bucketed in the viewer's
// tz so "this runtime is busy in the afternoon" actually means
// the operator's afternoon, not UTC's.
func (q *Queries) GetRuntimeTaskHourlyActivity(ctx context.Context, arg GetRuntimeTaskHourlyActivityParams) ([]GetRuntimeTaskHourlyActivityRow, error) {
rows, err := q.db.Query(ctx, getRuntimeTaskHourlyActivity, arg.RuntimeID, arg.Tz)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRuntimeTaskHourlyActivityRow{}
for rows.Next() {
var i GetRuntimeTaskHourlyActivityRow
if err := rows.Scan(&i.Hour, &i.Count); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRuntimeUsageByHour = `-- name: GetRuntimeUsageByHour :many
SELECT
EXTRACT(HOUR FROM tu.created_at AT TIME ZONE $2::text)::int AS hour,
tu.model,
SUM(tu.input_tokens)::bigint AS input_tokens,
SUM(tu.output_tokens)::bigint AS output_tokens,
SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens,
SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens,
COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS cost_usd_ticks,
COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
COUNT(DISTINCT tu.task_id)::int AS task_count
FROM task_usage tu
JOIN agent_task_queue atq ON atq.id = tu.task_id
WHERE atq.runtime_id = $1
AND tu.created_at >= $3::timestamptz
GROUP BY EXTRACT(HOUR FROM tu.created_at AT TIME ZONE $2::text), tu.model
ORDER BY hour, tu.model
`
type GetRuntimeUsageByHourParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Tz string `json:"tz"`
Since pgtype.Timestamptz `json:"since"`
}
type GetRuntimeUsageByHourRow struct {
Hour int32 `json:"hour"`
Model string `json:"model"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
CostUsdTicks int64 `json:"cost_usd_ticks"`
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
TaskCount int32 `json:"task_count"`
}
// Per-(hour, model) token aggregates (hour ∈ 0..23) for a runtime since a
// cutoff. Powers the "By hour" tab — shows when in the day this runtime is
// doing real work, with model preserved for client-side cost calculation
// (same reason as ListRuntimeUsageByAgent above). Hours with zero activity
// are omitted; the client fills the 24-bucket axis.
//
// Hours are extracted in the viewer's tz via @tz so afternoon
// work bucketed at UTC 06:00 lands in 14:00 for a UTC+8 viewer.
func (q *Queries) GetRuntimeUsageByHour(ctx context.Context, arg GetRuntimeUsageByHourParams) ([]GetRuntimeUsageByHourRow, error) {
rows, err := q.db.Query(ctx, getRuntimeUsageByHour, arg.RuntimeID, arg.Tz, arg.Since)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetRuntimeUsageByHourRow{}
for rows.Next() {
var i GetRuntimeUsageByHourRow
if err := rows.Scan(
&i.Hour,
&i.Model,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.CostUsdTicks,
&i.UncostedInputTokens,
&i.UncostedOutputTokens,
&i.UncostedCacheReadTokens,
&i.UncostedCacheWriteTokens,
&i.TaskCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRuntimeUsage = `-- name: ListRuntimeUsage :many
SELECT
DATE(bucket_hour AT TIME ZONE $2::text) AS date,
LOWER(provider) AS provider,
model,
SUM(input_tokens)::bigint AS input_tokens,
SUM(output_tokens)::bigint AS output_tokens,
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
SUM(cost_usd_ticks)::bigint AS cost_usd_ticks,
SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens,
SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens,
SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens,
SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens
FROM task_usage_hourly
WHERE runtime_id = $1
AND bucket_hour >= $3::timestamptz
GROUP BY DATE(bucket_hour AT TIME ZONE $2::text), LOWER(provider), model
ORDER BY DATE(bucket_hour AT TIME ZONE $2::text) DESC, LOWER(provider), model
`
type ListRuntimeUsageParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Tz string `json:"tz"`
Since pgtype.Timestamptz `json:"since"`
}
type ListRuntimeUsageRow struct {
Date pgtype.Date `json:"date"`
Provider string `json:"provider"`
Model string `json:"model"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
CostUsdTicks int64 `json:"cost_usd_ticks"`
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
}
// Reads from the UTC-bucketed `task_usage_hourly` rollup table,
// aggregated to per-(date, provider, model) under the
// caller-supplied @tz. Powers the trend chart on the runtime detail
// page and the per-row cost cell on the runtimes list.
//
// @tz is required, even if the caller intends "UTC", so the bucket
// cast is unambiguous — `bucket_hour` is UTC and the caller picks the
// calendar boundary per request.
//
// provider is LOWER()-normalized so mixed-case historical rows merge
// (same reason as ListRuntimeUsageByAgent below).
func (q *Queries) ListRuntimeUsage(ctx context.Context, arg ListRuntimeUsageParams) ([]ListRuntimeUsageRow, error) {
rows, err := q.db.Query(ctx, listRuntimeUsage, arg.RuntimeID, arg.Tz, arg.Since)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRuntimeUsageRow{}
for rows.Next() {
var i ListRuntimeUsageRow
if err := rows.Scan(
&i.Date,
&i.Provider,
&i.Model,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.CostUsdTicks,
&i.UncostedInputTokens,
&i.UncostedOutputTokens,
&i.UncostedCacheReadTokens,
&i.UncostedCacheWriteTokens,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRuntimeUsageByAgent = `-- name: ListRuntimeUsageByAgent :many
SELECT
atq.agent_id,
LOWER(tu.provider) AS provider,
tu.model,
SUM(tu.input_tokens)::bigint AS input_tokens,
SUM(tu.output_tokens)::bigint AS output_tokens,
SUM(tu.cache_read_tokens)::bigint AS cache_read_tokens,
SUM(tu.cache_write_tokens)::bigint AS cache_write_tokens,
COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS cost_usd_ticks,
COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
COUNT(DISTINCT tu.task_id)::int AS task_count
FROM task_usage tu
JOIN agent_task_queue atq ON atq.id = tu.task_id
WHERE atq.runtime_id = $1
AND tu.created_at >= $2::timestamptz
GROUP BY atq.agent_id, LOWER(tu.provider), tu.model
ORDER BY atq.agent_id, LOWER(tu.provider), tu.model
`
type ListRuntimeUsageByAgentParams struct {
RuntimeID pgtype.UUID `json:"runtime_id"`
Since pgtype.Timestamptz `json:"since"`
}
type ListRuntimeUsageByAgentRow struct {
AgentID pgtype.UUID `json:"agent_id"`
Provider string `json:"provider"`
Model string `json:"model"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWriteTokens int64 `json:"cache_write_tokens"`
CostUsdTicks int64 `json:"cost_usd_ticks"`
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
TaskCount int32 `json:"task_count"`
}
// Per-(agent, provider, model) token aggregates for a runtime since a cutoff. Powers
// the runtime-detail "Cost by agent" tab. task_usage only carries task_id,
// so we join the queue to expose agent_id. The model dimension is kept on
// purpose: cost is computed client-side from a per-model pricing table, so
// collapsing models server-side would erase the information needed to do
// that arithmetic. The client groups by agent_id and sums cost per agent.
//
// This view doesn't bucket by date, so it doesn't need @tz; only the
// @since cutoff is provided in runtime-local terms (computed in Go).
// provider is LOWER()-normalized so mixed-case historical rows merge with
// new rows (see ListDashboardUsageDaily in task_usage.sql).
func (q *Queries) ListRuntimeUsageByAgent(ctx context.Context, arg ListRuntimeUsageByAgentParams) ([]ListRuntimeUsageByAgentRow, error) {
rows, err := q.db.Query(ctx, listRuntimeUsageByAgent, arg.RuntimeID, arg.Since)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRuntimeUsageByAgentRow{}
for rows.Next() {
var i ListRuntimeUsageByAgentRow
if err := rows.Scan(
&i.AgentID,
&i.Provider,
&i.Model,
&i.InputTokens,
&i.OutputTokens,
&i.CacheReadTokens,
&i.CacheWriteTokens,
&i.CostUsdTicks,
&i.UncostedInputTokens,
&i.UncostedOutputTokens,
&i.UncostedCacheReadTokens,
&i.UncostedCacheWriteTokens,
&i.TaskCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}