-
Notifications
You must be signed in to change notification settings - Fork 2
Copilot Chat Data Contract
- Date: 2026-05-06
- Status: Accepted
- Issue: #649
- Milestone: 8.4.0 (epic: #647)
- Related: ADR-0088 §7 (host-scoped vs. provider-scoped surfaces, amended in #648), ADR-0089 (local-tail-first live-path contract), ADR-0090 (Cursor Usage API contract — direct precedent for this ADR), ADR-0091 (pricing manifest used to dollarize tailed tokens)
Budi 8.4 extends the host extension to live inside both Cursor and VS Code, with GitHub Copilot Chat as the first non-Cursor provider. Unlike Cursor — where per-message tokens and cost are read from the local state.vscdb::cursorDiskKV bubbles (ADR-0090, §2026-04-23) and the Cursor Usage API plays a supplementary overage-attribution role — Copilot Chat splits its surface across two upstreams:
-
Local JSON/JSONL under VS Code's
workspaceStorageandglobalStorage. Per-request tokens and model id land here within seconds of the wire call. This is the live signal. -
GitHub Billing API (
/users/{username}/settings/billing/premium_request/usage). Per-user dollar truth-up for individually-licensed users. Empty for org-managed-license users.
Both surfaces are undocumented contracts owned by GitHub, and the local format in particular has shifted at least four times in the last year — VS Code delta → Copilot CLI shape → legacy usage.* shape → Feb-2026 result.metadata.* shape. Without a versioned ADR pinning the contract, the next Copilot Chat release silently breaks the parser and the next Budi train re-discovers the format from scratch. ADR-0090 set the precedent for this pattern with the Cursor Usage API; this ADR is the Copilot Chat sibling.
The implementation tickets that land against this contract are #651 (R1.4 — local tailer) and #652 (R1.5 — sync_direct reconciliation against the Billing API). This ADR is the spec they implement to; any change in upstream behavior is handled by amending this ADR in the same PR as the parser update so the contract and the code never disagree.
The Copilot Chat data contract below is the authoritative Budi-side description of the surface. The forthcoming Copilot Chat provider (crates/budi-core/src/providers/copilot_chat.rs, R1.4) reads and writes against this contract; the sync_direct reconciliation worker (R1.5) consumes the Billing API contract in §3. Any divergence in upstream behavior is handled by amending this ADR in lockstep with the parser change.
-
Provider id:
copilot_chat. Distinct from the existingcopilot_cliprovider (crates/budi-core/src/providers/copilot.rs) — Copilot CLI tails~/.copilot/session-state/and is unrelated to the VS Code/Insiders/Codium/Cursor extension surface. -
Host scope: Copilot Chat is a VS Code-family provider only. The same provider plugin handles every VS Code variant (stable, Insiders, Exploration, VSCodium, Cursor) and remote-server installs because all of them write to identically-shaped
User/directories. -
Surface scope: provider-scoped on the cloud dashboard, on
budi stats, and onbudi statusline --format jsonwith a single?provider=copilot_chat. Host-scoped only when aggregated alongside other providers via the multi-provider statusline endpoint (R1.3, #650) per ADR-0088 §7.
The provider iterates the cross-product of OS-specific application-support roots and a small set of VS Code-family directory names. Anything matching is a candidate root; missing roots are silently skipped.
-
macOS:
~/Library/Application Support/{Code,Code - Insiders,Code - Exploration,VSCodium,Cursor}/User -
Linux:
~/.config/{Code,Code - Insiders,Code - Exploration,VSCodium,Cursor}/User -
Windows:
%APPDATA%\{Code,Code - Insiders,Code - Exploration,VSCodium,Cursor}\User -
Remote / dev-container:
~/.vscode-server/data/User,~/.vscode-server-insiders/data/User,~/.vscode-remote/data/User,/tmp/.vscode-server/data/User,/workspace/.vscode-server/data/User
The remote roots cover SSH remote, dev containers, Codespaces, and VS Code Tunnels. They are checked unconditionally on every host so a developer who SSHes into a workstation that itself has Copilot Chat running picks up both local and remote sessions.
Five candidate subpath shapes, all checked. The {GitHub,github} brace expansion is required because the Copilot extension's publisher-id casing has flipped at least once between releases.
workspaceStorage/<hash>/chatSessions/workspaceStorage/<hash>/{GitHub,github}.copilot-chat/{chatSessions,debug-logs}/workspaceStorage/<hash>/{GitHub,github}.copilot/{chatSessions,debug-logs}/globalStorage/emptyWindowChatSessions/globalStorage/{GitHub,github}.copilot-chat/**/{chatSessions,chat-sessions,sessions}/**/*.{json,jsonl}globalStorage/{GitHub,github}.copilot/**/{chatSessions,chat-sessions,sessions}/**/*.{json,jsonl}
The two globalStorage/{GitHub,github}.copilot{,-chat}/... patterns are intentionally recursive in the middle segment: GitHub has shipped at least three different sub-directory layouts under that prefix (chatSessions/, chat-sessions/, sessions/<lang>/), and pinning to any one shape regresses on the next release. The bottom-out is anchored at a directory-name allowlist (chatSessions, chat-sessions, sessions) instead of "any *.json or *.jsonl" — anchoring at a known session-storage directory name keeps the recursion tolerant of layout shuffles below the publisher-id directory while excluding files the Copilot extension parks as siblings of those directories. Concretely, embedding caches (commandEmbeddings.json, settingEmbeddings.json) and the Copilot CLI v2 state blob (copilot.cli.oldGlobalSessions.json) live one level under the publisher-id directory and never inside a session-storage directory; the allowlist is the cheapest invariant that excludes them without preempting the next layout shuffle (#684). When the Copilot extension introduces a fourth session-storage directory name in a future release, this allowlist is amended in lockstep — the same drill as §2.3 record-shape additions. This is a discovery-layer change; MIN_API_VERSION does not bump (the §2.6 record-shape contract is untouched).
Each candidate path is read as a stream of newline-delimited JSON (*.jsonl) or as a JSON document (*.json). Both forms wrap their per-message records in an envelope key — the parser flattens the envelope before applying the token-key dispatch.
Envelope keys (any one of these may be present; first match wins):
| Format | Envelope shape | Notes |
|---|---|---|
| JSONL mutation log (v4, 8.4.1) | { "kind": 0|1|2, "k": [...], "v": ... } |
The github.copilot-chat extension's authoritative on-disk shape since VS Code 1.109 / extension ≥0.47.0. kind: 0 is a full session snapshot; kind: 1 is a set mutation at JSON-Pointer-shaped path k; kind: 2 is an array splice/append at k. Tokens land via later kind: 1 patches like {"kind":1,"k":["requests",8,"completionTokens"],"v":39} — never at the top of the line. The parser replays the mutation log onto a per-session reducer state and applies the token-key dispatch below to the materialized request, not the raw line. |
| JSONL line wrapper (v2/v3 compat) | { "kind": N, "v": [ ... ] } |
Pre-mutation-log shape kept for hand-trimmed fixtures and any build that wraps records in v without k. The reducer treats kind: 2 with no k as an append to requests. |
| JSON document | { "requests": [ ... ] } |
Persisted-on-close session snapshot. Each item in requests is a turn with optional result.metadata.{promptTokens,outputTokens}. |
| JSON document (legacy) | { "messages": [ ... ] } |
Older synthetic-fixture shape. Retained for back-compat. |
| Bare record (no envelope) | { ...token keys... } |
Treated as a single-record envelope. Used by the v1 unit fixtures and any future format that drops the wrapper. |
Token-key shapes (applied to each flattened record; first non-zero pair wins):
| Format | Shape origin | Input-tokens key | Output-tokens key |
|---|---|---|---|
| VS Code delta | original VS Code Copilot extension delta-event format | promptTokens |
outputTokens |
| Copilot CLI | shape inherited from the standalone copilot CLI in 2025 |
modelMetrics.inputTokens |
modelMetrics.outputTokens |
| Legacy | the OpenAI-ish shape Copilot Chat used through 2025-Q4 | usage.promptTokens |
usage.completionTokens |
| Feb 2026+ | nested-result shape introduced in the Feb-2026 Copilot Chat release | result.metadata.promptTokens |
result.metadata.outputTokens |
| Output-only fallback (v3, 8.4.0) | shape used by VS Code Copilot Chat builds circa May-2026 that persist response token counts but not prompt token counts | (input = 0) | completionTokens |
The parser tries the four full-pair shapes in the order above per record and uses the first one that yields both a non-zero input and a non-zero output count. If none match, it tries the output-only fallback as a last resort and emits the row with input_tokens = 0. Records that match no shape — full-pair or fallback — are skipped (see §2.6).
The envelope split was added in 8.4.0 after the smoke-gate fixtures uncovered that real on-disk JSONL files write tokens at v[].result.metadata.{promptTokens,outputTokens} rather than at the top of the line — the four token-key shapes are unchanged, the parser just looks one level deeper before applying them. MIN_API_VERSION was bumped to 2 in lockstep (§2.6).
The output-only fallback was added in the same 8.4.0 cycle after smoke-gate verification on a freshly captured session uncovered that newer VS Code Copilot Chat builds (May-2026) persist completionTokens at the top of each response record but drop the prompt-token counterpart entirely — result.metadata no longer contains promptTokens or outputTokens for these records. The fallback is the only shape allowed to relax the both-non-zero invariant from the four full-pair shapes; rows it emits flow through downstream pricing as output-only at the manifest layer and are truthed up to the real bill by the §3 Billing API reconciliation worker on the next tick (for users with a configured PAT). MIN_API_VERSION was bumped to 3.
Mutation-log reducer (v4, 8.4.1, R1.1, #668). VS Code 1.109+ (and github.copilot-chat ≥0.47.0) persist sessions as a JSON Pointer mutation log: a kind:0 snapshot followed by kind:1 set-at-pointer and kind:2 array-splice patches. Token counts arrive on later kind:1 patches like {"kind":1,"k":["requests",8,"completionTokens"],"v":39} — buried inside k, never at the top of the line. The v3 parser saw these as flat records with no token keys at the top level and emitted zero rows from active sessions on extension ≥0.47.0; only historical sessions whose kind:0 snapshot already inlined the tokens produced rows. The v4 parser is therefore a per-session reducer:
-
kind:0—vis the initial state. Top-level keys are merged into the reducer state (sostate.requests,state.sessionId, etc. start populated). -
kind:1—setat JSON-Pointer-shaped pathkto valuev. Numeric segments index arrays (auto-grown with placeholder objects/arrays); string segments key objects (auto-created on missing intermediates). -
kind:2— array splice/append atk.vis an array of items to push. Whenkis missing or empty (hand-trimmed fixtures and a few older builds), defaults to["requests"].
After each mutation is applied, the reducer scans state.requests and runs the four-then-five token-key dispatch above against the materialized request — that is, the request object as it exists after the patch — rather than against the raw line. The four full-pair shapes and the output-only fallback are unchanged; what changed is what the dispatch is applied to. A request emits a row the moment the dispatch returns Some, keyed by requestId so a future patch on the same request (e.g. an updated timestamp) does not double-emit.
Tail offset semantics: tail_offsets.byte_offset still records bytes consumed and the framework still hands the parser only the appended chunk, but the parser reads the full file from disk so the reducer can replay from byte 0 every tick. Cross-call re-emission is safe because the (session_id, requestId) deterministic UUID collides at the database upsert layer. For long sessions a per-session "last-processed line index" cache can be added later if cost matters; on a typical session file (~100 KB) full replay is sub-millisecond.
MIN_API_VERSION was bumped to 4.
Mutation-paths that emit no rows by design (v5, 8.5.1, #791). VS Code 1.119+ persists every chat-input attachment the user drags from a non-source surface (Settings UI, Outline, file tree, run/debug view) as a kind:1 k=["inputState","attachments"] v=[...] mutation. Each attachment item is a DOM-like UI introspection record with shape ["ancestors","attributes","computedStyles","dimensions","fullName","icon","id","innerText","kind","modelDescription","name","value"], routinely tens to hundreds of kilobytes per mutation. The reducer applies these mutations to state.inputState.attachments like any other kind:1 patch — they never reach state.requests, the four-then-five token-key dispatch is never run against them, and they emit zero rows. The shape signature is intentionally not added to §2.3: these records carry no request data, no promptTokens, no completionTokens, no usage block, no modelMetrics — they are UI state, not chat content. Counting them as a "fifth shape" would be a category error.
In the wild (#791) the attachments mutations dominate the byte stream — a single VS Code session can write 30 MB of attachments in 30 minutes while completing zero requests — and the tailer rows / Copilot Chat heuristic in budi doctor was misreading that bytes-vs-rows ratio as a parser regression. The v5 fixture (vscode_chat_0_47_0_v5.jsonl) pins the contract so the signal-to-noise ratio does not drift again. The companion discovery-layer fix (§2.2 above) excludes <hash>/{GitHub,github}.copilot-chat/debug-logs/<requestId>/main.jsonl — OpenTelemetry span output that the previous discovery rule mis-classified as a session file, accounting for the bulk of historical copilot_chat_unknown_record_shape warnings.
MIN_API_VERSION was bumped to 5. No parser logic changed in v5 (the reducer already handled inputState.attachments mutations as "any other kind:1 patch") — the bump exists to make the fixture + discovery audit visible through the §2.6 ADR/code drift signal.
Canonical fixture (R1.2, 8.4.1, #669). The shape described above is pinned to a real on-disk capture at crates/budi-core/src/providers/copilot_chat/fixtures/vscode_chat_0_47_0.jsonl (sanitized — prompt text, response markdown, code citations, file paths, and local-machine metadata stripped; envelope keys, requestId, timestamps, agent.*, modelId, responseId, modelState, and completionTokens / promptTokens patches preserved). A sibling vscode_chat_0_47_0.expected.json lists the per-request (requestId, output_tokens, input_tokens, model) tuples the reducer must materialize. A truncated companion vscode_chat_0_47_0_streaming.jsonl slices the fixture mid-stream — kind:2 stub written, kind:1 completionTokens patch not yet — and pins the no-emit-until-completion-token contract from R1.1. When extension N+1 changes the format again, the next bump captures a new fixture and the previous one is kept as a regression for the older format.
A side note from the same investigation, amended 8.4.2 (#685): the original 8.4.1 wording ruled result.metadata.resolvedModel out as a pricing key wholesale, citing dated suffixes (claude-haiku-4-5-20251001) and GPU-fleet codes (capi-noe-ptuc-h200-oswe-vscode-prime) as evidence. That rule was too strong — it conflated the fleet-code shape with all uses of the field. Across three real on-disk sessions on 2026-05-07, resolvedModel carried a clean LiteLLM-canonical Anthropic key, a clean non-Anthropic key (grok-code-fast-1 for an auto-routed Grok turn), and a fleet code respectively. Discriminating by shape alone is unsafe — fleet codes share the same [a-z0-9-]+ shape as real model ids — but shape + manifest membership is a clean filter: fleet codes are never in the manifest, real model ids always are. Per §2.4 the parser now prefers resolvedModel when both gates pass and falls through to modelId / the §2.4.1 agent.id table otherwise, so the previous "never use resolvedModel" rule no longer applies. The Billing API reconciliation in §3 still supplies the dollar truth for individually-licensed users on every tick.
Cache-token keys, when present, follow the same per-shape pattern under cacheReadTokens / cacheWriteTokens (delta and Feb-2026 shapes) or under usage.cacheReadInputTokens / usage.cacheCreationInputTokens (legacy). Cache tokens are best-effort — Copilot Chat does not expose cache fields on every record.
The parser walks three sources in this order (precedence flipped 8.4.2 / #685; previously resolvedModel was ruled out wholesale and modelId led):
-
result.metadata.resolvedModel— when the value is shape-clean (^[a-z][a-z0-9-]*$) and known to the in-memory pricing manifest (directly or via the alias overlay from #680). This is the actual model GitHub routed to. Captures non-Anthropicauto-routed turns (grok-code-fast-1) and dated LiteLLM-canonical Anthropic keys (claude-haiku-4-5-20251001) without us guessing. Skips GPU-fleet codes (capi-noe-ptuc-h200-oswe-vscode-prime) because they're never in the manifest, so the worst case here is a no-op falling through to (2)/(3). -
modelId— top-levelmodelId, thenresult.metadata.modelId(Feb-2026+ shape). Strip thecopilot/prefix if present (e.g.copilot/claude-sonnet-4-5→claude-sonnet-4-5). Returned as-is unless it is the literal"auto"router placeholder, in which case (3) runs. -
§2.4.1
agent.idstatic-table fallback — see below. The 8.4.1 R1.4 resolver, demoted from primary to last-resort by #685; the table itself is unchanged.
If none of the three yield a value, fall back to a per-session default if a record carries tokens without a model id (e.g. interrupted sessions). Default is the per-session model recorded in the session manifest, if any; otherwise the model id is left empty and the row is tagged unpriced:no_model by the cost enricher (consistent with how unpriced:no_tokens is handled in ADR-0090 §2026-04-23).
This is a model-extraction priority change, not a §2.3 record-shape change — MIN_API_VERSION does not bump.
2.4.1 auto router resolution (R1.4, 8.4.1, #671)
When the user picks auto in the Copilot Chat model selector, GitHub picks the actual model server-side and persists the literal string "auto" as the request's modelId. The LiteLLM pricing manifest has no auto entry, so a literal "auto" model id falls through to unpriced:no_pricing and rows price at $0 — the headline post-#R1.1 user-visible defect (#671) on the surface of budi sessions for any developer who leaves the model picker on the default.
The parser resolves "auto" to a concrete model id via agent.id only after §2.4 step (1) (manifest-known resolvedModel) and step (2) (concrete modelId) have both failed. The order, post-#685:
- If §2.4 step (1) hit a manifest-known
resolvedModel— done; this section never runs. - If
modelId != "auto"— pass through as-is (§2.4 step (2)). - Otherwise, look at
agent.idon the same record and resolve via the table below. - If
agent.idis missing or unrecognised, preserve the literal"auto"so the row still emits — pricing then falls through tounpriced:no_pricingand the §3 Billing API reconciliation worker trues the dollar number up to the real bill on the next tick (for individually-licensed users with a configured PAT).
agent.id |
Resolves to | Notes |
|---|---|---|
github.copilot.editsAgent |
claude-sonnet-4-5 |
Edit-mode chat. Copilot has routed to Claude Sonnet for code-edit-heavy turns since the GPT-5 / Sonnet 4.5 dual-default rollout in early 2026. |
github.copilot.codingAgent |
claude-sonnet-4-5 |
Newer agent-mode coding flow; same routing default as editsAgent. |
github.copilot.workspaceAgent |
gpt-4.1 |
@workspace chat. |
github.copilot.terminalAgent |
gpt-4.1 |
@terminal chat. |
github.copilot.default |
gpt-4.1 |
Plain chat panel (default participant). |
github.copilot.chat-default |
gpt-4.1 |
Older alias for the default participant. |
github.copilot |
gpt-4.1 |
Bare publisher id seen on a small fraction of older sessions. |
Three options were considered (per #671): Option A — resolve forward in the file via agent.id. Option B — accept the $0 and lean on the §3 reconciliation worker. Option C — move the table into a model_aliases block on the LiteLLM manifest cache. Option A ships in 8.4.1 with the table living inline in crates/budi-core/src/providers/copilot_chat.rs::resolve_auto_model_id. Option B alone is unacceptable because for org-managed-license users the §3.4 path returns empty and a wrong guess would leave them at $0 indefinitely; Option A guarantees a non-zero list-price-equivalent number for every user. Option C is the longer-term home — defer to 9.0.0 unless the inline table proves unreliable in practice.
GitHub does not contractually pin which model auto resolves to — it can shift between Copilot updates. The table is therefore the current most-common default at the time of the 8.4.1 patch, not a contract. When upstream rotates a default, the fix is the same as for §2.3 shape drift: amend this section, edit resolve_auto_model_id, cut both in the same PR. Wrong guesses only affect org-managed-license users (the §3 reconciliation trues up dollars for individually-licensed users on its next tick), so the cost of a stale entry is bounded.
Re-using the resolver: Continue / Cline / Roo Code are deferred to 9.0.0 (#295) and will hit the same auto-router shape. The agent.id → model mapping is per-provider — each provider plugin will ship its own table, but the resolution rule (§2.4.1) is the canonical pattern they implement.
Tokens come from §2.3, the model id from §2.4, and dollarization runs through pricing::lookup from ADR-0091. Copilot Chat is not on a per-call dollar API at the local-tail layer — the dollar number is tokens × manifest_price. This is identical to the framing in ADR-0090 §2026-04-23: a list-price equivalent, not a Copilot bill. The Copilot bill itself is reconciled via §3 below for individually-licensed users, and is necessarily absent for org-managed-license users.
When the parser encounters a record whose token keys match none of the four shapes in §2.3, it logs once per (file_path, shape_signature) per daemon run at warn level with the message copilot_chat_unknown_record_shape and a redacted set of the top-level keys present, then skips the record. Skipping a record never fails the file — partial parses are valid, the next record may match, and a future Copilot Chat release that adds a fifth shape does not break ingestion silently.
When a fifth shape is observed in the wild, the fix is:
- Update §2.3 of this ADR with the new shape.
- Add the matching arm to the parser dispatch.
- Bump the
copilot_chatprovider'sMIN_API_VERSIONconstant (defined incrates/budi-core/src/providers/copilot_chat.rsas a monotonically-incrementing integer, mirroring the pattern inbudi-cursor'sMIN_API_VERSION). - Cut the ADR amendment, the parser change, and the version bump in the same PR.
The MIN_API_VERSION bump is what makes ADR/code drift visible: a manifest version mismatch surfaces in budi doctor (R1.6, #653) and is the signal that an upgrade requires an ADR review.
Live ingestion uses the existing reverse-proxy-first JSONL tailing infrastructure from ADR-0089. Each file's byte offset is persisted in the existing tailer_offsets table keyed by (provider = "copilot_chat", path). JSON-document files (the non-JSONL chatSessions/*.json shape) are tailed by re-parsing on mtime change and tracking the last-seen messages[] length; this is more conservative than byte-offset tailing but matches how the document is rewritten in place by the extension. Sessions are correlated to messages by the file's parent-directory <session-id> segment for chatSessions/ paths, by the JSON document's own sessionId field where present, and otherwise by a deterministic-uuid derived from (file_path, message_index) (consistent with the copilot_cli provider's deterministic_uuid shape at crates/budi-core/src/providers/copilot.rs).
GET https://api.github.com/users/{username}/settings/billing/premium_request/usage
Response shape (representative — only the fields Budi reads are pinned here):
{
"billing_cycle_start": "2026-05-01T00:00:00Z",
"billing_cycle_end": "2026-05-31T23:59:59Z",
"premium_request_usage": [
{
"date": "2026-05-04",
"model": "gpt-4.1",
"request_count": 142,
"premium_requests_used": 35.5,
"amount_in_cents": 875,
"is_overage": false
}
]
}GET https://api.github.com/users/{username}/settings/billing/usage
GitHub's public roadmap has Premium Request Units (PRUs) replaced by GitHub AI Credits — a token-based unit — on 2026-06-01. The endpoint path drops the /premium_request segment; the response shape changes the per-row unit from premium_requests_used to credits_used, adds explicit input_tokens and output_tokens columns, and keeps amount_in_cents as the dollar truth.
The sync_direct worker probes the post-transition endpoint first; on 404 it falls back to the pre-transition endpoint. This makes the cutover seamless without a release gate. The Budi-side type is a BillingUsageRow enum with Pru { premium_requests_used, .. } and Credit { credits_used, input_tokens, output_tokens, .. } arms; the cost_cents column is populated from amount_in_cents either way.
- Method: GitHub Personal Access Token (PAT).
-
Required scope:
manage_billing:copilot. Fine-grained tokens require the equivalent Plan: read-only permission on the target user. -
User opt-in only. The PAT is stored in the daemon's existing keyring-backed secret store under the key
copilot_chat:billing_pat. The user supplies it viabudi config set copilot_chat.billing_pator via the host extension's settings panel. The daemon never auto-prompts for a PAT and never falls back to theghCLI's session token (that token does not carrymanage_billing:copilotand the silent fallback would create a confusing auth-error surface). If no PAT is configured, thesync_directworker is a no-op andbudi doctorreportscopilot_chat: billing reconciliation unconfigured (local tail only). -
Headers:
Authorization: Bearer <PAT>Accept: application/vnd.github+json-
X-GitHub-Api-Version: 2022-11-28(current). When the post-transition endpoint requires a newer API-Version pin, the bump rides with the same PR that adds the post-transition arm. -
User-Agent: budi/<version>(consistent with other Budi outbound calls).
For users whose Copilot license is org-billed (Copilot Business / Enterprise seat), the endpoint returns 200 with an empty premium_request_usage: [] (or credits_used: 0 in the post-transition shape). This is the documented behavior, not a bug. GitHub bills the org, not the user, and the per-user endpoint has no truth to surface.
The local-tail path from §2 still produces tokens × pricing-manifest dollars for these users — the dollar number is exactly the list-price-equivalent framing from ADR-0091, so a Copilot Business user gets a meaningful dashboard number, just not a Copilot bill. budi doctor flags org-managed users with copilot_chat: org-managed license — billing reconciliation unavailable, local-tail tokens × manifest pricing in effect. The ingest path tags rows from this state with cost_confidence = "estimated" (not "exact"), matching the existing taxonomy.
Detection of the org-managed state: the empty-but-200 response persisted across two consecutive successful ticks within the same billing cycle is treated as org-managed. A single empty response can be a "no usage yet this cycle" individual user; the second consecutive empty response inside an active cycle is the unambiguous signal.
For individually-licensed users, the Billing API is the dollar truth. Local-tail rows for the same (date, model) are truthed-up but not replaced:
-
tokensfrom local tail are preserved (Billing API doesn't always carry them in the pre-2026-06-01 shape). -
cost_centsis overwritten fromamount_in_centson a(date, model)-bucketed basis. Overwrites are taggedpricing_source = "billing_api:copilot_chat"to distinguish them from manifest-priced rows per ADR-0091 §4. -
cost_confidenceis bumped from"estimated"to"exact". - The pre-existing
manifest:vNNNpricing_sourcetag is shadowed by thebilling_api:copilot_chattag on the same row. This is a single exception to ADR-0091 Rule B (known→known with a new price is never a rewrite): the rewrite is from estimated-from-manifest to exact-from-vendor, which is a confidence increase, not a price change in the Rule B sense. The exception is logged for the audit trail. - Bucketing granularity is
(date, model)because that is the granularity the Billing API exposes. Per-message attribution is preserved on the local-tail row; the dollar correction is a per-bucket scaling factor applied uniformly to every message in the bucket.
This ADR adds two new outbound destinations to Budi's surface:
-
https://api.github.com/users/{username}/settings/billing/...— only for users who have explicitly configured a PAT under §3.3. The request carries the user's own GitHub PAT to GitHub's own API. No Budi-owned infrastructure sits between the client and GitHub. - No new outbound for the local-tail path — files are read from the user's own machine.
ADR-0083 §Neutral is amended to add the api.github.com Billing API destination to the list of permitted outbound endpoints, gated on the user's opt-in PAT configuration. The trust class is the same as gh api /users/.../billing from any developer's terminal. Budi never logs the PAT, never includes it in diagnostic bundles (budi support bundle redacts the keyring path), and rotates it on every successful keyring read (no in-memory caching beyond the active request).
These are the known failure modes and limits the parser and reconciliation worker must tolerate:
- Undocumented local format. Has shifted four times (§2.3); will shift again. The §2.6 versioning rule bounds the blast radius.
-
Session files rewritten in place. Some Copilot Chat sub-versions rewrite the entire session JSON document on every message. The byte-offset tailer cannot be used for these files (§2.7); the document-path uses
mtime+messages[]length tracking. This is more expensive than byte-offset tailing but bounded by the per-session message count. -
Dual-publisher casing. The publisher id flips between
GitHubandgithubacross releases (§2.2). Path matching is case-insensitive on the publisher segment. -
PAT scope drift. GitHub has renamed
manage_billing:copilotonce already (wasread:billingpre-2024). The §3.3 scope name is current as of 2026-05; on a403with a clear scope-related body,budi doctorsurfaces a remediation message that names the current required scope. - Org-managed empty response. §3.4 — needs the two-consecutive-empty heuristic to disambiguate from "no usage yet this cycle."
-
Billing cycle alignment. GitHub bills on user-account anniversary, not calendar month. The
billing_cycle_start/billing_cycle_endfields drive the reconciliation window; Budi does not assume month boundaries. - 2026-06-01 transition. PRUs → AI Credits. §3.2 covers both shapes and the probe-then-fallback handles the cutover.
-
Rate limit. GitHub's primary rate limit is per-PAT (5,000/h authenticated); the
sync_directcadence is one call per billing cycle per active day, well under the limit.403withX-RateLimit-Remaining: 0is logged once per process and the worker retries on the next tick.
This ADR pins the contract (paths, key shapes, endpoints, auth, response shape, caveats). The code lives in:
-
crates/budi-core/src/providers/copilot_chat.rs(R1.4) — local-tail parser, path discovery, andProviderimpl. -
crates/budi-core/src/sync/copilot_chat_billing.rs(R1.5) —sync_directreconciliation worker against the Billing API.
Both modules must reference this ADR at the top of the file. When upstream ships a breaking change in either surface, the fix is:
- Update this ADR with the new shape (§2.3 for local, §3 for billing).
- Update the relevant code path to match.
- Bump
MIN_API_VERSION(§2.6) for local-tail changes. - Cut the ADR edit, the code change, and the version bump in the same PR so the contract and the code never disagree.
- Both surfaces are pinned. A future format shift (the fifth, or sixth, …) lands as a paired ADR-amendment + parser-change PR instead of a silent regression discovered via dashboard drift.
- Org-managed users are first-class. The empty-billing case has an explicit contract path; the dashboard number is meaningful (list-price equivalent via ADR-0091) instead of a confusing zero.
-
Truth-up without rewriting history. Reconciliation upgrades
estimated → exactconfidence on(date, model)buckets; ADR-0091's Rule B is preserved with a single, audited carve-out for confidence increases. - Cutover is seamless. The probe-first / fallback shape in §3.2 means the 2026-06-01 PRU→Credits transition does not require a Budi release boundary.
- VS Code variants share one provider. Code, Insiders, Exploration, VSCodium, Cursor, and remote-server installs all hit the same parser via §2.1 cross-product.
-
One new opt-in outbound. §4 —
api.github.comjoins the list of permitted destinations. Privacy class is the same asgh api; opt-in via PAT keeps it user-driven. -
Document-rewrite tail mode is more expensive. §2.7 —
mtime+messages[]length tracking is unavoidable for the rewrite-in-place sub-versions; CPU bounded by per-session message count, which is small in practice. - Two-tick org-managed disambiguation has a one-tick lag. §3.4 — the first empty response can't be classified; the second consecutive one can. In the worst case, a new individual user with no usage yet looks unconfigured for one tick. Acceptable.
-
Cloud sync shape unchanged. Cloud receives
cost_centswith no knowledge ofpricing_source = "billing_api:copilot_chat"vsmanifest:vNNN. Provider-scoped semantics from ADR-0083 are preserved. -
copilot_cliprovider unchanged. That provider continues to tail~/.copilot/session-state/and is unrelated to the VS Code-familycopilot_chatsurface. -
Statusline contract unchanged. Copilot Chat plugs into the existing single-provider response shape; aggregation across Copilot Chat + Cursor + Continue happens via R1.3's
?provider=a,b,chost-scoped path per ADR-0088 §7.
-
A
copilot_chatreverse-proxy live path. The Copilot Chat extension does not route through a configurable HTTP proxy in a way Budi can intercept; the local-tail path is the live signal per ADR-0089. -
Per-message dollar attribution from the Billing API. The endpoint exposes
(date, model)granularity; per-message dollar is recovered via §3.5 bucket-scaling, not as a separate Billing API call. - Continue, Cline, Roo Code, Aider, Windsurf providers. Deferred to 9.0.0 per #647 "out of scope". Each gets its own ADR following the same pattern as this one.
- JetBrains Copilot Chat coverage. Out of 8.4 scope; revisited if/when budi-cursor grows a JetBrains sibling.
- Org-billing-side reconciliation (Copilot Business / Enterprise admin surfaces). That is an org admin's data, not the developer's, and lives outside the local-developer-first contract of ADR-0088.
- ADR-0083: Cloud Ingest Identity and Privacy Contract (amended by §4 — Billing API destination added to permitted outbound list)
- ADR-0088: 8.x Local-Developer-First Product Contract §7 (host-scoped vs. provider-scoped surfaces, amended in #648)
- ADR-0089: JSONL Tailing as Sole Live Path
- ADR-0090: Cursor Usage API Contract — direct precedent for this ADR's pattern (pin an undocumented contract, amend in lockstep with the parser)
-
ADR-0091: Model Pricing Manifest Source of Truth —
pricing::lookupis what dollarizes locally-tailed Copilot Chat tokens (§2.5) - #647 — 8.4.0 epic
- #649 — this ADR's tracking issue
-
#650 — R1.3, multi-provider statusline endpoint that aggregates across
copilot_chat - #651 — R1.4, local-tail provider plugin (implements §2)
-
#652 — R1.5,
sync_directBilling API reconciliation (implements §3) -
#653 — R1.6,
budi doctorsurfaces installed VS Code AI extensions and tailer health (consumes §2.6MIN_API_VERSION+ §3.4 org-managed signal + §3.3 unconfigured-PAT signal)
Last verified against code on 2026-05-14.
Wiki cross-references audited on 2026-05-23 — broken relative paths from the retired in-tree docs/adr/ directory were rewritten to wiki links; references to historical ADRs 0081 / 0082 / 0086 / 0088 were left as plain text. Content claims against current source were not re-verified in this pass.
budi · Issues · Releases · app.getbudi.dev · getbudi.dev
Start here
ADRs — Data & privacy
ADRs — Ingestion
ADRs — Pricing
- Model Pricing – Embedded Baseline and Runtime Refresh
- Custom Team Pricing and Effective Cost
- Codex Cost Model – Marginal-Token Counting
ADRs — Provider contracts
Operational references
- Daemon Lifecycle and Autostart
- Provider Plugin Contract
- Cloud Sync Mechanics
- Statusline Integration
- Operations and Observability
- Release and Versioning
Ecosystem