Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Sessions say what they cost, estimated at list price from the usage they report

Home and the Workspace overview show agent spend only for g1t's own metered runs; harness sessions said nothing of what they cost. update_session now takes a session's token usage per model (cumulative, so each report replaces the last), checks it, and keeps it in sessions.usage with sessions.cost_usd priced from Anthropic's list prices (g1t_contracts::model_prices, checked 2026-10-06). A model g1t has no price for is left out and the session says its cost is partial; with none priced the cost is null. Only the workspace's members see the cost, as with the rest of a session's inside. Part of #157

G1t-Session:
ses_01m573jm0ze1q9tram0b4dcva1
syntaqxcommitted Parent0ba9169Browse files
16 files+521−90/16 viewed
+36−2
134134 pub fn description(self) -> &'static str {
135135 match self {
136136 CoordinationOp::StartSession => "Start a session: one run of your own harness (a Claude Code session, a Codex run) on a repository, so others can see who is working on what. `harness` is claude_code, codex, cursor, copilot or other. Give `harness_session_id`, the harness's own id for the session, and starting again with it returns the session already started (refreshing the work item, pull request, branch, title and head given) instead of a new one, so a hook that fires twice is harmless. `item` and `pull` attach it to a work item of the repository's workspace and a pull request of the repository; a pull request for a work item attaches that work item too. A subagent is a session of its own: give `parent_id` (one of your sessions here), its `agent_type`, the `task` and `prompt` its parent gave it, and as `harness_session_id` the parent's harness id, `/agent/` and the subagent's id; it starts on the parent's work item, pull request and branch unless it says otherwise, sees and is seen by its siblings in get_work and list_sessions, and once ended stays ended when started again. Returns the session (`id` `ses_…`, `state` active, idle or ended). A new session, or one started again after it ended, is a session.started event. Needs a confirmed email address and a role that may open work items.",
137− CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. Returns the session; one ended with end_session is refused with `409`.",
137+ CoordinationOp::UpdateSession => "Update one of your sessions: attach it to a work item or pull request, or change its branch, head commit or title. Only the fields given change. An empty body is a heartbeat: every call keeps the session active and renews the claims made from it. A session reads as idle after 15 minutes without one and ends (`end_reason` timed_out) after 30 minutes; a heartbeat brings a timed-out one back. Attaching a pull request moves the entries recorded so far into that pull request's session. A subagent's `task`, `prompt`, `model` and `result` (what it reported, up to 8,000 characters) can be set too; a subagent that ended takes them and stays ended. `usage` reports the session's token usage so far, per model, replacing what was reported before: g1t estimates `cost_usd` from it at Anthropic's list price (none when no model in it has a known price), with `cost_partial` true when some of it was for a model it has no price for. Returns the session; one ended with end_session is refused with `409`.",
138138 CoordinationOp::EndSession => "End one of your sessions, with an optional `reason` and `result` (what it reported when it finished: a subagent's final message, up to 8,000 characters, never a tool's output). A session.ended event. Ending one already ended returns it as it is, taking `result` if it had none. Its subagents still going end with it (`parent ended`). Its claims are not released: release them, hand the work item off, or let them expire. Anyone with write access to the repository may also end someone else's session, one left behind by a harness that was killed or never ended it: it ends as `ended by hand by @you`, without their `reason` or `result`.",
139139 CoordinationOp::GetSession => "A session and its entries, oldest first, numbered within the session (`seq`); `after` gives the entries after that number, 500 at a time. With them: `children`, its subagents and theirs in the order they started (each names its `parent_id`); `touched`, the files, commits, branches, pull requests and work items it and they touched; `links`, the work items and pull requests they worked on and how each was tied (`via`: attached, claim, branch, commit, trailer, opened or ref); `skills`, the skills they used by name; and for a subagent, `root`, the session at the top of its tree. `updates` contains up to 100 oldest pending coordination events that concern this session (id, kind, data, at): those about the work items and pull requests it is on or tied to, or every one while it is on nothing yet; never its own start, end or claims. Reading never acknowledges them; use record_session_entries with acknowledge IDs after considering them. To check for updates without the transcript, give `after` 4294967295. Anyone who can read the repository may. With `workspace` instead of `repo`, the session is found in whichever repository of the workspace it runs in, and `repo` says which.",
140140 CoordinationOp::ListSessions => "A repository's sessions, most recently heard from first, up to 100: `state` active (heard from in the last 15 minutes), idle or ended; `item` (a work item's number) or `pull` for those on one, attached or tied to it any other way; `path` for those that touched a file (or any under a folder ending in `/`). Subagents are left out unless `subagents` is true, which lists each tree whole. Each says how many `subagents` it started. Anyone who can read the repository may. With `workspace` instead of `repo`, every repository of the workspace you may read: see list_workspace_sessions.",
206206 ),
207207 &["repo", "harness"],
208208 ),
209− CoordinationOp::UpdateSession => (with(fields(json!({ "id": id, "result": result })), subagent.clone()), &["repo", "id"]),
209+ CoordinationOp::UpdateSession => (
210+ with(
211+ fields(json!({
212+ "id": id,
213+ "result": result,
214+ "usage": {
215+ "type": "array",
216+ "description": "The session's token usage so far, one entry per model: `model`, `tier` (`long` for a claude-haiku-5-5 prompt over 100,000 tokens), `input_tokens`, `output_tokens`, `cache_write_5m_tokens`, `cache_write_1h_tokens` and `cache_read_tokens`, each a whole number. Cumulative: it replaces the usage given before. Up to 50 models. g1t estimates the session's `cost_usd` from it at Anthropic's list price.",
217+ "items": {
218+ "type": "object",
219+ "properties": {
220+ "model": { "type": "string" },
221+ "tier": { "type": "string" },
222+ "input_tokens": { "type": "integer" },
223+ "output_tokens": { "type": "integer" },
224+ "cache_write_5m_tokens": { "type": "integer" },
225+ "cache_write_1h_tokens": { "type": "integer" },
226+ "cache_read_tokens": { "type": "integer" },
227+ },
228+ "required": ["model"],
229+ },
230+ },
231+ })),
232+ subagent.clone(),
233+ ),
234+ &["repo", "id"],
235+ ),
210236 CoordinationOp::EndSession => (
211237 json!({
212238 "repo": repo,
471497 g1t_kit::call(work, "start_session", &args).await
472498 }
473499 CoordinationOp::UpdateSession => {
500+ let usage = match &input["usage"] {
501+ Value::Null => None,
502+ given => match g1t_contracts::model_prices::parse_usage(given) {
503+ Ok(usage) => Some(usage),
504+ Err(message) => return invalid(message),
505+ },
506+ };
474507 let args = UpdateSessionArgs {
475508 actor: actor(),
476509 repo,
485518 prompt: text(input, "prompt"),
486519 model: text(input, "model"),
487520 result: text(input, "result"),
521+ usage,
488522 };
489523 g1t_kit::call(work, "update_session", &args).await
490524 }
+0−0

Binary or large file; its contents are not shown.

+56−1
11 ---
22 title: Recording sessions
3−description: How a harness records its sessions on g1t, with the API, MCP or the CLI. Start, heartbeat, record and end a session, record subagents, work before there is a work item, and have g1t start sessions on your Anthropic account.
3+description: How a harness records its sessions on g1t, with the API, MCP or the CLI. Start, heartbeat, record and end a session, record subagents, report what a session cost, work before there is a work item, and have g1t start sessions on your Anthropic account.
44 ---
55
66 A [session](/work/sessions/) is one run of your harness on a repository:
125125 [Claude Code plugin](/guides/claude-code-plugin/#subagents) does all of
126126 this for Claude Code's subagents.
127127
128+## What a session cost
129+
130+A harness can say how many tokens a session used, and g1t estimates what
131+that cost at Anthropic's list price. The session's page and the
132+workspace's **Sessions** list show it as "≈ $4.12 at list price". It is
133+an estimate, not a bill: it is what the tokens cost at Anthropic's
134+published per-token prices, whatever your plan or discounts.
135+
136+1. **Report** the session's usage so far with `session` `update`,
137+ `g1t session heartbeat --usage-json '…'`, or
138+ [`PATCH /repos/{owner}/{name}/sessions/{id}`](/reference/api/sessions/update-session/)
139+ with `usage`: one entry per model, with its `input_tokens`,
140+ `output_tokens`, `cache_write_5m_tokens`, `cache_write_1h_tokens` and
141+ `cache_read_tokens`. Usage is cumulative: each report replaces the
142+ last, so send the whole session's each time.
143+
144+ ```json
145+ {
146+ "usage": [
147+ {
148+ "model": "claude-opus-5-5",
149+ "input_tokens": 1840,
150+ "output_tokens": 52310,
151+ "cache_write_5m_tokens": 210400,
152+ "cache_write_1h_tokens": 0,
153+ "cache_read_tokens": 4102300
154+ }
155+ ]
156+ }
157+ ```
158+
159+2. **g1t prices it** as it records it: each model's tokens at its list
160+ price, cache writes at 1.25 times the input price for 5 minutes and
161+ twice it for 1 hour. A model id is matched without its date
162+ (`-20251001`), a `[1m]` suffix or a provider prefix
163+ (`us.anthropic.`). For claude-haiku-5-5, a prompt over 100,000 tokens
164+ is priced at its long-prompt rate: give those tokens in an entry of
165+ their own with `tier` `long`.
166+3. **The session shows `cost_usd`**, the sum over the models g1t has a
167+ price for. A model it has no price for, another provider's included,
168+ is left out, and `cost_partial` says some usage was priced as unknown
169+ ("some usage priced as unknown" on the page). With no model priced,
170+ `cost_usd` is null.
171+
172+A subagent reports its own usage, to its own session: its parent's
173+transcript leaves out what the subagent used, so nothing is counted twice.
174+Only the workspace's members see what a session cost, as with the rest of
175+what is inside it ([who sees what](/work/sessions/#who-sees-what)). Up to
176+50 models a session, each count a whole number of tokens.
177+
178+The [Claude Code plugin](/guides/claude-code-plugin/#what-a-session-cost)
179+counts it from the session's transcript and sends only the counts. The
180+Codex plugin does not report usage yet: it records sessions through g1t's
181+MCP tools, with no hooks to read Codex's session files from.
182+
128183 ## Start a session on your Anthropic account
129184
130185 g1t runs no model. Work on a work item happens in your own harness, with
+1−1
430430 | Action | What it does | Required | Scope |
431431 | --- | --- | --- | --- |
432432 | [`start`](/reference/api/sessions/start-session/) | Start a session: `harness` (`claude_code`, `claude_managed_agents`, `codex`, `cursor`, `copilot` or `other`), and `harness_session_id`, which makes starting again return the same session. `item` (a work item of the repository's workspace), `pull`, `branch`, `title` and `head_commit` as you know them. A subagent gives `parent_id`, `agent_type`, `task` and `prompt`, and starts on its parent's work item and branch. | `repo`, `harness` | `pull_requests:write` |
433−| [`update`](/reference/api/sessions/update-session/) | A heartbeat, and renews the session's claims; with fields, attaches an `item` or `pull`, or sets `branch`, `head_commit`, `title`, or a subagent's `task`, `prompt`, `model` or `result`. | `repo`, `id` | `pull_requests:write` |
433+| [`update`](/reference/api/sessions/update-session/) | A heartbeat, and renews the session's claims; with fields, attaches an `item` or `pull`, or sets `branch`, `head_commit`, `title`, or a subagent's `task`, `prompt`, `model` or `result`; `usage` reports its token usage so far, per model, from which g1t estimates `cost_usd`. | `repo`, `id` | `pull_requests:write` |
434434 | [`record`](/reference/api/sessions/record-session-entries/) | Record up to 200 `entries`, each with `kind` and `text` (`skill` for a skill used, by name), `tool` for tools, and a `key` that makes recording it again a no-op; and `touched`, what it touched (`file`, `commit`, `branch`, `pull`, `item`), which ties it to pull requests and work items and tells sessions on the same files of each other. | `repo`, `id` | `pull_requests:write` |
435435 | [`end`](/reference/api/sessions/end-session/) | End it, with an optional `reason` and `result` (what it reported). Its subagents still going end with it. | `repo`, `id` | `pull_requests:write` |
436436 | [`get`](/reference/api/sessions/get-session/) | A session and its entries (`after` pages through them), its subagents (`children`), what they touched, the work items and pull requests they worked on (`links`) and the skills used. With [`workspace`](/reference/api/sessions/get-workspace-session/) and `id` instead of `repo`, a session of any of the workspace's repositories, with its `repo`. | `repo` or `workspace`, `id` | `pull_requests:read` |
+2−2
223223 | Who | Sees |
224224 | --- | --- |
225225 | Whoever ran it | All of it: its prompts, tool calls, what it touched and reported, its pending updates. |
226−| A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. |
227−| Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the work items and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch or model. On a pull request's session, only g1t's own notes. |
226+| A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, what it cost ([estimated at list price](/guides/sessions/#what-a-session-cost)), its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. |
227+| Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the work items and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch, model or cost. On a pull request's session, only g1t's own notes. |
228228
229229 g1t's own agent's sessions are listed only for members.
230230
+0−0

Binary or large file; its contents are not shown.

+1−0
3333 pub mod live;
3434 pub mod integrations;
3535 pub mod members;
36+pub mod model_prices;
3637 mod ids;
3738 mod names;
3839 mod outcome;
+316−0
1+//! What a model's tokens cost at list price, for estimating what a
2+//! harness session cost from the token usage it reports.
3+//!
4+//! The prices are Anthropic's first-party list prices in US dollars per
5+//! million tokens, from the pricing page of Anthropic's API documentation,
6+//! checked on 2026-10-06. Writing to the prompt cache costs 1.25 times the input price
7+//! for the 5-minute lifetime and twice the input price for the 1-hour one,
8+//! on every model. Any other model, another provider's included, is priced
9+//! as unknown: its cost is not estimated.
10+//!
11+//! A session reports its usage as a list of [`ModelUsage`], one per model
12+//! (and tier) it used, cumulative for the session. [`parse_usage`] reads
13+//! and checks one as given; [`estimate`] prices it.
14+
15+use serde::{Deserialize, Serialize};
16+use serde_json::Value;
17+
18+/// A model's list prices, in US dollars per million tokens.
19+#[derive(Clone, Copy, Debug, PartialEq)]
20+pub struct ModelPrice {
21+ pub input: f64,
22+ pub output: f64,
23+ /// Reading from the prompt cache.
24+ pub cache_read: f64,
25+}
26+
27+impl ModelPrice {
28+ /// Writing to the prompt cache for 5 minutes: 1.25 times input.
29+ pub fn cache_write_5m(&self) -> f64 {
30+ self.input * 1.25
31+ }
32+
33+ /// Writing to the prompt cache for 1 hour: twice input.
34+ pub fn cache_write_1h(&self) -> f64 {
35+ self.input * 2.0
36+ }
37+}
38+
39+const fn price(input: f64, output: f64, cache_read: f64) -> ModelPrice {
40+ ModelPrice { input, output, cache_read }
41+}
42+
43+/// The tier of claude-haiku-5-5 for a request whose prompt (input, cache
44+/// read and cache write tokens) is over [`LONG_PROMPT_TOKENS`].
45+pub const LONG_TIER: &str = "long";
46+
47+/// The prompt size above which claude-haiku-5-5 is priced as [`LONG_TIER`].
48+pub const LONG_PROMPT_TOKENS: u64 = 100_000;
49+
50+/// Every model priced, by its id: from Anthropic's pricing documentation,
51+/// checked on 2026-10-06. claude-haiku-5-5 has a second row for its
52+/// [`LONG_TIER`].
53+pub const MODEL_PRICES: &[(&str, Option<&str>, ModelPrice)] = &[
54+ ("claude-fable-5-1", None, price(10.0, 50.0, 0.25)),
55+ ("claude-mythos-5-1", None, price(10.0, 50.0, 0.25)),
56+ ("claude-fable-5", None, price(10.0, 50.0, 1.00)),
57+ ("claude-mythos-5", None, price(10.0, 50.0, 1.00)),
58+ ("claude-opus-5-5", None, price(4.0, 20.0, 0.20)),
59+ ("claude-opus-5", None, price(5.0, 25.0, 0.50)),
60+ ("claude-opus-4-8", None, price(5.0, 25.0, 0.50)),
61+ ("claude-opus-4-7", None, price(5.0, 25.0, 0.50)),
62+ ("claude-opus-4-6", None, price(5.0, 25.0, 0.50)),
63+ ("claude-sonnet-5-5", None, price(2.0, 10.0, 0.20)),
64+ ("claude-sonnet-5", None, price(2.0, 10.0, 0.20)),
65+ ("claude-sonnet-4-6", None, price(3.0, 15.0, 0.30)),
66+ ("claude-haiku-5-5", None, price(0.10, 0.50, 0.01)),
67+ ("claude-haiku-5-5", Some(LONG_TIER), price(0.50, 2.50, 0.05)),
68+ ("claude-haiku-4-5", None, price(1.0, 5.0, 0.10)),
69+];
70+
71+/// A model id as the price table names it: lowercase, without a provider
72+/// prefix (`anthropic.`, `us.anthropic.`), a context suffix (`[1m]`), a
73+/// version suffix (`-v1:0`) or a date (`-20251001`).
74+pub fn model_key(model: &str) -> String {
75+ let mut key = model.trim().to_ascii_lowercase();
76+ if let Some(at) = key.find("anthropic.") {
77+ key = key[at + "anthropic.".len()..].to_owned();
78+ }
79+ if let Some(at) = key.find('[') {
80+ key.truncate(at);
81+ }
82+ if let Some(at) = key.find(':') {
83+ key.truncate(at);
84+ }
85+ loop {
86+ let Some((head, tail)) = key.rsplit_once('-') else { break };
87+ let date = tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit());
88+ let version = tail.len() >= 2 && tail.starts_with('v') && tail[1..].bytes().all(|b| b.is_ascii_digit());
89+ if !(date || version) {
90+ break;
91+ }
92+ key = head.to_owned();
93+ }
94+ key
95+}
96+
97+/// A model's list prices for `tier`, or none for a model not in the table.
98+/// A tier the model has no row for is priced as its base row.
99+pub fn model_price(model: &str, tier: Option<&str>) -> Option<ModelPrice> {
100+ let key = model_key(model);
101+ let rows = || MODEL_PRICES.iter().filter(|(id, _, _)| *id == key);
102+ rows()
103+ .find(|(_, row_tier, _)| tier.is_some() && *row_tier == tier)
104+ .or_else(|| rows().find(|(_, row_tier, _)| row_tier.is_none()))
105+ .map(|(_, _, price)| *price)
106+}
107+
108+/// What one model (and tier) of a session used, in tokens, as its harness
109+/// reported it.
110+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
111+pub struct ModelUsage {
112+ pub model: String,
113+ /// `long` for a claude-haiku-5-5 prompt over 100,000 tokens; none otherwise.
114+ #[serde(default, skip_serializing_if = "Option::is_none")]
115+ pub tier: Option<String>,
116+ #[serde(default)]
117+ pub input_tokens: u64,
118+ #[serde(default)]
119+ pub output_tokens: u64,
120+ #[serde(default)]
121+ pub cache_write_5m_tokens: u64,
122+ #[serde(default)]
123+ pub cache_write_1h_tokens: u64,
124+ #[serde(default)]
125+ pub cache_read_tokens: u64,
126+}
127+
128+impl ModelUsage {
129+ /// What it cost at list price in US dollars, or none for a model not
130+ /// in the table.
131+ pub fn cost_usd(&self) -> Option<f64> {
132+ let price = model_price(&self.model, self.tier.as_deref())?;
133+ let tokens = |count: u64, per_million: f64| count as f64 * per_million;
134+ Some(
135+ (tokens(self.input_tokens, price.input)
136+ + tokens(self.output_tokens, price.output)
137+ + tokens(self.cache_write_5m_tokens, price.cache_write_5m())
138+ + tokens(self.cache_write_1h_tokens, price.cache_write_1h())
139+ + tokens(self.cache_read_tokens, price.cache_read))
140+ / 1_000_000.0,
141+ )
142+ }
143+
144+ fn tokens(&self) -> u64 {
145+ self.input_tokens
146+ .saturating_add(self.output_tokens)
147+ .saturating_add(self.cache_write_5m_tokens)
148+ .saturating_add(self.cache_write_1h_tokens)
149+ .saturating_add(self.cache_read_tokens)
150+ }
151+}
152+
153+/// A session's usage priced: the sum over the models priced (none when no
154+/// model is), and whether some of it was for models priced as unknown.
155+#[derive(Clone, Copy, Debug, Default, PartialEq)]
156+pub struct CostEstimate {
157+ pub cost_usd: Option<f64>,
158+ pub partial: bool,
159+}
160+
161+/// Prices a session's usage. A model with no tokens counts for nothing.
162+pub fn estimate(usage: &[ModelUsage]) -> CostEstimate {
163+ let mut out = CostEstimate::default();
164+ for one in usage.iter().filter(|one| one.tokens() > 0) {
165+ match one.cost_usd() {
166+ Some(cost) => out.cost_usd = Some(out.cost_usd.unwrap_or(0.0) + cost),
167+ None => out.partial = true,
168+ }
169+ }
170+ out
171+}
172+
173+/// The most models one session's usage names.
174+pub const MAX_USAGE_MODELS: usize = 50;
175+/// The longest model id and tier kept.
176+pub const MAX_USAGE_MODEL_CHARS: usize = 100;
177+pub const MAX_USAGE_TIER_CHARS: usize = 20;
178+/// The most tokens one count may be: far beyond any session.
179+pub const MAX_USAGE_TOKENS: u64 = 1_000_000_000_000;
180+
181+const COUNTS: [&str; 5] = ["input_tokens", "output_tokens", "cache_write_5m_tokens", "cache_write_1h_tokens", "cache_read_tokens"];
182+
183+/// A session's usage as given (a list of `{ model, tier?, input_tokens,
184+/// output_tokens, cache_write_5m_tokens, cache_write_1h_tokens,
185+/// cache_read_tokens }`), checked: at most [`MAX_USAGE_MODELS`] models,
186+/// each named, every count a whole number from 0 to [`MAX_USAGE_TOKENS`].
187+/// The same model and tier given twice are added together.
188+pub fn parse_usage(value: &Value) -> Result<Vec<ModelUsage>, String> {
189+ let Value::Array(rows) = value else {
190+ return Err("usage is a list of { model, tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens }.".to_owned());
191+ };
192+ if rows.len() > MAX_USAGE_MODELS {
193+ return Err(format!("usage names at most {MAX_USAGE_MODELS} models."));
194+ }
195+ let mut out: Vec<ModelUsage> = Vec::new();
196+ for row in rows {
197+ let Value::Object(fields) = row else {
198+ return Err("Each usage is an object with model and its token counts.".to_owned());
199+ };
200+ let model = fields.get("model").and_then(Value::as_str).map(str::trim).unwrap_or_default();
201+ if model.is_empty() || model.chars().count() > MAX_USAGE_MODEL_CHARS {
202+ return Err(format!("Each usage names its model, in up to {MAX_USAGE_MODEL_CHARS} characters."));
203+ }
204+ let tier = match fields.get("tier") {
205+ None | Some(Value::Null) => None,
206+ Some(Value::String(tier)) if tier.trim().is_empty() => None,
207+ Some(Value::String(tier)) if tier.trim().chars().count() <= MAX_USAGE_TIER_CHARS => Some(tier.trim().to_owned()),
208+ Some(_) => return Err(format!("A usage's tier is text, up to {MAX_USAGE_TIER_CHARS} characters.")),
209+ };
210+ let mut counts = [0u64; 5];
211+ for (count, name) in counts.iter_mut().zip(COUNTS) {
212+ *count = match fields.get(name) {
213+ None | Some(Value::Null) => 0,
214+ Some(value) => match value.as_u64() {
215+ Some(n) if n <= MAX_USAGE_TOKENS => n,
216+ _ => return Err(format!("{name} is a whole number of tokens from 0 to {MAX_USAGE_TOKENS}, not {value}.")),
217+ },
218+ };
219+ }
220+ let [input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens] = counts;
221+ let one = ModelUsage { model: model.to_owned(), tier, input_tokens, output_tokens, cache_write_5m_tokens, cache_write_1h_tokens, cache_read_tokens };
222+ match out.iter_mut().find(|known| known.model == one.model && known.tier == one.tier) {
223+ Some(known) => {
224+ known.input_tokens = known.input_tokens.saturating_add(one.input_tokens);
225+ known.output_tokens = known.output_tokens.saturating_add(one.output_tokens);
226+ known.cache_write_5m_tokens = known.cache_write_5m_tokens.saturating_add(one.cache_write_5m_tokens);
227+ known.cache_write_1h_tokens = known.cache_write_1h_tokens.saturating_add(one.cache_write_1h_tokens);
228+ known.cache_read_tokens = known.cache_read_tokens.saturating_add(one.cache_read_tokens);
229+ }
230+ None => out.push(one),
231+ }
232+ }
233+ Ok(out)
234+}
235+
236+#[cfg(test)]
237+mod tests {
238+ use super::*;
239+ use serde_json::json;
240+
241+ fn close(a: f64, b: f64) -> bool {
242+ (a - b).abs() < 1e-9
243+ }
244+
245+ #[test]
246+ fn the_table_pins_list_prices() {
247+ assert_eq!(model_price("claude-opus-5-5", None), Some(price(4.0, 20.0, 0.20)));
248+ assert_eq!(model_price("claude-fable-5-1", None), Some(price(10.0, 50.0, 0.25)));
249+ assert_eq!(model_price("claude-fable-5", None), Some(price(10.0, 50.0, 1.00)));
250+ assert_eq!(model_price("claude-sonnet-4-6", None), Some(price(3.0, 15.0, 0.30)));
251+ assert_eq!(model_price("claude-haiku-5-5", None), Some(price(0.10, 0.50, 0.01)));
252+ assert_eq!(model_price("claude-haiku-5-5", Some("long")), Some(price(0.50, 2.50, 0.05)));
253+ assert_eq!(model_price("claude-haiku-4-5", None), Some(price(1.0, 5.0, 0.10)));
254+ let opus = model_price("claude-opus-4-7", None).unwrap();
255+ assert!(close(opus.cache_write_5m(), 6.25) && close(opus.cache_write_1h(), 10.0));
256+ }
257+
258+ #[test]
259+ fn a_model_is_found_without_its_date_context_or_provider() {
260+ assert_eq!(model_key("claude-haiku-4-5-20251001"), "claude-haiku-4-5");
261+ assert_eq!(model_key("claude-opus-5-5[1m]"), "claude-opus-5-5");
262+ assert_eq!(model_key("us.anthropic.claude-sonnet-4-6-20260101-v1:0"), "claude-sonnet-4-6");
263+ assert_eq!(model_key("anthropic.claude-opus-4-8"), "claude-opus-4-8");
264+ assert!(model_price("Claude-Opus-5-5[1M]", None).is_some());
265+ // A tier a model has no row for is its base price.
266+ assert_eq!(model_price("claude-opus-5-5", Some("long")), model_price("claude-opus-5-5", None));
267+ }
268+
269+ #[test]
270+ fn other_models_are_priced_as_unknown() {
271+ for model in ["gpt-5.1-codex", "claude-opus-3", "o3", "<synthetic>", "claude-opus-5-5x"] {
272+ assert_eq!(model_price(model, None), None, "{model}");
273+ }
274+ }
275+
276+ #[test]
277+ fn a_sessions_cost_is_the_sum_of_the_models_priced() {
278+ let usage = vec![
279+ ModelUsage { model: "claude-opus-5-5".into(), input_tokens: 1_000_000, output_tokens: 100_000, cache_write_5m_tokens: 200_000, cache_write_1h_tokens: 100_000, cache_read_tokens: 10_000_000, ..Default::default() },
280+ ModelUsage { model: "claude-haiku-5-5".into(), tier: Some("long".into()), input_tokens: 2_000_000, ..Default::default() },
281+ ];
282+ // Opus 5.5: 4 + 2 + 1 (5m: 0.2M × 5) + 0.8 (1h: 0.1M × 8) + 2 = 9.8; Haiku 5.5 long: 2M × 0.5 = 1.
283+ let priced = estimate(&usage);
284+ assert!(close(priced.cost_usd.unwrap(), 10.8), "{priced:?}");
285+ assert!(!priced.partial);
286+ let mixed = [usage.clone(), vec![ModelUsage { model: "gpt-5".into(), input_tokens: 5, ..Default::default() }]].concat();
287+ let priced = estimate(&mixed);
288+ assert!(close(priced.cost_usd.unwrap(), 10.8) && priced.partial);
289+ let unknown = estimate(&[ModelUsage { model: "gpt-5".into(), output_tokens: 1, ..Default::default() }]);
290+ assert_eq!(unknown, CostEstimate { cost_usd: None, partial: true });
291+ assert_eq!(estimate(&[ModelUsage { model: "gpt-5".into(), ..Default::default() }]), CostEstimate::default());
292+ }
293+
294+ #[test]
295+ fn usage_is_checked_as_given() {
296+ let usage = parse_usage(&json!([
297+ { "model": "claude-opus-5-5", "input_tokens": 3, "output_tokens": 4 },
298+ { "model": "claude-opus-5-5", "input_tokens": 1, "tier": null },
299+ { "model": "claude-haiku-5-5", "tier": "long", "cache_read_tokens": 9 },
300+ ]))
301+ .unwrap();
302+ assert_eq!(usage.len(), 2);
303+ assert_eq!(usage[0].input_tokens, 4);
304+ assert_eq!(usage[1].tier.as_deref(), Some("long"));
305+ assert!(parse_usage(&json!({})).is_err());
306+ assert!(parse_usage(&json!([{ "input_tokens": 1 }])).is_err());
307+ assert!(parse_usage(&json!([{ "model": "m", "input_tokens": -1 }])).is_err());
308+ assert!(parse_usage(&json!([{ "model": "m", "input_tokens": 1.5 }])).is_err());
309+ assert!(parse_usage(&json!([{ "model": "m", "input_tokens": "7" }])).is_err());
310+ assert!(parse_usage(&json!([{ "model": "m", "output_tokens": MAX_USAGE_TOKENS + 1 }])).is_err());
311+ assert!(parse_usage(&json!([{ "model": "x".repeat(101) }])).is_err());
312+ let many: Vec<Value> = (0..51).map(|i| json!({ "model": format!("m{i}") })).collect();
313+ assert!(parse_usage(&Value::Array(many)).is_err());
314+ assert_eq!(parse_usage(&json!([])).unwrap(), vec![]);
315+ }
316+}
+13−0
214214 /// person).
215215 #[serde(default)]
216216 pub title_settled_at: Option<String>,
217+ /// What it cost, estimated at Anthropic's list price from the token
218+ /// usage its harness reported ([`crate::model_prices`]), in US dollars;
219+ /// none until it reports usage for a model with a known price.
220+ #[serde(default)]
221+ pub cost_usd: Option<f64>,
222+ /// Whether some of its usage was for models priced as unknown, which
223+ /// [`Session::cost_usd`] leaves out.
224+ #[serde(default)]
225+ pub cost_partial: bool,
217226 }
218227
219228 /// Where a session's title came from. g1t settles a title from the
808817 pub model: Option<String>,
809818 #[serde(default)]
810819 pub result: Option<String>,
820+ /// The session's token usage so far, per model, as its harness counts
821+ /// it: cumulative, so it replaces what was reported before.
822+ #[serde(default)]
823+ pub usage: Option<Vec<crate::model_prices::ModelUsage>>,
811824 }
812825
813826 /// `end_session`. Returns `Outcome<Session>`; ending one already ended
+1−0
12891289 prompt: None,
12901290 model: None,
12911291 result: None,
1292+ usage: None,
12921293 }
12931294 }
12941295
+7−0
1+-- What a harness session cost: its token usage as its harness last
2+-- reported it (JSON, cumulative, one entry per model: coordination/sessions.rs),
3+-- and the cost estimated from it at Anthropic's list price in US dollars
4+-- (g1t_contracts::model_prices). cost_usd is NULL until the session reports
5+-- usage for a model with a known price.
6+ALTER TABLE sessions ADD COLUMN usage TEXT;
7+ALTER TABLE sessions ADD COLUMN cost_usd REAL;
+1−0
213213 prompt: None,
214214 model: None,
215215 result: None,
216+ usage: None,
216217 })
217218 .await?;
218219 if let Outcome::Fail(failure) = attached {
+16−1
5252 " AS item,
5353 pull, branch, actor_id, actor_name, harness, harness_session_id,
5454 title, state, head_commit, started_at, last_seen_at, ended_at, end_reason,
55− parent_id, agent_type, task, prompt, result, model, title_source, title_settled_at"
55+ parent_id, agent_type, task, prompt, result, model, title_source, title_settled_at,
56+ usage, cost_usd"
5657 );
5758
5859 /// A claim's columns, with its work item's workspace and number.
105106 title_source: Option<String>,
106107 #[serde(default)]
107108 title_settled_at: Option<String>,
109+ /// Its token usage as last reported (JSON).
110+ #[serde(default)]
111+ usage: Option<String>,
112+ #[serde(default)]
113+ cost_usd: Option<f64>,
108114 }
109115
116+/// Whether some of a session's usage, as kept, was for models priced as unknown.
117+fn cost_partial(usage: Option<&str>) -> bool {
118+ usage
119+ .and_then(|usage| serde_json::from_str::<Vec<g1t_contracts::model_prices::ModelUsage>>(usage).ok())
120+ .is_some_and(|usage| g1t_contracts::model_prices::estimate(&usage).partial)
121+}
122+
110123 impl SessionRow {
111124 /// The session as it reads at `now`.
112125 pub(crate) fn at(self, now: u64) -> Session {
137150 first_prompt: None,
138151 title_source: self.title_source.as_deref().and_then(TitleSource::parse),
139152 title_settled_at: self.title_settled_at,
153+ cost_partial: cost_partial(self.usage.as_deref()),
154+ cost_usd: self.cost_usd,
140155 }
141156 }
142157 }
+20−0
176176 )
177177 }
178178
179+/// Session `?3`'s token usage (`?1`, JSON as reported) and the cost
180+/// estimated from it (`?2`): cumulative, so each report replaces the last.
181+pub(crate) const SET_USAGE: &str = "UPDATE sessions SET usage = ?1, cost_usd = ?2 WHERE id = ?3";
182+
179183 /// A subagent's heartbeat keeps its parent `?2` seen at `?1`.
180184 pub(crate) const SEE_PARENT: &str = "UPDATE sessions SET last_seen_at = ?1 WHERE id = ?2 AND state = 'active' AND last_seen_at < ?1";
181185
339343 model: fields.model.clone(),
340344 title_source: fields.title.as_ref().map(|_| TitleSource::Agent.as_str().to_owned()),
341345 title_settled_at: fields.title.as_ref().map(|_| at.clone()),
346+ usage: None,
347+ cost_usd: None,
342348 }
343349 .at(now);
344350 let started = |session: &Session| g1t_contracts::events::marked(SessionEvent::of(session), Some(&a.actor));
570576 model: tidy(a.model.as_deref(), MAX_MODEL_CHARS),
571577 result: clip(a.result.as_deref().map(shown::clean).as_deref(), MAX_RESULT_CHARS),
572578 };
579+ // Its usage first, so the session returned carries the cost.
580+ if let Some(usage) = &a.usage {
581+ let usage = match g1t_contracts::model_prices::parse_usage(&serde_json::to_value(usage).unwrap_or_default()) {
582+ Ok(usage) => usage,
583+ Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
584+ };
585+ let cost = g1t_contracts::model_prices::estimate(&usage).cost_usd;
586+ let json = serde_json::to_string(&usage).unwrap_or_else(|_| "[]".to_owned());
587+ self.db
588+ .prepare(SET_USAGE)
589+ .bind(&[json.into(), cost.map_or(JsValue::NULL, JsValue::from), row.id.as_str().into()])?
590+ .run()
591+ .await?;
592+ }
573593 self.resume(&repo, row, &a.actor, fields).await
574594 }
575595
+10−1
7676 session.head_commit = None;
7777 session.harness_session_id = None;
7878 session.agent_type = None;
79+ session.cost_usd = None;
80+ session.cost_partial = false;
7981 session.end_reason = session.end_reason.take().filter(|reason| reason == "timed_out");
8082 }
8183 }
108110 }
109111
110112 /// What an update may say of another session that only members see.
111−const MEMBERS_ONLY: &[&str] = &["title", "first_prompt", "prompt", "task", "result", "branch", "head_commit", "harness_session_id", "model", "agent_type", "end_reason"];
113+const MEMBERS_ONLY: &[&str] = &[
114+ "title", "first_prompt", "prompt", "task", "result", "branch", "head_commit", "harness_session_id", "model", "agent_type", "end_reason",
115+ "cost_usd", "costUsd",
116+];
112117
113118 /// An update's data without what only members see: what other sessions
114119 /// are titled, were asked, reported and run on.
213218 first_prompt: Some("ugh fix it".into()),
214219 title_source: None,
215220 title_settled_at: None,
221+ cost_usd: Some(4.12),
222+ cost_partial: true,
216223 }
217224 }
218225
237244 assert_eq!(one.result, None);
238245 assert_eq!(one.branch, None);
239246 assert_eq!(one.model, None);
247+ assert_eq!((one.cost_usd, one.cost_partial), (None, false));
240248 assert_eq!(one.item, Some(4));
241249 assert_eq!(one.actor.username, "a");
242250 for kind in [SessionEntryKind::Prompt, SessionEntryKind::ToolCall, SessionEntryKind::Note, SessionEntryKind::Message] {
251259 assert_eq!(one.title.as_deref(), Some("Fix this login with G1T_TOKEN=[redacted]"));
252260 assert_eq!(one.first_prompt, None);
253261 assert_eq!(one.result.as_deref(), Some("done; token ghp_[redacted]"));
262+ assert_eq!((one.cost_usd, one.cost_partial), (Some(4.12), true));
254263 let prompt = entry(an_entry(SessionEntryKind::Prompt, "wtf, fix it"), Sight::Member).unwrap();
255264 assert!(prompt.withheld && prompt.text.is_empty());
256265 let output = entry(an_entry(SessionEntryKind::ToolResult, "AWS_SECRET=x"), Sight::Member).unwrap();
+41−1
77 BRANCH_SESSIONS, FAMILY, FIRST_PROMPTS, INSERT_LINK, INSERT_OVERLAP, INSERT_TOUCH, PULL_SESSIONS, PullSessionRow, TOUCHED_BY_OTHERS, end_tree_sql,
88 pull_sessions_of, tree_sql,
99 };
10−use super::sessions::{INSERT_ENTRY, MOVE_ENTRIES, RENEW_CLAIM, harness_session_sql, insert_session_sql, refresh_sql, renewal, resume_sql};
10+use super::sessions::{INSERT_ENTRY, MOVE_ENTRIES, RENEW_CLAIM, SET_USAGE, harness_session_sql, insert_session_sql, refresh_sql, renewal, resume_sql};
1111 use super::{after_minutes, expire_claims_sql, time_out_sessions_sql};
1212 use super::updates::deliver_sql;
1313 use g1t_contracts::time::rfc3339;
188188 assert_eq!(state, "active");
189189 }
190190
191+/// Reports usage the way `update_session` does, then heartbeats: the cost and usage the session reads with.
192+fn report_usage(db: &Connection, id: &str, usage: serde_json::Value) -> (Option<f64>, Option<String>) {
193+ let usage = g1t_contracts::model_prices::parse_usage(&usage).unwrap();
194+ let cost = g1t_contracts::model_prices::estimate(&usage).cost_usd;
195+ db.execute(SET_USAGE, params![serde_json::to_string(&usage).unwrap(), cost, id]).unwrap();
196+ db.query_row(
197+ &resume_sql(),
198+ params![
199+ None::<String>, None::<u32>, None::<String>, None::<String>, None::<String>, None::<String>, None::<String>, None::<String>,
200+ None::<String>, None::<String>, rfc3339(NOW), id, None::<String>
201+ ],
202+ |row| Ok((row.get("cost_usd")?, row.get("usage")?)),
203+ )
204+ .unwrap()
205+}
206+
207+#[test]
208+fn a_sessions_usage_replaces_the_last_and_is_priced_at_list_price() {
209+ let db = database();
210+ start(&db, "ses_1", Some("a"));
211+ let (cost, usage) = report_usage(&db, "ses_1", serde_json::json!([{ "model": "claude-opus-5-5", "input_tokens": 1_000_000 }]));
212+ assert_eq!(cost, Some(4.0));
213+ assert!(!super::cost_partial(usage.as_deref()));
214+ // Cumulative: the next report replaces it, it is not added.
215+ let (cost, usage) = report_usage(
216+ &db,
217+ "ses_1",
218+ serde_json::json!([{ "model": "claude-opus-5-5", "input_tokens": 2_000_000 }, { "model": "gpt-5", "output_tokens": 10 }]),
219+ );
220+ assert_eq!(cost, Some(8.0));
221+ assert!(super::cost_partial(usage.as_deref()));
222+ // Only models priced as unknown: no cost, and partial.
223+ let (cost, usage) = report_usage(&db, "ses_1", serde_json::json!([{ "model": "gpt-5", "output_tokens": 10 }]));
224+ assert_eq!(cost, None);
225+ assert!(super::cost_partial(usage.as_deref()));
226+ let read: Option<f64> = db.query_row("SELECT sessions.cost_usd FROM sessions WHERE id = 'ses_1'", [], |row| row.get(0)).unwrap();
227+ assert_eq!(read, None);
228+ assert!(!super::cost_partial(None));
229+}
230+
191231 #[test]
192232 fn a_subagent_starts_once_and_once_finished_stays_finished() {
193233 let db = database();

Comments

Sign in to comment.