Skip to content

Commit 41ea115

Browse files
Merge pull request #139 from CodeGraphContext/feat/tenant-cost
feat(observe): attribute runs and costs to explicit tenants
2 parents 842a405 + 8023c28 commit 41ea115

20 files changed

Lines changed: 759 additions & 46 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ print(g.compile().invoke({"question": "meaning of life"}))
7070

7171
Typed state, declared writes, a budget — none optional. The rest of the surface (agents, tools, memory, sessions, policy documents, cost attribution, OTel) is in the [deep dive](docs/deep-dive.md) and the [cookbook](docs/cookbook/).
7272

73+
Cost attribution also supports explicit tenants: pass `tenant="team-a"` to a compiled graph or governed loop, then read its bill with `attribute_tenant(trace, "team-a", rates=...)` or `grapharc cost trace.jsonl --tenant team-a --json`. `run`, `plan`, `go` and policy-governed `agent` runs carry an explicit `--tenant`, `GRAPHARC_TENANT` or config value into the trace. Recorded USD and rate-card estimates stay separate; old unlabelled runs remain unassigned. See [the cost guide](docs/cookbook/06-serving-and-ops.md#where-did-the-tokens-go) for the boundaries.
74+
7375
## The admission gate
7476

7577
You cannot pre-author a graph for "investigate this incident" — the shape is discovered while working. So the graph is proposed at runtime, and a deterministic checker stands between proposing and running: registry, policy, remaining budget, depth, acyclicity, all on every proposal. A rejection is structured feedback the planner replans against; work discovered mid-run re-enters the same gate. Watch it refuse (free, scripted, the first proposal names a policy-denied kind):

‎ROADMAP.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ allowances; the agent records document-driven denials and approval decisions.
451451
importing the module needs no OTel. Previously documented as unverified
452452
against the real SDK — verified during this pass against
453453
`opentelemetry-sdk` 1.44.0, with spans reaching an `InMemorySpanExporter`.
454-
- [~] **10.4 — Cost attribution** per run, thread (session) and node. The
454+
- [x] **10.4 — Cost attribution** per run, thread (session), node and explicit tenant. The
455455
price is now recorded, not guessed: every gateway publishes a
456456
`cost_usd` through the same `llm_output` envelope, the runtime's usage
457457
callback accumulates it per node and writes it onto the `end` event, and
@@ -461,9 +461,16 @@ allowances; the agent records document-driven denials and approval decisions.
461461
figure wins outright rather than being averaged with a guess. Tokens are
462462
counted from the same events `metrics.summarize` uses — node `end` events
463463
*plus* work outside any node span, which is what a `grapharc agent` run
464-
consists of entirely — and the suite asserts the two agree. **One gap
465-
left:** there is no tenant on a trace event, so tenant attribution is not
466-
offered rather than being approximated.
464+
consists of entirely — and the suite asserts the two agree. An optional
465+
tenant on `RunContext` labels every native graph, planner and agent event;
466+
the loop carries it through round sub-runs. `attribute_tenant` and
467+
`grapharc cost --tenant NAME` select whole labelled runs, refuse conflicting
468+
labels, and leave old unlabelled runs unassigned. Planning tokens without
469+
a recorded price are estimated or marked unpriced, including in tenant
470+
totals. **Producer limit:** planner turns and failed node terminal events
471+
still omit the provider price ([#138](https://github.com/CodeGraphContext/GraphARC/issues/138));
472+
a rate-card estimate is not a recorded bill.
473+
Tenant labels provide attribution, not authorization.
467474
- [ ] **10.2 — Rollback** and versioned graph/prompt configs.
468475
- [ ] **10.5 — Alerting** on budget, failure, and verifier-drift.
469476

‎docs/cookbook/06-serving-and-ops.md‎

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,7 +1570,9 @@ from pathlib import Path
15701570

15711571
from langchain_core.messages import HumanMessage
15721572

1573-
from grapharc.observe import RateCard, TraceRecorder, attribute, summarize, to_mermaid
1573+
from grapharc.observe import (
1574+
RateCard, TraceRecorder, attribute, attribute_tenant, summarize, to_mermaid,
1575+
)
15741576
from grapharc.runtime.graph import END, START, GraphARC
15751577
from grapharc.runtime.state import GraphARCState
15761578
from grapharc.testing import ScriptedChatModel
@@ -1612,6 +1614,12 @@ metrics = summarize(trace, "r1")
16121614
print("metrics :", metrics.nodes_executed, "nodes,", metrics.tokens, "tokens,", metrics.per_node)
16131615
print()
16141616
print(to_mermaid(trace, "r1"))
1617+
1618+
# A second run has an explicit tenant; the old unlabelled run stays excluded.
1619+
g.compile().invoke({"question": "budgets?"}, run_id="r2", tenant="team-a")
1620+
bill = attribute_tenant(trace, "team-a", rates=RateCard(default=3.0))
1621+
assert [run.run_id for run in bill.runs] == ["r2"]
1622+
assert bill.tokens == attribute(trace, "r2").tokens
16151623
```
16161624

16171625
```
@@ -1634,24 +1642,25 @@ flowchart TD
16341642
```
16351643

16361644
`RunCost.tokens` and `RunMetrics.tokens` agree by construction — both count the
1637-
`end` events of node executions, and the test suite asserts they match, because
1645+
`end` and `error` events plus work outside node spans, and the suite asserts they match, because
16381646
a cost report and an audit trail that disagree are worse than either alone.
16391647

16401648
**Three honest limits:**
16411649

1642-
- **Nothing in GraphARC writes `cost_usd` onto a trace event today.** The field
1643-
exists and `TraceRecorder.event` accepts it, but the kernel does not pass it,
1644-
so `recorded_cost_usd` is always `None` on a trace from today's runtime.
1645-
Everything you see above is `estimated_cost_usd` — tokens × your rate card,
1646-
reported in its own field so nobody mistakes an estimate for an invoice.
1650+
- **Recorded cost depends on the producer.** The kernel writes provider prices
1651+
onto successful node terminal events, and agents record their per-call prices.
1652+
Planner turns and failed node terminal events currently omit the provider
1653+
price. The example above uses a backend that reports no price, so its money
1654+
is `estimated_cost_usd` — tokens × your rate card. Recorded and estimated
1655+
figures remain separate; a recorded figure takes precedence over an estimate.
16471656
- **`complete` is what tells you the total is a total.** Tokens with neither a
16481657
recorded cost nor a matching rate are counted in `unpriced_tokens`, and a
16491658
non-zero count means `cost_usd` is a lower bound. A `RateCard` with no
16501659
`default` and no matching model prices nothing.
1651-
- **A node that raised has no token count at node level.** No `end` event was
1652-
written. Where it was an `AgentNode`, its per-call sub-events still hold the
1653-
spend, reported as `tokens_before_error` — kept out of the total so the total
1654-
keeps matching `metrics`.
1660+
- **Older error events may have no token count.** Current node wrappers record
1661+
tokens on both `end` and `error`. When an older or hand-built trace omits the
1662+
failed node's count, an agent's per-call sub-events may still hold the spend,
1663+
reported as `tokens_before_error` and kept out of the total that matches `metrics`.
16551664

16561665
`to_mermaid` renders the graph's *declared topology* — the `topology` event every
16571666
run now writes — with execution status overlaid per node: `done`, `running`,
@@ -1666,11 +1675,54 @@ renderer that speaks Mermaid.
16661675
`attribute_thread(trace, thread_id)` is the same for a whole session across
16671676
resumes, and `by_node(trace)` ranks every node in a file by cost.
16681677

1678+
For tenant attribution, supply one label at the run entry point, as the example
1679+
above does with `tenant="team-a"`. `attribute_tenant` selects its second run,
1680+
`r2`, while the first unlabelled run stays excluded.
1681+
1682+
Every compiled execution entry (`invoke`, `stream`, `ainvoke`, `astream`,
1683+
`astream_events`) accepts `tenant=`, as does `GovernedLoop.run`. The loop carries
1684+
it through planning, admission, approval, round execution and stop events;
1685+
`AgentNode` inherits its supplied `RunContext.tenant`. Custom producers must
1686+
label their own events consistently. Attribution is a caller-supplied label,
1687+
not an authorization check; it does not apply a policy on its own.
1688+
1689+
`attribute_tenant(trace, name, rates=...)` reads one snapshot and returns
1690+
`TenantCost`: the matching runs, tokens, duration, recorded USD, estimated USD,
1691+
combined known USD, unpriced tokens and `complete`. It prices the same whole
1692+
runs as `attribute`, so model substeps do not add a second copy of node spend.
1693+
An unknown tenant raises `ReplayError`. Any run with inconsistent labels,
1694+
including a mixture of labelled and unlabelled events, makes the reader refuse
1695+
the report rather than guess a bill. Legacy runs with no tenant remain
1696+
unassigned, and writing an unlabelled run adds no tenant key.
1697+
1698+
The CLI uses the same reader:
1699+
1700+
```bash
1701+
grapharc cost trace.jsonl --tenant team-a --json
1702+
grapharc cost trace.jsonl --run-id r2
1703+
```
1704+
1705+
Choose exactly one scope. JSON includes separate `recorded_cost_usd` and
1706+
`estimated_cost_usd`, `unpriced_tokens`, `complete`, and per-run detail for a
1707+
tenant report. The CLI supplies no rate card, so tokens without recorded prices
1708+
remain unpriced; use the Python API for estimates. Missing or unreadable files
1709+
exit 2, and missing selections or inconsistent labels exit 1, including in JSON.
1710+
1711+
`run`, `plan`, `go` and policy-governed `agent` runs carry a tenant supplied by
1712+
`--tenant`, `GRAPHARC_TENANT`
1713+
or `grapharc.toml` into their traces. The policy layer's implicit `"default"`
1714+
does not label a trace; an explicit `--tenant default` does. Executing a saved
1715+
plan uses the current invocation's explicit tenant, rather than inheriting a
1716+
label from the planning run. The standalone `grapharc agent` command
1717+
currently requires `--policy` when selecting a tenant; its trace uses the explicit
1718+
selection, while the document's implicit default tenant stays unlabelled.
1719+
1720+
16691721
---
16701722

16711723
## The CLI tour
16721724

1673-
Twelve commands. Every one takes `--json`, which prints the same payload as one
1725+
The commands below all take `--json`, which prints the same payload as one
16741726
document on stdout — including failures, which become the document rather than a
16751727
line on stderr.
16761728

@@ -1687,6 +1739,7 @@ line on stderr.
16871739
| `grapharc diff <trace> <a> <b>` | compare two runs in one trace |
16881740
| `grapharc trace <trace>` | pretty-print a trace file |
16891741
| `grapharc metrics <trace> <run>` | summarize one run |
1742+
| `grapharc cost <trace> --tenant NAME` | attribute whole labelled runs; alternatively select `--run-id ID` |
16901743
| `grapharc viz <trace> <run>` | render the executed path as Mermaid |
16911744

16921745
`run --check-only` prints a `topology` digest for comparing the same graph

0 commit comments

Comments
 (0)