Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixed
- **Copilot agent requests in VS Code chat count every model call.** Copilot Chat 0.69 keeps only the last call of an agent request in its metadata, so CodeBurn showed a fraction of the output and cost. It now reads the request's totals (all calls, with the cache split when VS Code records it), and a request that records GitHub's credits costs exactly that bill (1 credit = $0.01) in reports and plan credits. A chat session that also ran on the agent host is counted once, from the agent host. In Copilot plan credits each chat request is rated on its own. Past Copilot days re-derive once after the update.
- Copilot showed `claude-sonnet-4-6` as "Sonnet 4" instead of "Sonnet 4.6".

## 0.9.27 - 2026-10-11

### Added
Expand Down
20 changes: 15 additions & 5 deletions docs/providers/copilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ Microsoft 365 Copilot (Office, Teams) and Copilot in the browser are not support

JSONL in the first three locations (schemas differ; the parser switches by source type / event shape), a SQLite DB for the OTel source, and a Nitrite (H2 MVStore) `.db` for the JetBrains source. VS Code core chat sessions use a delta journal: `kind:0` sets the root object, `kind:1` writes a value at path `k`, and `kind:2` appends items to an array path.

Core chat-session journals read input from `result.metadata.promptTokens`, falling back to the request's `promptTokens` when metadata has no positive count. Output keeps the existing `result.metadata.outputTokens` then request `completionTokens` precedence. These fields are alternatives, never summed. Input-only requests count; rows without reported usage remain skipped rather than estimated from text. The request-level prompt count is the value VS Code recorded, not a reconstructed total across an agent loop's model calls.
An agent-mode request makes one model call per tool round. In Copilot Chat 0.69 (VS Code 1.141) `result.metadata.promptTokens` and `outputTokens` describe only the last of those calls, while the request itself carries the whole request: `completionTokens` (output summed over every call), `modelTotals` (per model `inputTokens`, `cachedTokens`, `outputTokens`, summed over every call) and `copilotCredits` (GitHub's billed `total_nano_aiu / 1e9`, subagents included). Core chat sessions, journal and legacy JSON alike, read each request in this order:

1. `modelTotals`, summed over its models: output, cache reads from `cachedTokens`, and input without the cached part (the Copilot engine counts cached tokens inside `inputTokens`; an input below the cached count is taken as already uncached).
2. Otherwise output from the request's `completionTokens`, then `result.metadata.outputTokens`; input from `result.metadata.promptTokens`, then the request's `promptTokens`. Both prompt fields hold the last call only, and there is no cache split, so input of a multi-round request without `modelTotals` stays undercounted.

The fields are alternatives, never summed. `copilotCredits` rides as `nanoAiu`, exactly like the agent host's `totalNanoAiu`, so plan credits for these requests are exact, and it sets the request's cost to the bill (credits × $0.01, kept through the cache as a billed cost) because the last-round prompt cannot price it. Requests without `copilotCredits` stay priced from the tokens above. VS Code fills `copilotCredits` from GitHub's `total_nano_aiu` on the extension path, but for an agent-host turn without that figure it falls back to the agent's own `_meta.cost`. The saved request does not say which one it holds. VS Code 1.141 saves full requests only for local sessions, which take the `total_nano_aiu` path, so the figure is treated as the bill; a saved request from the fallback would be priced as if it were.

A chat session that also ran on the agent host is counted once, from the agent host. At serve time, a journal request is left out when a session with exactly the same id has billed usage on the agent-host side: a session-state `events.jsonl` call with tokens or `nanoAiu`, or a session-store row. A session-state file with no usage yet keeps the journal. VS Code 1.141 does not save agent-host sessions as full journals (only an index entry), so this guards a shape not seen on real data.

In plan credits, a chat-session request is rated on its own: a request with `copilotCredits` counts as exact, one without keeps its token estimate, so a session spanning the Copilot Chat 0.69 upgrade is not billed whole. `sessionCopilotCredits` is a running session total and is not read. `result.metadata.summaries[]` holds only compaction text (`toolCallRoundId`, `text`); compaction usage reaches the request's own usage fields, so it adds nothing. Input-only and credits-only requests count; rows without reported usage remain skipped rather than estimated from text.

## VS Code agent host (Copilot CLI engine)

Expand Down Expand Up @@ -197,9 +206,10 @@ see the #927 ruling in `src/session-cache.ts`).
Copilot AI credits (`codeburn plan set copilot-pro`). Billing-grade cost
rewrite of every report is still upstream #890.

These CLI session-store rows and the `session.shutdown` rollup's
`totalNanoAiu` (CLI and VS Code agent host) are the only local sources that
carry an exact credit figure. VS Code chat sessions and transcripts, the OTel
These CLI session-store rows, the `session.shutdown` rollup's
`totalNanoAiu` (CLI and VS Code agent host) and `copilotCredits` on VS Code
chat-session requests (Copilot Chat 0.69 on) are the only local sources that
carry an exact credit figure. Older chat sessions, transcripts, the OTel
`agent-traces.db` and JetBrains stores never do,
so on a typical machine most requests have no exact figure at all (#1199).
Everything unrated is estimated instead: the request's tokens priced at the
Expand All @@ -209,7 +219,7 @@ see the #927 ruling in `src/session-cache.ts`).
(the legacy premium-request weight) never touches the credit figure. So the
estimate's accuracy hinges on the source: on Copilot CLI events, which carry
the cache split, CodeBurn's token pricing landed within about 15% of the
exact bill; VS Code chat sessions record prompt and output tokens only, so
exact bill; older VS Code chat sessions record prompt and output tokens only, so
their estimate can land either side of the bill, and the numbers in #1199
show the gap can reach roughly 2x. A session that carries any exact figure
is never estimated on top of, because `total_nano_aiu` already bills that
Expand Down
11 changes: 8 additions & 3 deletions src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,11 @@ import type { DateRange, ProjectSummary } from './types.js'
// instead of dropped, Gemini 3.8 Flash placeholders get their own ids and
// Gemini 3 Flash -a/-d rows are priced. Calls and cost only rise, so no
// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
export const DAILY_CACHE_VERSION = 74
// v75: Copilot chat-session requests count every agent round and bill from
// copilotCredits, and a chat session that also ran on the agent host is left
// to it. That twin drops calls, so copilot joins
// PENDING_REDERIVE_PROVIDER_VERSIONS at 75.
export const DAILY_CACHE_VERSION = 75
const MIN_SUPPORTED_VERSION = 28

/// Providers whose per-day CALL COUNT means something different at
Expand All @@ -382,8 +386,9 @@ const MIN_SUPPORTED_VERSION = 28
/// untouched, in both directions, and every other provider keeps the guard.
const PENDING_REDERIVE_PROVIDER_VERSIONS: Readonly<Record<string, number>> = {
// 56: a store row now pairs with its tokenless per-turn twin, which can
// move one call across midnight.
copilot: 56,
// move one call across midnight. 75: chat sessions that also ran on the
// agent host are no longer counted from the journal.
copilot: 75,
// Codex response records replace stale/zero token_count twins and can
// legitimately reduce counts as well as recover missing usage.
codex: 43,
Expand Down
5 changes: 3 additions & 2 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1758,10 +1758,11 @@ function lookupShortName(id: string): string | undefined {
/// so `gpt-5` cannot capture `gpt-5.5` or `gpt-5.6-luna`, exactly as the
/// global table's `id === key || id.startsWith(key + '-')` cannot (#1530).
/// Providers match their local tables with this so one model id resolves to
/// one display name on every provider.
/// one display name on every provider. A one- or two-digit dash segment is a
/// minor version (`claude-sonnet-4-6`), not a suffix; dates are longer.
export function modelKeyMatches(model: string, key: string): boolean {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`(?<![\\w.-])${escaped}(?=$|-)`).test(model)
return new RegExp(`(?<![\\w.-])${escaped}(?=$|-(?!\\d{1,2}(?:$|[-.])))`).test(model)
}

// Reports re-derive the display name of every cached call; the answer depends
Expand Down
16 changes: 15 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4163,6 +4163,7 @@ export async function parseProviderSources(
storeProject: Map<string, string>
nanRollupFallbackTs: Map<string, string>
sessionEarliestValidTs: Map<string, string>
agentHostUsageSessions: Set<string>
} | null = null
if (providerName === 'copilot') {
// DEPENDS on the durable full-load exemption in session-cache.ts
Expand All @@ -4189,6 +4190,11 @@ export async function parseProviderSources(
// sealed days.
const nanRollupFallbackTs = new Map<string, string>()
const sessionEarliestValidTs = new Map<string, string>()
// Sessions the agent host bills (session-state rollups or turns, store
// rows). A VS Code chat-session journal of the same session is a second
// record of the same requests, so it is left out at serve time. A
// session-state file with no usage yet keeps the journal.
const agentHostUsageSessions = new Set<string>()
// Session-state per-turn calls carry no per-call project (the serve loop
// takes it from source.project), so resolve it the same way here — every
// call of the session must serve under the SAME label, whichever of the
Expand All @@ -4203,6 +4209,10 @@ export async function parseProviderSources(
seenAggKeys.add(c.deduplicationKey)
const ts = new Date(c.timestamp).getTime()
const isStore = c.deduplicationKey.startsWith('copilot-store:')
if ((isStore || basename(cachedPath) === 'events.jsonl') && (c.nanoAiu != null ||
c.usage.inputTokens + c.usage.outputTokens + c.usage.cacheReadInputTokens + c.usage.cacheCreationInputTokens > 0)) {
agentHostUsageSessions.add(turn.sessionId)
}
const isRollup = c.deduplicationKey.startsWith(shutdownPrefix)
const aggKey = `${turn.sessionId}\n${c.model}`
if (!Number.isNaN(ts)) {
Expand Down Expand Up @@ -4306,7 +4316,7 @@ export async function parseProviderSources(
}
}
}
copilotRecon = { storeKeys, storeCalls, rollupLegs, supplementaryStoreKeys, turnOwnedOutputKeys, sessionProject, storeProject, nanRollupFallbackTs, sessionEarliestValidTs }
copilotRecon = { storeKeys, storeCalls, rollupLegs, supplementaryStoreKeys, turnOwnedOutputKeys, sessionProject, storeProject, nanRollupFallbackTs, sessionEarliestValidTs, agentHostUsageSessions }
}
const copilotServeProject = (sessionId: string): string | undefined =>
copilotRecon
Expand All @@ -4318,6 +4328,10 @@ export async function parseProviderSources(
let changed = false
const kept: CachedCall[] = []
for (const c of turn.calls) {
if (c.deduplicationKey.startsWith('copilot-chatsession:') && copilotRecon.agentHostUsageSessions.has(turn.sessionId)) {
changed = true
continue
}
if (c.deduplicationKey.startsWith(shutdownPrefix)) {
const tsValid = !Number.isNaN(new Date(c.timestamp).getTime())
if (tsValid && copilotRecon.storeKeys.has(`${turn.sessionId}\n${c.model}`)) {
Expand Down
14 changes: 12 additions & 2 deletions src/plan-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,17 @@ export function copilotCreditSpend(projects: ProjectSummary[]): CopilotCreditSpe
// supplementary twin across a local-day boundary, so both nanoAiu rows can
// occupy separate turns of the same session. Close the twin once here.
forEachPlanSession(projects, calls => {
const copilot = calls.filter(call => call.provider === 'copilot')
// A VS Code chat-session request's copilotCredits bills that request
// alone, so a session that spans the Copilot Chat 0.69 upgrade keeps an
// estimate for its older requests. Agent-host figures bill the session.
const isChatRequest = (call: ParsedApiCall) => call.deduplicationKey.startsWith('copilot-chatsession:')
const chatRated = calls.filter(call => call.provider === 'copilot' && isChatRequest(call) && isFiniteNanoAiu(call.nanoAiu))
const chatUnrated = calls.filter(call => call.provider === 'copilot' && isChatRequest(call) && !isFiniteNanoAiu(call.nanoAiu))
for (const call of chatRated) nanoSum += call.nanoAiu!
for (const call of chatUnrated) estimatedUsd += call.costUSD
ratedCalls += behavioralCallCount(chatRated)
unratedCalls += behavioralCallCount(chatUnrated)
const copilot = calls.filter(call => call.provider === 'copilot' && !isChatRequest(call))
if (copilot.length === 0) return
const primaryNano = copilot.filter(call => !call.supplementaryAccounting && isFiniteNanoAiu(call.nanoAiu))
const suppNano = copilot.filter(call => call.supplementaryAccounting && isFiniteNanoAiu(call.nanoAiu))
Expand Down Expand Up @@ -161,7 +171,7 @@ export function copilotCreditsNote(rated: number, unrated: number): string {
if (unrated === 0) {
return `All ${rated} Copilot requests in this period carry GitHub's exact credit figure, so spentCredits is complete.`
}
return `${rated} of ${rated + unrated} Copilot requests carry GitHub's exact credit figure (only Copilot CLI and VS Code agent-host sessions record it). spentCredits counts only those; estimatedCredits adds the rest priced from tokens at listed API rates, which matched GitHub within about 15% on real Copilot CLI events; VS Code chat sessions record no cache split, so their estimate can land either side of the bill. percentUsed tracks estimatedCredits while any request is unrated.`
return `${rated} of ${rated + unrated} Copilot requests carry GitHub's exact credit figure (Copilot CLI, VS Code agent-host sessions and VS Code chat sessions from Copilot Chat 0.69 on record it). spentCredits counts only those; estimatedCredits adds the rest priced from tokens at listed API rates, which matched GitHub within about 15% on real Copilot CLI events; older VS Code chat sessions record no cache split and only the last model call's prompt, so their estimate can land either side of the bill. percentUsed tracks estimatedCredits while any request is unrated.`
}

export function projectMonthEnd(
Expand Down
Loading
Loading