Skip to content

feat(agent-finops): persist model usage ledger and expose cost queries #266

Closed

Description

@mashkovd

Parent roadmap

  • mctlhq/.github#48

Depends on

  • mctlhq/.github#49 — canonical ModelUsageRecord / pricing contract.
  • mctlhq/.github#50 — first DevLoop producer instrumentation for the pilot.

Goal

Provide an mctl-owned durable usage/cost ledger and query surface so token/cost history survives trace-backend retention changes and can answer operator questions such as:

  • how much did this DevLoop cost?
  • how much did investigator vs implementer vs shepherd spend?
  • what did issue/PR/repository X spend in a time window?
  • which provider/model accounted for the spend?
  • what fraction of usage came from cache/reasoning tokens?

The ledger is a financial/usage read model, not a replacement for traces or provider invoices.

Scope

Durable append-oriented store

Persist normalized records conforming to #49 with deterministic idempotency/dedupe.

Requirements:

  • append-oriented/auditable history;
  • unique invocation/dedupe key;
  • trace/execution/workflow correlation;
  • no prompt/completion body required;
  • retention longer than or independent from trace-backend retention;
  • schema/version migrations preserve historical pricing semantics.

Versioned pricing catalog

Persist or load a versioned mctl pricing catalog with effective dates and provider/model matching rules.

Calculated cost must record the exact pricing version/rates used so historical values remain reproducible after price changes.

Do not overwrite provider-reported cost with calculated cost.

Ingestion contract

Define an authenticated/idempotent ingestion path for usage events emitted by mctl-agents / other trusted producers.

The first implementation may be REST/internal API, queue, or another existing mctl pattern selected during implementation. Do not couple ingestion to a specific tracing backend.

Query surface

Expose read operations sufficient for:

by execution/workflow/work_item
by repository / issue / PR
by agent / DevLoop stage
by provider / model
by time window
by terminal outcome

Return both token breakdown and cost classification (provider_reported, calculated, future invoice_reconciled) without implying estimates are invoice truth.

Reconciliation boundary

Define a future-safe hook for provider billing/export reconciliation, but do not implement full accounting integration in this issue unless trivial.

Acceptance criteria

  • Usage records can be ingested idempotently from the DevLoop pilot.
  • Duplicate delivery/retry cannot double-count a paid model invocation.
  • Historical usage remains queryable after trace data is deleted/replaced.
  • Pricing versions/effective rates make calculated historical cost reproducible.
  • Provider-reported and calculated cost are returned as separate fields/types.
  • Queries can aggregate by issue/PR/repo/execution/agent-stage/model/provider/time window.
  • A query for one pilot DevLoop returns the same stage/total usage as its producer records.
  • No prompt/completion payload is required or stored for FinOps accounting.
  • Unit/integration tests cover idempotency, price changes, unknown model pricing and partial/missing provider usage fields.

Non-goals

  • Final UI/dashboard design.
  • Hard execution-budget enforcement.
  • Provider invoice replacement.
  • Storing arbitrary tracing payloads.
  • Choosing Traceway/Langfuse/Tempo/Phoenix as a dependency of the ledger.

Activity

  1. mashkovd commented on Sep 22, 2026

    @mashkovd
    MemberAuthor

    Dependency note: the #50 line here is inverted

    This issue's Depends on section lists mctlhq/.github#50. The canonical
    graph says the opposite, and the graph is right.

    roadmap/epics/agent-finops.yaml:

    - id: usage-ledger            # this issue, mctl-api#266
      dependsOn: [usage-contract]
    
    - id: devloop-instrumentation # .github#50
      dependsOn: [usage-ledger]

    So #50 depends on this issue, not the reverse. Taking the prose literally
    would make the two mutually blocking and neither could ever start.

    It also reads correctly as an ordering: #50 instruments one DevLoop end-to-end
    to produce records, and there is nowhere to put them until this ledger exists.
    A producer before its store is the wrong way round.

    This issue's only real dependency is usage-contract (.github#49), which is
    now complete
    — delivered as ADR-012
    (docs/adr/012-model-usage-cost-attribution-contract.md in mctl-agents,
    merged ce50386). usage-ledger is therefore ready in the live ready-set.

    What ADR-012 already settles, so it is not re-litigated here

    • Record grain is (run, model), not run. ResultMessage.model_usage is keyed
      by model and one run legitimately spans a main loop plus a cheap-profile
      sub-agent, so a per-run row merges them irrecoverably.
    • Dedupe key: sha256(session_id | result_uuid | model_key) with an
      idempotent upsert, with the explicit carve-out that a retry which re-runs the
      model
      is a new invocation with a real new charge and must not be deduped.
    • provider_reported_cost is always null today under the subscription
      decision; the SDK's costUSD is a price-table calculation and belongs in
      calculated_cost with a pricing_version.
    • Absent ≠ zero for every usage field, so "nothing spent" stays
      distinguishable from "nothing measured".
    • Storage boundary: this repo, as a sibling table to agent_executions,
      joined on temporal_workflow_id — the spine record_execution already writes.

    One thing the implementer should know before starting

    There is no instrumentation to read from yet:

    $ grep -rn "total_cost_usd\|model_usage" --include="*.py" orchestrator/   # mctl-agents
    (no matches)
    

    Six paths spend model quota and none captures usage; three of them never touch
    ResultMessage at all. This issue can build the store, the pricing catalog and
    the query surface against the contract, but its end-to-end proof needs #50.
    That is the correct order — store first, producer second — and is why the
    inverted prose above matters.

  2. mashkovd commented on Sep 22, 2026

    @mashkovd
    MemberAuthor

    Closed by c963963 (PR #344). Acceptance mapping against the merged code, since a closing keyword is not evidence.

    Criterion Status Evidence on main
    Records ingested idempotently from the DevLoop pilot Partial — see below TestIngestIsIdempotent, TestUsageHandlers_IngestIsIdempotentOverHTTP
    Duplicate delivery/retry cannot double-count a paid invocation Delivered PRIMARY KEY (id) + ON CONFLICT DO NOTHING; TestSuppliedIDCannotBypassTheDedupeKey, TestRetryThatRerunsTheModelIsNotDeduped
    Historical usage queryable after trace data is deleted Delivered ledger is mctl-owned storage keyed on correlation ids, not a trace lookup; TestRecordsRemainQueryableByCorrelationWithoutTraceData
    Pricing versions make historical cost reproducible Delivered cost derived once at ingest and stored with its card version; TestStoredCostSurvivesAPriceChange, TestCatalogResolvesByInstantNotLatest
    Provider-reported and calculated cost returned as separate fields Delivered separate columns and separate summary fields, never summed; TestCalculateDoesNotOverwriteProviderReportedCost
    Aggregate by issue/PR/repo/execution/agent-stage/model/provider/time window Delivered closed GroupBy set + Filter; TestSummaryAggregatesOverDimensions, TestSummaryByModelUsesTheModelKeyFallback

    The one criterion not fully met

    "Ingested idempotently from the DevLoop pilot" is satisfied as the ingestion path is proven idempotent against pilot-shaped records — not as a live pilot emitting them. No producer exists yet: grep -rn "total_cost_usd\|model_usage" orchestrator/ in mctl-agents still returns nothing.

    That is not a gap I left; it is a consequence of the dependency direction. The canonical graph has .github#50 (devloop-instrumentation) depending on this item, not the reverse, so a live pilot could not exist before the ledger it writes to. The issue prose states the dependency backwards — noted in an earlier comment on this issue. Closing on that reading rather than silently treating the criterion as met.

    What a reader should know before building on this

    • id is derived server-side and never trusted from the request. A supplied id that disagrees is a 400. A producer that could pick its own id would opt out of the guarantee entirely.
    • Absent is not zero. Every token column is nullable, every counter is *int64, and summary buckets stay nullable. SUM() ignores "not measured" and counts "reported zero". Do not make these NOT NULL DEFAULT 0.
    • Rates live in a file (USAGE_PRICING_CATALOG), not in the binary. With no catalog the ledger records every token count and derives no cost — deliberately, since inventing rates produces plausible-looking wrong money.
    • A nil store is 503, never an empty 200. Zero spend read from an unconfigured ledger is the opposite of the truth.

    Review: five rounds — 5 P2 + 9 P3 from claude and agy, all verified against the code before fixing. Final two rounds were 0 P1 / 0 P2 with agy PASS. Residual P3s tracked separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions