diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index d2201b8bf..0a716043f 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -83,6 +83,12 @@ "sha": "abad48ddd3117056bbda12f2f7e87142d8c43369" } }, + { + "name": "ai-credits-breakdown", + "source": "plugins/ai-credits-breakdown", + "description": "See exactly where your own GitHub Copilot AI credits go: dollars by day, repository, model, call type and prompt-cache state, built privately on your computer from your Copilot telemetry.", + "version": "1.0.0" + }, { "name": "ai-ready", "description": "Analyze any repository and generate AI-ready configuration — AGENTS.md, copilot-instructions.md, CI workflows, issue templates, and more. Mines your PR review patterns and creates files customized to your stack.", diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 848417f22..8a815165a 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -30,6 +30,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [accessibility-kanban](../plugins/accessibility-kanban/README.md) | Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work. | 1 items | accessibility, github-issues, issue-triage, kanban-board, planning-workflow, status-tracking | | [acreadiness-cockpit](../plugins/acreadiness-cockpit/README.md) | Drive Microsoft AgentRC from Copilot chat: assess AI readiness, generate Copilot instructions (flat or nested with applyTo globs for monorepos), and manage policies. Produces a self-contained static HTML dashboard at reports/index.html. | 4 items | agentrc, ai-readiness, copilot-instructions, readiness-report, monorepo, policy, dashboard | | [agent-radar](../plugins/agent-radar/README.md) | See a live map of the Copilot agents on your computer, their sub-agents, their commands, and where their data goes. | 1 items | agents, canvas, copilot-extension, data-flow, mcp, observability, subagents, token-usage | +| [ai-credits-breakdown](../plugins/ai-credits-breakdown/README.md) | See exactly where your own GitHub Copilot AI credits go: dollars by day, repository, model, call type and prompt-cache state, built privately on your computer from your Copilot telemetry. | 1 items | ai-credits, canvas, copilot-extension, cost-tracking, token-usage, usage-analytics | | [ai-team-orchestration](../plugins/ai-team-orchestration/README.md) | Run a lightweight, role-separated AI development team with flexible tools, developer-selected models, proportionate planning, and optional QA. | 4 items | ai-team, multi-agent, sprint-planning, brainstorm, project-management, orchestration, developer-workflow | | [apng-studio](../plugins/apng-studio/README.md) | Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png. | 1 items | animated-png, apng, copilot-canvas, frame-animation, image-export, interactive-canvas | | [arcade-canvas](../plugins/arcade-canvas/README.md) | Play six retro Phaser mini-games in a Copilot canvas while agents work. | 1 items | arcade-games, copilot-canvas, interactive-canvas, phaser, retro-games, session-breaks | diff --git a/extensions/ai-credits-breakdown/LICENSE b/extensions/ai-credits-breakdown/LICENSE new file mode 100644 index 000000000..8544dab82 --- /dev/null +++ b/extensions/ai-credits-breakdown/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Roza Jiang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/ai-credits-breakdown/README.md b/extensions/ai-credits-breakdown/README.md new file mode 100644 index 000000000..09d73c61d --- /dev/null +++ b/extensions/ai-credits-breakdown/README.md @@ -0,0 +1,81 @@ +# AI Credits Breakdown + +AI Credits Breakdown is a canvas extension for the GitHub Copilot app. It gives you a detailed, private view of your **own** GitHub Copilot AI credit usage (1 AI credit = 1 AIU = $0.01), across every repository, organization and Copilot surface that leaves telemetry on your computer. + +![AI Credits Breakdown showing sample data](assets/preview.png) + +The screenshot shows fictional sample data. + +## What it shows + +One analytics page with a sticky filter bar. Every panel follows every filter. + +- **Filters:** period presets (this month to date, last 7/30/90 days, all time, any single month) or an exact from/to date range, plus repository, owner, model, surface, call type, basis and prompt cache. Each dropdown lists its options with their spend under the other filters. Download CSV exports the filtered rows. Filters are kept in the URL hash. +- **Headline:** dollars spent in the range, daily average, pace to month end, the previous comparable period and the busiest day. +- **Daily credits** as a hanging bar chart over a running-total curve. Drag across it to zoom the date range. +- **Spend by repository, model, owner, surface and call type.** Click any row to filter the whole page to it. +- **What credits pay for:** cache reads, cache writes, output and fresh input. +- **When it happens:** an hour-of-week heat map in your local time zone. +- **Prompt cache: reused or rebuilt.** Every metered model call by what it found in the prompt cache (reused, expired after a pause, rebuilt mid-conversation, or a new conversation), with spend, calls, cost per call and what the credits paid for. This shows how much goes to rewriting a conversation into the cache after a break. +- **Heaviest sessions,** and **spend by automation** (Copilot app automations) as a daily line graph. + +## Requirements + +- GitHub Copilot app with canvas support +- Node 22.13+ (uses `node:sqlite`) +- Copilot CLI or Copilot app usage on this computer. Without any, the panel tells you no usage was found yet. + +## Usage + +1. Install the plugin: + + ```bash + copilot plugin install ai-credits-breakdown@awesome-copilot + ``` + +2. In the Copilot app, open **Canvases**. +3. Open **AI Credits Breakdown**. The canvas takes an optional `period` (`mtd`, `7d`, `30d`, `90d` or `all`) to choose the first range. +4. Use **Refresh** to read your latest telemetry. + +## Canvas action + +The canvas has one action, `get_summary`, so you can ask Copilot questions such as "how much did I spend on Opus this month?". It takes an optional `from` and `to` (`YYYY-MM-DD`, UTC days) and returns totals in credits and USD, the top models, repositories, surfaces and call types, the prompt-cache states and the busiest days. It does not return session ids, prompts or titles. + +## How the numbers are built + +The extension reads, read-only, from `COPILOT_HOME` (default `~/.copilot`): + +| Source | Gives | +| --- | --- | +| `session-store.db` `assistant_usage_events` | Exact nano-AIU per model call, with model, call type, token counts and the per-token rates in effect | +| `session-state/*/events.jsonl` (`session.shutdown`) | Cumulative per-session, per-model credits and tokens (running maximum; repeats ignored) | +| `session-state/*/events.jsonl` (`session.usage_checkpoint`) | Prompt-cache lifetime per model | +| `data.db` (Copilot app) | Per-session totals, automation names and which sessions an automation ran, and each project's repository | + +Each session takes the largest total among the sources, and per-call rows win where they exist. Credits = `total_nano_aiu / 1e9`. Days and months are UTC (as GitHub bills); the hour-of-week grid uses your local time zone. + +A call counts as having **reused the cache** when its cache-read tokens are at least half of the previous call's prompt in the same conversation and model. Otherwise it is **expired after a pause** when the previous call went out longer ago than the model's cache lifetime (as recorded by the Copilot CLI, with a default of 5 minutes for Claude and 30 minutes for other models), and **rebuilt mid-conversation** when it was more recent or there was none on that model (a model switch, compaction or a changed prompt). The first call of a conversation is a **new conversation**. + +These are consumption figures at list per-token rates, not an invoice. Not captured: Copilot coding agent and code review sessions (they run in the cloud), IDE chat, and other computers. + +## Privacy and security + +- Everything is computed on your computer. The extension makes no network requests and sends nothing anywhere. +- Prompts, session titles, file paths and URLs are never read into the view. Session ids are shortened to 8 characters, and only repository names (`owner/name`), model ids and automation names are shown. +- The canvas serves its page from a loopback-only HTTP server (`127.0.0.1`). The page and its data are behind a random secret path that changes every time the canvas opens, and the server also checks the `Host` and `Origin` headers, so other local users and web pages cannot read your usage. +- The page loads no scripts or styles from other servers (its Content-Security-Policy allows only itself). +- The tool uses no paid services. + +## Development + +```bash +cd extensions/ai-credits-breakdown +node --test collector.test.mjs server.test.mjs +``` + +The tests use only fictional telemetry from `sample-data.mjs`, which writes a fake `COPILOT_HOME`. To try the ledger builder on it: + +```bash +node -e 'import("./sample-data.mjs").then((m) => m.writeSampleHome("/tmp/sample-copilot-home", { days: 220 }))' +COPILOT_HOME=/tmp/sample-copilot-home node collector.mjs # prints a one-line summary; add a path to also write the ledger as JSON +``` diff --git a/extensions/ai-credits-breakdown/assets/preview.png b/extensions/ai-credits-breakdown/assets/preview.png new file mode 100644 index 000000000..f87d4bf57 Binary files /dev/null and b/extensions/ai-credits-breakdown/assets/preview.png differ diff --git a/extensions/ai-credits-breakdown/collector.mjs b/extensions/ai-credits-breakdown/collector.mjs new file mode 100644 index 000000000..48fd41db1 --- /dev/null +++ b/extensions/ai-credits-breakdown/collector.mjs @@ -0,0 +1,760 @@ +// Builds a per-day / per-session / per-model ledger of GitHub Copilot AI credits +// from the telemetry the Copilot CLI and Copilot app already keep on this machine. +// +// Everything here is read-only and local. Sources (all optional): +// 1. /session-store.db assistant_usage_events per-call metered nano-AIU +// 2. /session-state/*/events.jsonl session.shutdown cumulative per-session metrics +// session.usage_checkpoint prompt-cache lifetimes +// 3. /data.db Copilot app per-session totals, automations, projects +// +// 1 AI credit = 1 AIU = $0.01. total_nano_aiu / 1e9 = credits. +// No prompts, session titles, file paths or URLs are read into the ledger. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +// node:sqlite needs Node 22.13+. It is loaded with process.getBuiltinModule (Node 22.3+) rather than a +// static import: Node 22's module.builtinModules omits "node:"-only modules, so the repository's canvas +// smoke test, which runs on Node 22, would report a static import of it as an undeclared dependency. +const sqlite = process.getBuiltinModule?.("node:sqlite"); +if (!sqlite) throw new Error("AI Credits Breakdown needs Node 22.13 or later (it reads your usage with node:sqlite)."); +const { DatabaseSync } = sqlite; + +export const CREDIT_USD = 0.01; +const EV_MIN_RESIDUAL = 1.0; // credits; ignore smaller disagreements between sources + +// $/1M tokens (input, cached input, cache write, output). Used only where per-call telemetry +// has not revealed a model's own rates, and to split session totals into what they paid for. +// Published table: https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing +const FALLBACK_RATES = { + "claude-opus-4.8": [5, 0.5, 6.25, 25], + "claude-opus-5": [5, 0.5, 6.25, 25], + "claude-opus-5.5": [4, 0.2, 5, 20], + "claude-sonnet-5": [2, 0.2, 2.5, 10], + "claude-sonnet-5.5": [2, 0.2, 2.5, 10], + "claude-sonnet-4.6": [3, 0.3, 3.75, 15], + "claude-haiku-4.5": [1, 0.1, 1.25, 5], + "gpt-5.3-codex": [1.75, 0.175, 0, 14], + "gpt-5.4": [2.5, 0.25, 0, 15], + "gpt-5.4-mini": [0.75, 0.075, 0, 4.5], + "gpt-5.5": [5, 0.5, 0, 30], + "gpt-5.6-sol": [4, 0.4, 5, 20], + "gpt-5.6-terra": [2, 0.2, 2.5, 12], + "gpt-5.6-luna": [0.2, 0.02, 0.25, 1.2], + "gpt-6-astra": [10, 1, 12.5, 50], + "gpt-6-sol": [2, 0.2, 2.5, 10], + "gpt-6.1-sol": [2, 0.1, 2.5, 10], + "mai-code-1.1-flash": [0.2, 0.02, 0, 1.2], + "kimi-k2.7-code": [3, 0.3, 0, 15], + // Not in the published table: priced like the nearest published sibling. + "claude-opus-4.6": [5, 0.5, 6.25, 25], + "claude-opus-4.6-1m": [5, 0.5, 6.25, 25], + "claude-opus-4.7": [5, 0.5, 6.25, 25], + "claude-opus-4.7-xhigh": [5, 0.5, 6.25, 25], + "gpt-5.2": [1.75, 0.175, 0, 14], + "gpt-5-mini": [0.25, 0.025, 0, 2], +}; +const ASSUMED_RATE_MODELS = [ + "claude-opus-4.6", "claude-opus-4.6-1m", "claude-opus-4.7", "claude-opus-4.7-xhigh", "gpt-5.2", +]; + +const SURFACE_BY_APP_TYPE = { + project: "Copilot app · project session", + workspace: "Copilot app · project session", + general_chat: "Copilot app · chat", + workflow: "Copilot app · automation", + workflow_workspace: "Copilot app · automation", + cli_session: "Copilot CLI", +}; +const KIND_BY_INITIATOR = { + user: "Your prompt", + agent: "Agent turn", + "sub-agent": "Sub-agent", + compaction: "Context compaction", +}; + +// What each per-call metered model call found in the prompt cache (see cacheStates()); display order. +export const CACHE_STATES = [ + ["warm", "Cache reused"], + ["expired", "Expired after a pause"], + ["rebuilt", "Rebuilt mid-conversation"], + ["new", "New conversation"], + ["none", "Not itemised"], +]; +const CACHE_IDX = Object.fromEntries(CACHE_STATES.map(([id], i) => [id, i])); +const CACHE_REUSE_MIN = 0.5; // share of the previous call's prompt that must come back as cache reads +// Prompt-cache lifetimes (seconds) used when the CLI has recorded none for a model's family. +const DEFAULT_CACHE_TTL = { claude: 300, other: 1800 }; +const TOKEN_TYPES = ["input", "cache_read", "cache_write", "output"]; + +export function copilotHome() { + return process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"); +} + +export function localTimeZone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +} + +export function family(model) { + const m = model.toLowerCase(); + if (m.includes("opus")) return "Claude Opus"; + if (m.includes("sonnet") || m.includes("haiku")) return "Claude Sonnet & Haiku"; + if (m.startsWith("gpt")) return "GPT"; + return "Other"; +} + +// Lookup in a table keyed by values read from local telemetry, which must never reach inherited +// properties such as "constructor". +const ownValue = (table, key) => (Object.hasOwn(table, key) ? table[key] : undefined); + +const PRETTY = { gpt: "GPT", mai: "MAI", kimi: "Kimi", claude: "Claude" }; +export function prettyModel(id) { + return id.replace("-1m", " (1M)").split("-").map((part) => { + const lower = part.toLowerCase(); + if (ownValue(PRETTY, lower)) return PRETTY[lower]; + if (/^\d/.test(part) || part.startsWith("(")) return part; + return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); + }).join(" "); +} + +const isoDay = (ts) => ts.slice(0, 10); +const round3 = (x) => Math.round(x * 1000) / 1000; +const zeros = () => [0, 0, 0, 0]; + +// Most frequent key of a Map; the first one seen wins a tie. +function mode(counter) { + let best, bestCount = -1; + for (const [key, count] of counter) { + if (count > bestCount) { best = key; bestCount = count; } + } + return best; +} +function bump(map, key, by = 1) { + map.set(key, (map.get(key) || 0) + by); +} +function nested(map, key, make) { + let value = map.get(key); + if (value === undefined) { value = make(); map.set(key, value); } + return value; +} + +// Weekday (Mon=0) and hour of a timestamp in a time zone. The UTC offset is looked up once per +// 15-minute bucket, because zone transitions never fall inside one. +export function createLocalClock(timeZone) { + const format = new Intl.DateTimeFormat("en-US", { + timeZone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", + hour: "numeric", minute: "numeric", second: "numeric", + }); + const offsets = new Map(); + const BUCKET = 15 * 60 * 1000; + const offsetFor = (ms) => { + const bucket = Math.floor(ms / BUCKET); + let offset = offsets.get(bucket); + if (offset === undefined) { + const start = bucket * BUCKET; + const p = Object.fromEntries(format.formatToParts(new Date(start)).map((x) => [x.type, Number(x.value)])); + offset = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - start; + offsets.set(bucket, offset); + } + return offset; + }; + return (ms) => { + const local = new Date(ms + offsetFor(ms)); + return { weekday: (local.getUTCDay() + 6) % 7, hour: local.getUTCHours() }; + }; +} + +function openReadOnly(file) { + if (!fs.existsSync(file)) return null; + try { + const db = new DatabaseSync(file, { readOnly: true }); + // The Copilot CLI and app may be writing right now; wait briefly rather than fail. + db.exec("PRAGMA busy_timeout = 3000"); + return db; + } catch { + return null; + } +} + +function tableColumns(db, table) { + try { + return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name)); + } catch { + return new Set(); + } +} + +// Per-call events grouped by session, and the per-token rates each model's calls revealed. +export function loadEvents(db) { + const bySession = new Map(); + const ratesSeen = new Map(); + const have = tableColumns(db, "assistant_usage_events"); + const required = ["session_id", "model", "total_nano_aiu", "created_at"]; + if (required.some((column) => !have.has(column))) return { bySession, ratesSeen }; + const wanted = ["session_id", "model", "input_tokens", "output_tokens", "cache_read_tokens", "cache_write_tokens", + "total_nano_aiu", "initiator", "token_details_json", "created_at", "id", "agent_id", "duration_ms"]; + const select = wanted.map((column) => (have.has(column) ? column : `NULL AS ${column}`)).join(", "); + for (const row of db.prepare(`SELECT ${select} FROM assistant_usage_events`).iterate()) { + const timestamp = String(row.created_at ?? "").trim().replace(" ", "T"); + const startMs = Date.parse(/[zZ]$|[+-]\d{2}:\d{2}$/.test(timestamp) ? timestamp : `${timestamp}Z`); + if (!row.session_id || !row.model || Number.isNaN(startMs)) continue; + row.created_at = new Date(startMs).toISOString(); + const comps = { input: 0, cache_read: 0, cache_write: 0, output: 0 }; + const tok = { input: 0, cache_read: 0, cache_write: 0, output: 0 }; + let itemised = false; + try { + for (const t of JSON.parse(row.token_details_json || "[]")) { + if (!Object.hasOwn(comps, t.tokenType)) continue; + const rate = t.costPerBatch / t.batchSize / 1e9; // credits per token + if (!Number.isFinite(rate)) continue; + itemised = true; + comps[t.tokenType] += t.tokenCount * rate; + tok[t.tokenType] += t.tokenCount; + if (t.tokenCount > 0) { + const byType = nested(ratesSeen, row.model, () => new Map()); + bump(nested(byType, t.tokenType, () => new Map()), Math.round(rate * 1e10) / 1e4); + } + } + } catch { /* malformed token details: keep the call's credits without a split */ } + // The token details are what the credits are priced from, so they win. Without them (absent, + // empty or malformed) the call's own counters still give its token counts; input_tokens includes + // the cached tokens, so fresh input is what is left after them. + if (!itemised) { + tok.cache_read = row.cache_read_tokens ?? 0; + tok.cache_write = row.cache_write_tokens ?? 0; + tok.output = row.output_tokens ?? 0; + tok.input = row.input_tokens == null ? 0 : Math.max(0, row.input_tokens - tok.cache_read - tok.cache_write); + } + nested(bySession, row.session_id, () => []).push({ + model: row.model, + credits: (row.total_nano_aiu || 0) / 1e9, + comps, tok, + kind: ownValue(KIND_BY_INITIATOR, row.initiator || "") || "Background", + ts: row.created_at, + // created_at is when the call finished; the request went out duration_ms earlier. + start: startMs / 1000 - (row.duration_ms || 0) / 1000, + id: row.id, + // The main agent, each sub-agent and the background calls hold separate conversations. + stream: `${row.agent_id || ""}\u0000${row.initiator ? "main" : "background"}`, + prompt: row.input_tokens ?? tok.input + tok.cache_read + tok.cache_write, + cached: row.cache_read_tokens ?? tok.cache_read, + }); + } + return { bySession, ratesSeen }; +} + +// Prompt-cache lifetime in seconds per model: the CLI's own record, else what it recorded for the +// model's provider family (Claude or the others), else that family's default. +export function cacheTtlFor(recorded) { + const ttl = new Map([...recorded].map(([model, counter]) => [model, mode(counter)])); + const group = (model) => (model.startsWith("claude") ? "claude" : "other"); + const summary = {}; + for (const g of Object.keys(DEFAULT_CACHE_TTL)) { + const seen = new Map(); + for (const [model, seconds] of ttl) if (group(model) === g) bump(seen, seconds); + summary[g] = seen.size ? mode(seen) : DEFAULT_CACHE_TTL[g]; + } + return { ttlFor: (model) => ttl.get(model) || summary[group(model)], summary }; +} + +// Tag every per-call event with what it found in the prompt cache (event.cs, an index into CACHE_STATES). +// Calls are taken in request order within a conversation. A call reused the cache when at least half of +// the previous call's prompt (same conversation and model) came back as cache reads. Otherwise the prompt +// was written to the cache again: "expired" when that previous call went out longer ago than the model's +// cache lifetime, "rebuilt" when it was recent or there was none on this model (a model switch, +// compaction or a changed prompt). The first call of a conversation is "new". +export function cacheStates(bySession, ttlFor) { + for (const events of bySession.values()) { + const seen = new Set(); + const last = new Map(); + const ordered = [...events].sort((a, b) => a.start - b.start || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + for (const e of ordered) { + const key = `${e.stream}\u0001${e.model}`; + const prev = last.get(key); + let state; + if (!seen.has(e.stream)) state = "new"; + else if (prev && e.cached >= CACHE_REUSE_MIN * prev.prompt) state = "warm"; + else if (prev && e.start - prev.start >= ttlFor(e.model)) state = "expired"; + else state = "rebuilt"; + e.cs = CACHE_IDX[state]; + seen.add(e.stream); + last.set(key, { start: e.start, prompt: e.prompt }); + } + } +} + +// Credits per 1M tokens (input, cache_read, cache_write, output): the modal rate each model's calls +// revealed, else the published table. +export function buildRates(ratesSeen) { + const rates = new Map(); + for (const [model, byType] of ratesSeen) { + const fallback = ownValue(FALLBACK_RATES, model); + rates.set(model, TOKEN_TYPES.map((type, i) => { + const counter = byType.get(type); + if (counter) return mode(counter); + return fallback ? fallback[i] * 100 : 0; + })); + } + for (const [model, fallback] of Object.entries(FALLBACK_RATES)) { + if (!rates.has(model)) rates.set(model, fallback.map((x) => x * 100)); + } + return rates; +} + +function price(model, tok, rates) { + const r = rates.get(model); + if (!r) return null; + return { input: tok.input * r[0] / 1e6, cache_read: tok.cache_read * r[1] / 1e6, cache_write: tok.cache_write * r[2] / 1e6, output: tok.output * r[3] / 1e6 }; +} + +// Scan one events.jsonl buffer for lines that start with a given JSON prefix, in file order. +function* linesStartingWith(buffer, needle) { + let pos = 0; + while ((pos = buffer.indexOf(needle, pos)) !== -1) { + if (pos === 0 || buffer[pos - 1] === 10) { + let end = buffer.indexOf(10, pos); + if (end === -1) end = buffer.length; + yield buffer.toString("utf8", pos, end); + pos = end; + } else { + pos += needle.length; + } + } +} +const SHUTDOWN_LINE = Buffer.from('{"type":"session.shutdown"'); +const CHECKPOINT_LINE = Buffer.from('{"type":"session.usage_checkpoint"'); + +// What one transcript contributes: the prompt-cache lifetimes its usage checkpoints recorded, and its +// shutdown records as [timestamp, totalCredits, {model: {credits, tok, calls}}] in file order. +function parseTranscript(buffer) { + const ttls = []; + for (const line of linesStartingWith(buffer, CHECKPOINT_LINE)) { + try { + for (const state of JSON.parse(line).data.modelCacheState || []) { + if (state.modelId && state.cacheTtlSeconds) ttls.push([state.modelId, Math.trunc(Number(state.cacheTtlSeconds))]); + } + } catch { /* skip malformed checkpoint */ } + } + const seq = []; + for (const line of linesStartingWith(buffer, SHUTDOWN_LINE)) { + let event; + try { event = JSON.parse(line); } catch { continue; } + const d = event.data || {}; + const models = Object.create(null); + for (const [model, mm] of Object.entries(d.modelMetrics || {})) { + const td = mm.tokenDetails || {}; + const u = mm.usage || {}; + const tok = { + input: td.input?.tokenCount ?? 0, + cache_read: td.cache_read?.tokenCount ?? u.cacheReadTokens ?? 0, + cache_write: td.cache_write?.tokenCount ?? u.cacheWriteTokens ?? 0, + output: td.output?.tokenCount ?? u.outputTokens ?? 0, + }; + if (Object.keys(td).length === 0) { + tok.input = Math.max(0, (u.inputTokens || 0) - tok.cache_read - tok.cache_write); + } + models[model] = { credits: (mm.totalNanoAiu || 0) / 1e9, tok, calls: mm.requests?.count ?? 0 }; + } + seq.push([event.timestamp, (d.totalNanoAiu || 0) / 1e9, models]); + } + return { ttls, seq }; +} + +// Transcripts only grow, and most are finished long ago, so what was parsed from each file is kept by +// its identity (inode, size and modification time) and only changed files are read again. Entries for +// files that have gone are dropped on the next load. `reads` counts file reads, for the tests. +const transcriptCache = new Map(); // events.jsonl path -> { key, parsed } +export const transcriptStats = { reads: 0 }; + +// Per session: the shutdown records of its transcript, in file order. Also the prompt-cache lifetimes +// the CLI recorded: Map>. Files are read asynchronously and the loop yields +// regularly, so a large history does not block the extension's other work. +export async function loadShutdowns(home) { + const out = new Map(); + const cacheTtls = new Map(); + const root = path.join(home, "session-state"); + let dirs = []; + try { dirs = await fs.promises.readdir(root); } catch { return { shutdowns: out, cacheTtls }; } + const seen = new Set(); + let processed = 0; + for (const dir of dirs) { + if (++processed % 64 === 0) await new Promise((resolve) => setImmediate(resolve)); + const file = path.join(root, dir, "events.jsonl"); + let parsed; + try { + const stat = await fs.promises.stat(file, { bigint: true }); + if (!stat.isFile()) continue; + const key = `${stat.ino}:${stat.size}:${stat.mtimeNs}`; + const cached = transcriptCache.get(file); + if (cached?.key === key) { + parsed = cached.parsed; + } else { + transcriptStats.reads++; + parsed = parseTranscript(await fs.promises.readFile(file)); + // The key is taken before the read, so a file that grew meanwhile is read again next time. + transcriptCache.set(file, { key, parsed }); + } + } catch { continue; } + seen.add(file); + for (const [model, seconds] of parsed.ttls) bump(nested(cacheTtls, model, () => new Map()), seconds); + if (parsed.seq.length) out.set(dir, parsed.seq); + } + for (const file of transcriptCache.keys()) { + if (file.startsWith(root + path.sep) && !seen.has(file)) transcriptCache.delete(file); + } + return { shutdowns: out, cacheTtls }; +} + +// Dated increments from cumulative shutdown metrics. +// Shutdown totals are cumulative across resumes; repeats are ignored and a lower value (seen in some +// older CLI builds) never subtracts: the running maximum is what counts. This matched the Copilot app's +// own per-session totals to within 4% on the sessions where both exist (a per-segment reading overshot +// by about 30%). Returns [[timestamp, credits, {model: {credits, tok, calls}}]]. +export function shutdownIncrements(seq) { + const out = []; + let prevTotal = 0; + const best = new Map(); + for (const [ts, total, models] of seq) { + if (total <= prevTotal + 1e-9) continue; + const delta = Object.create(null); + for (const [model, cur] of Object.entries(models)) { + const base = best.get(model); + const dc = cur.credits - (base ? base.credits : 0); + if (dc <= 1e-9) continue; + delta[model] = { + credits: dc, + tok: Object.fromEntries(TOKEN_TYPES.map((k) => [k, Math.max(0, cur.tok[k] - (base ? base.tok[k] : 0))])), + calls: Math.max(0, cur.calls - (base ? base.calls : 0)), + }; + } + const sd = total - prevTotal; + const msum = Object.values(delta).reduce((a, v) => a + v.credits, 0); + if (msum > 0) { + const scale = sd / msum; + for (const v of Object.values(delta)) v.credits *= scale; + } + out.push([ts, sd, delta]); + prevTotal = total; + for (const [model, cur] of Object.entries(models)) { + const b = best.get(model); + if (!b || cur.credits > b.credits) best.set(model, cur); + } + } + return out; +} + +// Automation names and run-session links only; never read automation prompts or session titles. +export function loadAutomations(db) { + const names = new Map(); + for (const row of db.prepare("SELECT id, name FROM workflows").all()) { + names.set(row.id, row.name || "Unnamed automation"); + } + const bySession = new Map(); + const runs = db.prepare( + "SELECT r.task_id AS task_id, COALESCE(NULLIF(r.session_id, ''), w.session_id) AS session_id " + + "FROM workflow_runs r LEFT JOIN workspaces w ON w.id = r.workspace_id").all(); + for (const { task_id: id, session_id: sid } of runs) { + if (!names.has(id)) names.set(id, `Deleted automation (${id.slice(0, 8)})`); + if (!sid) continue; + if (bySession.has(sid) && bySession.get(sid) !== id) { + throw new Error("An automation run session is linked to more than one automation"); + } + bySession.set(sid, id); + } + return { names, bySession }; +} + +function loadAppSessions(db) { + const app = new Map(); + const have = tableColumns(db, "sessions"); + if (!["id", "session_type", "created_at", "updated_at", "total_nano_aiu"].every((c) => have.has(c))) return app; + const col = (name) => (have.has(name) ? name : `NULL AS ${name}`); + const rows = db.prepare(`SELECT id, session_type, ${col("model")}, created_at, updated_at, total_nano_aiu FROM sessions`).all(); + for (const r of rows) { + app.set(r.id, { + type: r.session_type, model: r.model || "", created: r.created_at, updated: r.updated_at, + credits: (r.total_nano_aiu || 0) / 1e9, + }); + } + return app; +} + +function loadProjectRepos(db) { + const repos = new Map(); + try { + const rows = db.prepare( + "SELECT w.session_id AS session_id, p.github_owner AS owner, p.github_repo AS repo " + + "FROM workspaces w JOIN projects p ON p.id = w.project_id WHERE w.session_id IS NOT NULL").all(); + for (const r of rows) if (r.owner && r.repo) repos.set(r.session_id, `${r.owner}/${r.repo}`); + } catch { /* older Copilot app schema */ } + return repos; +} + +const emptyBreakdown = () => ({ credits: 0, comps: zeros(), tok: zeros(), calls: 0 }); + +// Sources that count the same usage disagree a little, and that is forgiven, but only against usage +// that is already counted. A total with nothing counted yet is real usage however small it is. +const exceedsCounted = (extra, best, counted) => extra > (counted > 0 ? Math.max(EV_MIN_RESIDUAL, 0.02 * best) : 1e-9); + +// Build the whole ledger. Returns a compact, column-oriented object that the dashboard filters in the browser. +export async function buildLedger({ home = copilotHome(), timeZone = localTimeZone(), now = new Date() } = {}) { + const store = openReadOnly(path.join(home, "session-store.db")); + const appDb = openReadOnly(path.join(home, "data.db")); + try { + let automationNames = new Map(); + let automationBySession = new Map(); + let automationsUnavailable = null; + if (appDb) { + try { + ({ names: automationNames, bySession: automationBySession } = loadAutomations(appDb)); + } catch (error) { + automationsUnavailable = error.message; + automationNames = new Map(); + automationBySession = new Map(); + } + } + + const { bySession: eventsBySession, ratesSeen } = store ? loadEvents(store) : { bySession: new Map(), ratesSeen: new Map() }; + const rates = buildRates(ratesSeen); + const { shutdowns, cacheTtls } = await loadShutdowns(home); + const { ttlFor, summary: cacheTtl } = cacheTtlFor(cacheTtls); + cacheStates(eventsBySession, ttlFor); + + // Session metadata ---------------------------------------------------------------------- + const meta = new Map(); + const newMeta = (extra = {}) => ({ repo: "", created: null, updated: null, surface: "Copilot CLI", ...extra }); + if (store && tableColumns(store, "sessions").has("repository")) { + for (const r of store.prepare("SELECT id, repository, created_at, updated_at FROM sessions").all()) { + meta.set(r.id, newMeta({ repo: r.repository || "", created: r.created_at, updated: r.updated_at })); + } + } + const app = appDb ? loadAppSessions(appDb) : new Map(); + const projectRepo = appDb ? loadProjectRepos(appDb) : new Map(); + for (const [sid, a] of app) { + const m = nested(meta, sid, () => newMeta({ created: a.created, updated: a.updated })); + m.surface = ownValue(SURFACE_BY_APP_TYPE, a.type) || "Copilot app · project session"; + if (!m.repo && projectRepo.has(sid)) m.repo = projectRepo.get(sid); + if (!m.created) { m.created = a.created; m.updated = a.updated; } + } + + // Fact rows: [day, session, model, kind, tier, credits, comps[4], tok[4], calls, cache state] + // tier 0 = metered per call, 1 = metered per session total. + const facts = []; + const hourly = new Map(); // day|session|model|kind|cache|weekday|hour -> credits + const sourcesTotal = { events: 0, shutdown: 0, app: 0 }; + const tierTotal = [0, 0]; + const clock = createLocalClock(timeZone); + + const add = (day, sid, model, kind, tier, credits, comps, tok, calls, cs = CACHE_IDX.none) => { + if (credits <= 0) return; + facts.push([day, sid, model, kind, tier, credits, comps, tok, calls, cs]); + tierTotal[tier] += credits; + }; + + const allIds = new Set([...eventsBySession.keys(), ...shutdowns.keys()]); + for (const [sid, a] of app) if (a.credits > 0) allIds.add(sid); + + for (const sid of [...allIds].sort()) { + const evs = eventsBySession.get(sid) || []; + const m = nested(meta, sid, () => newMeta()); + if (!m.created) { + const first = evs.length ? evs[0].ts : (shutdowns.has(sid) ? shutdowns.get(sid)[0][0] : null); + m.created = m.updated = first; + } + const evTotal = evs.reduce((a, e) => a + e.credits, 0); + const incs = shutdowns.has(sid) ? shutdownIncrements(shutdowns.get(sid)) : []; + const shTotal = incs.reduce((a, i) => a + i[1], 0); + const appTotal = app.has(sid) ? app.get(sid).credits : 0; + sourcesTotal.events += evTotal; + sourcesTotal.shutdown += shTotal; + sourcesTotal.app += appTotal; + const best = Math.max(evTotal, shTotal, appTotal); + const createdDay = (m.created || "").slice(0, 10) || (m.updated || "").slice(0, 10); + if (!createdDay) continue; + const appModel = app.get(sid)?.model || "unknown"; + + // (1) per-call events: exact day, model, call type, cache state + const agg = new Map(); + for (const e of evs) { + const day = isoDay(e.ts); + const key = [day, e.model, e.kind, e.cs].join("\u0000"); + const a = nested(agg, key, () => ({ day, model: e.model, kind: e.kind, cs: e.cs, ...emptyBreakdown() })); + a.credits += e.credits; + a.calls += 1; + TOKEN_TYPES.forEach((type, i) => { a.comps[i] += e.comps[type]; a.tok[i] += e.tok[type]; }); + const local = clock(Date.parse(e.ts)); + bump(hourly, [day, sid, e.model, e.kind, e.cs, local.weekday, local.hour].join("\u0000"), e.credits); + } + for (const a of agg.values()) add(a.day, sid, a.model, a.kind, 0, a.credits, a.comps, a.tok, a.calls, a.cs); + + // (2) residual not covered by per-call events + const residual = best - evTotal; + if (!exceedsCounted(residual, best, evTotal)) continue; + if (evs.length) { + // Per-call events start where the CLI began recording them; anything older is dated at session creation. + const evModels = new Map(); + for (const e of evs) bump(evModels, e.model, e.credits); + const resModels = new Map(); + if (incs.length) { + const final = new Map(); + for (const [, , byModel] of incs) { + for (const [model, v] of Object.entries(byModel)) { + nested(final, model, () => ({ credits: 0 })).credits += v.credits; + } + } + for (const [model, v] of final) { + const extra = v.credits - (evModels.get(model) || 0); + if (extra > 0) resModels.set(model, extra); + } + } + if (resModels.size) { + const tot = [...resModels.values()].reduce((a, v) => a + v, 0); + for (const [model, extra] of resModels) { + add(createdDay, sid, model, "Earlier usage", 1, residual * extra / tot, zeros(), zeros(), 0); + } + } else { + add(createdDay, sid, appModel, "Earlier usage", 1, residual, zeros(), zeros(), 0); + } + } else { + let placed = 0; + for (const [ts, sd, byModel] of incs) { + for (const [model, v] of Object.entries(byModel)) { + const tok = TOKEN_TYPES.map((t) => v.tok[t]); + const est = price(model, v.tok, rates); + let comps = est ? TOKEN_TYPES.map((t) => est[t]) : zeros(); + const esum = comps.reduce((a, c) => a + c, 0); + if (esum > 0) comps = comps.map((c) => c * v.credits / esum); + add(isoDay(ts), sid, model, "Session total", 1, v.credits, comps, tok, v.calls); + placed += v.credits; + } + if (!Object.keys(byModel).length) { + add(isoDay(ts), sid, appModel, "Session total", 1, sd, zeros(), zeros(), 0); + placed += sd; + } + } + const gap = best - placed; + if (exceedsCounted(gap, best, placed)) { + add(createdDay, sid, appModel, "Session total", 1, gap, zeros(), zeros(), 0); + } + } + } + + // Dimensions ----------------------------------------------------------------------------- + const models = [...new Set(facts.map((f) => f[2]))].sort(); + const kinds = [...new Set(facts.map((f) => f[3]))].sort(); + const surfaces = [...new Set(facts.map((f) => meta.get(f[1]).surface))].sort(); + const repos = [...new Set(facts.map((f) => meta.get(f[1]).repo))].sort(); + const sessionIds = [...new Set(facts.map((f) => f[1]))].sort(); + for (const sid of sessionIds) { + if (!automationBySession.has(sid) && meta.get(sid).surface === "Copilot app · automation") { + automationBySession.set(sid, "unattributed"); + automationNames.set("unattributed", "Unattributed automation sessions"); + } + } + const automationIds = [...automationNames.keys()].sort(); + const index = (list) => new Map(list.map((v, i) => [v, i])); + const ai = index(automationIds), mi = index(models), ki = index(kinds), si = index(surfaces); + const ri = index(repos), sx = index(sessionIds); + const days = [...new Set(facts.map((f) => f[0]))].sort(); + const di = index(days); + + const outFacts = facts.map(([day, sid, model, kind, tier, credits, comps, tok, calls, cs]) => + [di.get(day), sx.get(sid), mi.get(model), ki.get(kind), tier, round3(credits), ...comps.map(round3), ...tok, calls, cs]); + const sessions = sessionIds.map((sid) => ({ + id: sid.slice(0, 8), + repo: ri.get(meta.get(sid).repo), + surface: si.get(meta.get(sid).surface), + automation: automationBySession.has(sid) ? ai.get(automationBySession.get(sid)) ?? null : null, + })); + const hourlyRows = []; + for (const [key, credits] of hourly) { + const [day, sid, model, kind, cs, weekday, hour] = key.split("\u0000"); + if (credits > 0 && di.has(day) && sx.has(sid)) { + hourlyRows.push([di.get(day), sx.get(sid), mi.get(model), ki.get(kind), Number(weekday), Number(hour), round3(credits), Number(cs)]); + } + } + let eventsStart = null; + for (const evs of eventsBySession.values()) for (const e of evs) if (!eventsStart || e.ts < eventsStart) eventsStart = e.ts; + + return { + meta: { + generated: now.toISOString(), + timeZone, + creditUsd: CREDIT_USD, + total: round3(facts.reduce((a, f) => a + f[5], 0)), + periodStart: days[0] ?? null, + periodEnd: days[days.length - 1] ?? null, + tierTotals: { 0: round3(tierTotal[0]), 1: round3(tierTotal[1]) }, + sourceTotals: Object.fromEntries(Object.entries(sourcesTotal).map(([k, v]) => [k, round3(v)])), + assumedRateModels: ASSUMED_RATE_MODELS.filter((m) => models.includes(m)), + eventsStart, + cacheTtl, + cacheReuseMin: CACHE_REUSE_MIN, + automationsUnavailable, + sources: { sessionStore: Boolean(store), appDatabase: Boolean(appDb), sessionLogs: shutdowns.size }, + }, + dims: { + models: models.map((id) => ({ id, name: prettyModel(id), family: family(id) })), + kinds, surfaces, repos, days, + caches: CACHE_STATES.map(([id, name]) => ({ id, name })), + automations: automationIds.map((id) => ({ id, name: automationNames.get(id) })), + }, + sessions, + factColumns: ["day", "session", "model", "kind", "tier", "credits", "c_input", "c_cache_read", "c_cache_write", + "c_output", "t_input", "t_cache_read", "t_cache_write", "t_output", "calls", "cache"], + facts: outFacts, + hourlyColumns: ["day", "session", "model", "kind", "weekday", "hour", "credits", "cache"], + hourly: hourlyRows, + }; + } finally { + store?.close(); + appDb?.close(); + } +} + +// A compact, privacy-light summary of a ledger for the agent: totals and top entries, no session ids. +export function summarizeLedger(ledger, { from, to } = {}) { + const { dims, facts } = ledger; + const start = from || ledger.meta.periodStart; + const end = to || ledger.meta.periodEnd; + const rows = facts.filter((f) => { + const day = dims.days[f[0]]; + return day >= start && day <= end; + }); + const group = (pick) => { + const totals = new Map(); + for (const f of rows) bump(totals, pick(f), f[5]); + return [...totals].sort((a, b) => b[1] - a[1]); + }; + const total = rows.reduce((a, f) => a + f[5], 0); + const top = (entries, name, limit = 10) => entries.slice(0, limit).map(([key, credits]) => ({ + [name]: key, credits: round3(credits), usd: round3(credits * CREDIT_USD), share: total ? round3(credits / total) : 0, + })); + const byDay = group((f) => dims.days[f[0]]); + const calls = new Map(); + for (const f of rows) bump(calls, f[15], f[14]); + return { + period: { from: start, to: end, basis: "UTC days" }, + totalCredits: round3(total), + totalUsd: round3(total * CREDIT_USD), + activeDays: byDay.length, + sessions: new Set(rows.map((f) => f[1])).size, + models: top(group((f) => dims.models[f[2]].name), "model"), + repositories: top(group((f) => dims.repos[ledger.sessions[f[1]].repo] || "(no repository)"), "repository"), + surfaces: top(group((f) => dims.surfaces[ledger.sessions[f[1]].surface]), "surface"), + callTypes: top(group((f) => dims.kinds[f[3]]), "callType"), + promptCache: group((f) => f[15]).map(([i, credits]) => ({ + state: dims.caches[i].name, credits: round3(credits), usd: round3(credits * CREDIT_USD), calls: calls.get(i) || 0, + })), + busiestDays: top(byDay, "day", 5), + generated: ledger.meta.generated, + }; +} + +// `node collector.mjs [out.json]` prints a one-line summary, and writes the full ledger when given a path. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const ledger = await buildLedger(); + if (process.argv[2]) fs.writeFileSync(process.argv[2], JSON.stringify(ledger)); + console.log(`${ledger.facts.length} fact rows, ${ledger.sessions.length} sessions, ${ledger.meta.total} credits, ${ledger.meta.periodStart ?? "no data"} to ${ledger.meta.periodEnd ?? "no data"}`); +} diff --git a/extensions/ai-credits-breakdown/collector.test.mjs b/extensions/ai-credits-breakdown/collector.test.mjs new file mode 100644 index 000000000..ab44274d9 --- /dev/null +++ b/extensions/ai-credits-breakdown/collector.test.mjs @@ -0,0 +1,466 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + CACHE_STATES, buildLedger, buildRates, cacheStates, cacheTtlFor, createLocalClock, family, + loadAutomations, prettyModel, shutdownIncrements, summarizeLedger, transcriptStats, +} from "./collector.mjs"; +import { SENTINELS, writeSampleHome } from "./sample-data.mjs"; + +const state = (e) => CACHE_STATES[e.cs][0]; +const call = (id, startSeconds, prompt, cached, extra = {}) => ({ + id, model: "claude-sonnet-5.5", stream: "main", start: startSeconds, prompt, cached, ...extra, +}); + +function tempHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ai-credits-")); + return { home, cleanup: () => fs.rmSync(home, { recursive: true, force: true }) }; +} + +// --------------------------------------------------------------------------------------------- +// Automation attribution (names and run-session links only) +// --------------------------------------------------------------------------------------------- +function automationDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE workflows (id TEXT, name TEXT, prompt TEXT); + CREATE TABLE workflow_runs (task_id TEXT, session_id TEXT, workspace_id TEXT); + CREATE TABLE workspaces (id TEXT, session_id TEXT); + INSERT INTO workflows VALUES ('daily', 'Daily report', 'PRIVATE PROMPT'); + INSERT INTO workflows VALUES ('idle', 'Not run yet', 'PRIVATE PROMPT'); + INSERT INTO workspaces VALUES ('workspace', 'workspace-session');`); + return db; +} + +test("includes every automation without reading prompts", () => { + const db = automationDb(); + const { names, bySession } = loadAutomations(db); + assert.deepEqual(Object.fromEntries(names), { daily: "Daily report", idle: "Not run yet" }); + assert.equal(bySession.size, 0); +}); + +test("repeated runs are attributed once per session", () => { + const db = automationDb(); + db.exec(`INSERT INTO workflow_runs VALUES ('daily', 'run-1', NULL); + INSERT INTO workflow_runs VALUES ('daily', 'run-1', NULL); + INSERT INTO workflow_runs VALUES ('daily', 'run-2', NULL);`); + assert.deepEqual(Object.fromEntries(loadAutomations(db).bySession), { "run-1": "daily", "run-2": "daily" }); +}); + +test("a workspace is only a fallback for the run's own session", () => { + const db = automationDb(); + db.exec(`INSERT INTO workflow_runs VALUES ('daily', NULL, 'workspace'); + INSERT INTO workflow_runs VALUES ('daily', '', 'workspace'); + INSERT INTO workflow_runs VALUES ('daily', 'explicit-session', 'workspace'); + INSERT INTO workflow_runs VALUES ('daily', NULL, NULL);`); + assert.deepEqual(Object.fromEntries(loadAutomations(db).bySession), { "workspace-session": "daily", "explicit-session": "daily" }); +}); + +test("keeps a deleted automation without guessing a title", () => { + const db = automationDb(); + db.exec("INSERT INTO workflow_runs VALUES ('deleted-1234', 'old-session', NULL)"); + const { names, bySession } = loadAutomations(db); + assert.equal(names.get("deleted-1234"), "Deleted automation (deleted-)"); + assert.equal(bySession.get("old-session"), "deleted-1234"); +}); + +test("conflicting links fail instead of misattributing spend", () => { + const db = automationDb(); + db.exec(`INSERT INTO workflow_runs VALUES ('daily', 'shared-session', NULL); + INSERT INTO workflow_runs VALUES ('idle', 'shared-session', NULL);`); + assert.throws(() => loadAutomations(db), /more than one automation/); +}); + +// --------------------------------------------------------------------------------------------- +// Session-level totals +// --------------------------------------------------------------------------------------------- +const tok = (input, cache_read = 0, cache_write = 0, output = 0) => ({ input, cache_read, cache_write, output }); + +test("shutdown totals are cumulative: repeats are ignored and a lower value never subtracts", () => { + const m = (credits, t, calls) => ({ "claude-sonnet-5.5": { credits, tok: t, calls } }); + const seq = [ + ["2026-10-01T01:00:00Z", 10, m(10, tok(100, 1000), 5)], + ["2026-10-01T02:00:00Z", 10, m(10, tok(100, 1000), 5)], // repeat + ["2026-10-02T01:00:00Z", 25, m(25, tok(250, 2500), 12)], + ["2026-10-03T01:00:00Z", 20, m(20, tok(200, 2000), 9)], // dips: ignored + ["2026-10-04T01:00:00Z", 30, m(30, tok(300, 3000), 15)], + ]; + const incs = shutdownIncrements(seq); + assert.deepEqual(incs.map((i) => i[0]), ["2026-10-01T01:00:00Z", "2026-10-02T01:00:00Z", "2026-10-04T01:00:00Z"]); + assert.deepEqual(incs.map((i) => i[1]), [10, 15, 5]); + assert.equal(incs.reduce((a, i) => a + i[1], 0), 30); + assert.equal(incs[1][2]["claude-sonnet-5.5"].calls, 7); +}); + +test("per-model credits are scaled to the session increment", () => { + const seq = [["2026-10-01T01:00:00Z", 10, { + a: { credits: 6, tok: tok(1), calls: 1 }, b: { credits: 6, tok: tok(1), calls: 1 }, + }]]; + const [, total, byModel] = shutdownIncrements(seq)[0]; + assert.equal(total, 10); + assert.equal(byModel.a.credits + byModel.b.credits, 10); +}); + +// --------------------------------------------------------------------------------------------- +// Prompt cache +// --------------------------------------------------------------------------------------------- +test("classifies every call by what it found in the prompt cache", () => { + const ttl = () => 300; + const events = [ + call("1", 0, 20000, 0), // first call of the conversation + call("2", 10, 21000, 20000), // read the previous prompt back + call("3", 20, 22000, 21000), // read it back again + call("4", 1000, 23000, 0), // long pause, whole prompt written again + call("5", 1010, 40000, 1000), // recent, but most of the prompt was rewritten + call("6", 1020, 5000, 0, { stream: "sub-1" }), // a sub-agent is a separate conversation + ]; + cacheStates(new Map([["s", events]]), ttl); + assert.deepEqual(events.map(state), ["new", "warm", "warm", "expired", "rebuilt", "new"]); +}); + +test("the same model is tracked per conversation, and a model switch is rebuilt", () => { + const events = [ + call("1", 0, 20000, 0), + call("2", 10, 21000, 0, { model: "gpt-5.6-sol" }), + ]; + cacheStates(new Map([["s", events]]), () => 300); + assert.deepEqual(events.map(state), ["new", "rebuilt"]); +}); + +test("cache lifetimes: the CLI's record, then its family, then a default", () => { + const { ttlFor, summary } = cacheTtlFor(new Map([ + ["claude-sonnet-5.5", new Map([[300, 5]])], + ["gpt-5.6-sol", new Map([[3600, 2], [1800, 1]])], + ])); + assert.equal(ttlFor("claude-sonnet-5.5"), 300); + assert.equal(ttlFor("claude-opus-5.5"), 300); + assert.equal(ttlFor("gpt-5.6-sol"), 3600); + assert.equal(ttlFor("mai-code-1.1-flash"), 3600); + assert.deepEqual(summary, { claude: 300, other: 3600 }); + assert.deepEqual(cacheTtlFor(new Map()).summary, { claude: 300, other: 1800 }); +}); + +test("rates come from what calls revealed, else the published table", () => { + const rates = buildRates(new Map([["claude-sonnet-5.5", new Map([["input", new Map([[123.4, 9]])]])]])); + assert.equal(rates.get("claude-sonnet-5.5")[0], 123.4); + assert.equal(rates.get("claude-sonnet-5.5")[3], 1000); + assert.equal(rates.get("gpt-5.4")[0], 250); + assert.equal(rates.has("no-such-model"), false); +}); + +// --------------------------------------------------------------------------------------------- +// Names and time +// --------------------------------------------------------------------------------------------- +test("model names and families", () => { + assert.equal(prettyModel("claude-sonnet-5.5"), "Claude Sonnet 5.5"); + assert.equal(prettyModel("gpt-5.4-mini"), "GPT 5.4 Mini"); + assert.equal(prettyModel("claude-opus-4.6-1m"), "Claude Opus 4.6 (1M)"); + assert.equal(family("claude-opus-5"), "Claude Opus"); + assert.equal(family("claude-haiku-4.5"), "Claude Sonnet & Haiku"); + assert.equal(family("gpt-6-sol"), "GPT"); + assert.equal(family("kimi-k2.7-code"), "Other"); +}); + +test("weekday (Mon=0) and hour follow the local time zone, including daylight saving", () => { + const utc = createLocalClock("UTC"); + assert.deepEqual(utc(Date.parse("2026-10-28T12:34:00Z")), { weekday: 2, hour: 12 }); + const sydney = createLocalClock("Australia/Sydney"); + assert.deepEqual(sydney(Date.parse("2026-10-28T12:00:00Z")), { weekday: 2, hour: 23 }); // AEDT, UTC+11 + assert.deepEqual(sydney(Date.parse("2026-10-28T13:00:00Z")), { weekday: 3, hour: 0 }); + assert.deepEqual(sydney(Date.parse("2026-06-10T12:00:00Z")), { weekday: 2, hour: 22 }); // AEST, UTC+10 + const newYork = createLocalClock("America/New_York"); + assert.deepEqual(newYork(Date.parse("2026-07-01T03:00:00Z")), { weekday: 1, hour: 23 }); // Tuesday 23:00 EDT, UTC-4 +}); + +// --------------------------------------------------------------------------------------------- +// Whole ledger, built from fictional telemetry +// --------------------------------------------------------------------------------------------- +test("builds a consistent ledger from local telemetry", async () => { + const { home, cleanup } = tempHome(); + try { + const sample = writeSampleHome(home); + const ledger = await buildLedger({ home, timeZone: "UTC", now: new Date("2026-10-28T12:00:00Z") }); + const { dims, meta } = ledger; + + assert.ok(ledger.facts.length > 100); + assert.equal(ledger.facts[0].length, ledger.factColumns.length); + assert.equal(ledger.hourly[0].length, ledger.hourlyColumns.length); + assert.equal(meta.timeZone, "UTC"); + assert.equal(meta.generated, "2026-10-28T12:00:00.000Z"); + + // Per-call credits are exactly what the fictional telemetry recorded. + assert.ok(Math.abs(meta.tierTotals[0] - sample.eventCredits) < 1); + assert.ok(meta.tierTotals[1] > 0, "older sessions are placed from their shutdown records"); + const total = ledger.facts.reduce((a, f) => a + f[5], 0); + assert.ok(Math.abs(total - meta.total) < 1); + assert.ok(Math.abs(meta.tierTotals[0] + meta.tierTotals[1] - meta.total) < 1); + + // Every dimension index resolves. + for (const f of ledger.facts) { + assert.ok(dims.days[f[0]] && ledger.sessions[f[1]] && dims.models[f[2]] && dims.kinds[f[3]] && dims.caches[f[15]]); + } + assert.deepEqual(dims.caches.map((c) => c.id), CACHE_STATES.map(([id]) => id)); + assert.deepEqual(dims.automations.map((a) => a.name).sort(), ["Daily standup summary", "Docs link audit", "Weekly dependency check"]); + assert.ok(dims.surfaces.includes("Copilot app · automation")); + assert.ok(ledger.sessions.some((s) => s.automation !== null)); + assert.ok(dims.repos.includes("contoso/storefront")); + + // The per-call prompt-cache states all show up in the sample, and cost components add up. + const cacheCredits = Object.fromEntries(dims.caches.map((c) => [c.id, 0])); + for (const f of ledger.facts) cacheCredits[dims.caches[f[15]].id] += f[5]; + for (const id of ["warm", "expired", "rebuilt", "new"]) assert.ok(cacheCredits[id] > 0, id); + for (const f of ledger.facts.filter((row) => row[4] === 0)) { + assert.ok(Math.abs(f[6] + f[7] + f[8] + f[9] - f[5]) < 0.05, "per-call components add up to the call credits"); + } + assert.ok(meta.cacheTtl.claude === 300 && meta.cacheTtl.other === 1800); + } finally { + cleanup(); + } +}); + +test("never reads prompts, titles or file paths into the ledger", async () => { + const { home, cleanup } = tempHome(); + try { + writeSampleHome(home); + const ledger = await buildLedger({ home, timeZone: "UTC" }); + const text = JSON.stringify(ledger); + for (const secret of SENTINELS) assert.equal(text.includes(secret), false, secret); + assert.ok(ledger.sessions.every((s) => s.id.length <= 8), "session ids are shortened"); + } finally { + cleanup(); + } +}); + +test("summaries are privacy-light and consistent with the ledger", async () => { + const { home, cleanup } = tempHome(); + try { + writeSampleHome(home); + const ledger = await buildLedger({ home, timeZone: "UTC" }); + const all = summarizeLedger(ledger); + assert.ok(Math.abs(all.totalCredits - ledger.meta.total) < 1); + assert.equal(all.totalUsd, Math.round(all.totalCredits * 10) / 1000); + assert.ok(all.models.length > 0 && all.repositories.length > 0 && all.busiestDays.length === 5); + assert.deepEqual(all.models.map((m) => m.credits), [...all.models.map((m) => m.credits)].sort((a, b) => b - a)); + assert.equal(JSON.stringify(all).includes(ledger.sessions[0].id), false, "no session ids"); + + const oneDay = ledger.dims.days[ledger.dims.days.length - 1]; + const day = summarizeLedger(ledger, { from: oneDay, to: oneDay }); + assert.equal(day.period.from, oneDay); + assert.ok(day.totalCredits > 0 && day.totalCredits < all.totalCredits); + } finally { + cleanup(); + } +}); + +test("an empty or missing Copilot home gives an empty ledger instead of an error", async () => { + const { home, cleanup } = tempHome(); + try { + const ledger = await buildLedger({ home: path.join(home, "nothing-here"), timeZone: "UTC" }); + assert.equal(ledger.facts.length, 0); + assert.equal(ledger.meta.periodStart, null); + assert.equal(ledger.meta.total, 0); + assert.deepEqual(ledger.meta.sources, { sessionStore: false, appDatabase: false, sessionLogs: 0 }); + assert.equal(summarizeLedger(ledger).totalCredits, 0); + } finally { + cleanup(); + } +}); + +test("works without the Copilot app database and degrades on conflicting automation links", async () => { + const { home, cleanup } = tempHome(); + try { + writeSampleHome(home); + const appPath = path.join(home, "data.db"); + const app = new DatabaseSync(appPath); + const [session] = app.prepare("SELECT session_id FROM workflow_runs LIMIT 1").all().map((r) => r.session_id); + app.prepare("INSERT INTO workflow_runs VALUES ('conflict', 'wf-deps', NULL, ?)").run(session); + app.prepare("INSERT INTO workflow_runs VALUES ('conflict2', 'wf-docs', NULL, ?)").run(session); + app.close(); + const degraded = await buildLedger({ home, timeZone: "UTC" }); + assert.match(degraded.meta.automationsUnavailable, /more than one automation/); + assert.ok(degraded.facts.length > 0); + + fs.rmSync(appPath); + const noApp = await buildLedger({ home, timeZone: "UTC" }); + assert.equal(noApp.meta.sources.appDatabase, false); + assert.ok(noApp.facts.length > 0); + assert.equal(noApp.dims.automations.length, 0); + } finally { + cleanup(); + } +}); + +test("tolerates an older per-call schema and malformed log lines", async () => { + const { home, cleanup } = tempHome(); + try { + fs.mkdirSync(path.join(home, "session-state", "old-1"), { recursive: true }); + const store = new DatabaseSync(path.join(home, "session-store.db")); + store.exec(` + CREATE TABLE sessions (id TEXT, repository TEXT, created_at TEXT, updated_at TEXT); + CREATE TABLE assistant_usage_events (id INTEGER, session_id TEXT, model TEXT, total_nano_aiu INTEGER, created_at TEXT); + INSERT INTO sessions VALUES ('old-1', 'contoso/app', '2026-10-01T10:00:00Z', '2026-10-01T11:00:00Z'); + INSERT INTO assistant_usage_events VALUES (1, 'old-1', 'claude-sonnet-5.5', 5000000000, '2026-10-01T10:05:00Z'); + INSERT INTO assistant_usage_events VALUES (2, 'old-1', 'claude-sonnet-5.5', 1000000000, 'not a date');`); + store.close(); + fs.writeFileSync(path.join(home, "session-state", "old-1", "events.jsonl"), + '{"type":"session.shutdown","data":{broken\n{"type":"session.usage_checkpoint","data":{"modelCacheState":null}}\n'); + const ledger = await buildLedger({ home, timeZone: "UTC" }); + assert.equal(ledger.meta.total, 5); + assert.equal(ledger.facts.length, 1); + assert.equal(ledger.dims.repos[0], "contoso/app"); + } finally { + cleanup(); + } +}); + +// --------------------------------------------------------------------------------------------- +// Small hand-made telemetry homes +// --------------------------------------------------------------------------------------------- +const EVENT_COLUMNS = "id INTEGER, session_id TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, " + + "cache_read_tokens INTEGER, cache_write_tokens INTEGER, total_nano_aiu INTEGER, initiator TEXT, " + + "token_details_json TEXT, created_at TEXT, agent_id TEXT, duration_ms INTEGER"; +const details = (input, cacheRead, cacheWrite, output) => JSON.stringify([ + ["input", input], ["cache_read", cacheRead], ["cache_write", cacheWrite], ["output", output], +].map(([tokenType, tokenCount]) => ({ tokenType, tokenCount, batchSize: 1000000, costPerBatch: 1e9 }))); + +// events: [session, model, created_at, nanoAiu, columns?, detailsJson?, initiator?] +function eventsHome(home, events) { + const store = new DatabaseSync(path.join(home, "session-store.db")); + store.exec(`CREATE TABLE sessions (id TEXT, repository TEXT, created_at TEXT, updated_at TEXT); + CREATE TABLE assistant_usage_events (${EVENT_COLUMNS});`); + const insert = store.prepare("INSERT INTO assistant_usage_events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + events.forEach(([sid, model, createdAt, nano, c = {}, json = null, initiator = "user"], i) => { + insert.run(i + 1, sid, model, c.input ?? null, c.output ?? null, c.cacheRead ?? null, c.cacheWrite ?? null, + nano, initiator, json, createdAt, "", 1000); + }); + store.close(); +} +function shutdownLine(timestamp, credits, model = "claude-sonnet-5.5") { + return JSON.stringify({ + type: "session.shutdown", timestamp, + data: { totalNanoAiu: credits * 1e9, modelMetrics: { [model]: { totalNanoAiu: credits * 1e9, requests: { count: 3 }, + tokenDetails: { input: { tokenCount: 100 }, output: { tokenCount: 10 } } } } }, + }); +} +function writeTranscript(home, sid, lines) { + fs.mkdirSync(path.join(home, "session-state", sid), { recursive: true }); + fs.writeFileSync(path.join(home, "session-state", sid, "events.jsonl"), lines.join("\n") + "\n"); +} +const sumColumn = (ledger, column) => ledger.facts.reduce((a, f) => a + f[column], 0); + +test("unzoned SQLite timestamps are UTC whatever the machine's time zone is", async () => { + const { home, cleanup } = tempHome(); + const before = process.env.TZ; + try { + eventsHome(home, [["s1", "claude-sonnet-5.5", "2026-10-28 12:34:00", 2e9]]); + process.env.TZ = "America/New_York"; // Date.parse() reads an unzoned time in this zone + const ledger = await buildLedger({ home, timeZone: "UTC" }); + assert.equal(ledger.dims.days[ledger.facts[0][0]], "2026-10-28"); + assert.equal(ledger.hourly.length, 1); + assert.deepEqual(ledger.hourly[0].slice(4, 6), [2, 12], "Wednesday 12:00 UTC, not 16:00"); + assert.equal(ledger.meta.eventsStart, "2026-10-28T12:34:00.000Z"); + } finally { + if (before === undefined) delete process.env.TZ; else process.env.TZ = before; + cleanup(); + } +}); + +test("token counts fall back to the call's own counters only when it has no usable token details", async () => { + const { home, cleanup } = tempHome(); + try { + const counters = { input: 1000, cacheRead: 600, cacheWrite: 100, output: 50 }; + eventsHome(home, [ + ["none", "claude-sonnet-5.5", "2026-10-01T10:00:00Z", 1e9, counters, null], + ["bad", "claude-sonnet-5.5", "2026-10-02T10:00:00Z", 1e9, counters, "not json"], + ["empty", "claude-sonnet-5.5", "2026-10-03T10:00:00Z", 1e9, counters, "[]"], + // The billed details win when they disagree with the counters. + ["billed", "claude-sonnet-5.5", "2026-10-04T10:00:00Z", 1e9, counters, details(250, 600, 450, 50)], + ]); + const ledger = await buildLedger({ home, timeZone: "UTC" }); + const bySession = Object.fromEntries(ledger.facts.map((f) => [ledger.dims.days[f[0]], f.slice(10, 14)])); + assert.deepEqual(bySession["2026-10-01"], [300, 600, 100, 50], "fresh input = input minus cached tokens"); + assert.deepEqual(bySession["2026-10-02"], [300, 600, 100, 50]); + assert.deepEqual(bySession["2026-10-03"], [300, 600, 100, 50]); + assert.deepEqual(bySession["2026-10-04"], [250, 600, 450, 50]); + assert.ok(ledger.facts.every((f) => f[6] + f[7] + f[8] + f[9] <= f[5] + 1e-6)); + } finally { + cleanup(); + } +}); + +test("a session whose whole total is one credit or less is still counted", async () => { + const { home, cleanup } = tempHome(); + try { + writeTranscript(home, "shutdown-only", [shutdownLine("2026-10-05T10:00:00Z", 0.4)]); + fs.mkdirSync(home, { recursive: true }); + const app = new DatabaseSync(path.join(home, "data.db")); + app.exec(`CREATE TABLE sessions (id TEXT, title TEXT, session_type TEXT, model TEXT, created_at TEXT, updated_at TEXT, total_nano_aiu INTEGER); + INSERT INTO sessions VALUES ('app-only', NULL, 'project', 'claude-sonnet-5.5', '2026-10-06T10:00:00Z', '2026-10-06T11:00:00Z', 300000000);`); + app.close(); + const ledger = await buildLedger({ home, timeZone: "UTC" }); + assert.equal(ledger.sessions.length, 2); + assert.ok(Math.abs(ledger.meta.total - 0.7) < 1e-6, String(ledger.meta.total)); + assert.deepEqual(ledger.dims.kinds.sort(), ["Session total"]); + } finally { + cleanup(); + } +}); + +test("the tolerance still forgives small disagreements against usage already counted", async () => { + const { home, cleanup } = tempHome(); + try { + eventsHome(home, [["s1", "claude-sonnet-5.5", "2026-10-01T10:00:00Z", 100e9]]); + writeTranscript(home, "s1", [shutdownLine("2026-10-01T11:00:00Z", 100.6)]); + const ledger = await buildLedger({ home, timeZone: "UTC" }); + assert.ok(Math.abs(ledger.meta.total - 100) < 1e-6, "0.6 credits of disagreement is not added on top"); + assert.equal(ledger.meta.tierTotals[1], 0); + } finally { + cleanup(); + } +}); + +test("session logs are read once while they are unchanged, and again when they change or go", async () => { + const { home, cleanup } = tempHome(); + try { + writeTranscript(home, "a", [shutdownLine("2026-10-01T10:00:00Z", 5)]); + writeTranscript(home, "b", [shutdownLine("2026-10-02T10:00:00Z", 7)]); + const reads = () => transcriptStats.reads; + const start = reads(); + assert.equal((await buildLedger({ home, timeZone: "UTC" })).meta.total, 12); + assert.equal(reads() - start, 2); + assert.equal((await buildLedger({ home, timeZone: "UTC" })).meta.total, 12); + assert.equal(reads() - start, 2, "unchanged files are not read again"); + + fs.appendFileSync(path.join(home, "session-state", "a", "events.jsonl"), shutdownLine("2026-10-03T10:00:00Z", 9) + "\n"); + assert.equal((await buildLedger({ home, timeZone: "UTC" })).meta.total, 16, "the grown log is read again"); + assert.equal(reads() - start, 3); + + fs.rmSync(path.join(home, "session-state", "b"), { recursive: true }); + assert.equal((await buildLedger({ home, timeZone: "UTC" })).meta.total, 9, "a removed log no longer counts"); + assert.equal(reads() - start, 3); + } finally { + cleanup(); + } +}); + +test("values that look like inherited object properties are treated as ordinary names", async () => { + const { home, cleanup } = tempHome(); + try { + eventsHome(home, [["s1", "constructor", "2026-10-01T10:00:00Z", 3e9, {}, details(10, 0, 0, 5), "constructor"]]); + const app = new DatabaseSync(path.join(home, "data.db")); + app.exec(`CREATE TABLE sessions (id TEXT, title TEXT, session_type TEXT, model TEXT, created_at TEXT, updated_at TEXT, total_nano_aiu INTEGER); + INSERT INTO sessions VALUES ('s1', NULL, 'toString', 'constructor', '2026-10-01T10:00:00Z', '2026-10-01T11:00:00Z', 3000000000);`); + app.close(); + const ledger = await buildLedger({ home, timeZone: "UTC" }); + assert.equal(ledger.meta.total, 3); + assert.deepEqual(ledger.dims.models, [{ id: "constructor", name: "Constructor", family: "Other" }]); + assert.deepEqual(ledger.dims.kinds, ["Background"]); + assert.deepEqual(ledger.dims.surfaces, ["Copilot app · project session"]); + assert.ok(Number.isFinite(sumColumn(ledger, 5)) && Number.isFinite(sumColumn(ledger, 6))); + } finally { + cleanup(); + } +}); diff --git a/extensions/ai-credits-breakdown/copilot-extension.json b/extensions/ai-credits-breakdown/copilot-extension.json new file mode 100644 index 000000000..d784da6b0 --- /dev/null +++ b/extensions/ai-credits-breakdown/copilot-extension.json @@ -0,0 +1 @@ +{ "name": "ai-credits-breakdown", "version": 1 } diff --git a/extensions/ai-credits-breakdown/extension.mjs b/extensions/ai-credits-breakdown/extension.mjs new file mode 100644 index 000000000..7fb35ccdd --- /dev/null +++ b/extensions/ai-credits-breakdown/extension.mjs @@ -0,0 +1,73 @@ +// Extension: ai-credits-breakdown +// A detailed, private view of your own GitHub Copilot AI credit usage, built on this computer +// from the telemetry the Copilot CLI and Copilot app already keep in ~/.copilot. +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { buildLedger, summarizeLedger } from "./collector.mjs"; +import { startServer, stopServer } from "./server.mjs"; + +const PERIODS = ["mtd", "7d", "30d", "90d", "all"]; +const LEDGER_TTL_MS = 3_000; + +// Concurrent requests share one build, and a build is reused for a few seconds (for example when the +// agent asks for a summary right after the panel loads). Anything older is read afresh. +let ledger = null, ledgerAt = 0, building = null; +function getLedger() { + if (ledger && Date.now() - ledgerAt < LEDGER_TTL_MS) return Promise.resolve(ledger); + building ||= buildLedger().then((built) => { + ledger = built; + ledgerAt = Date.now(); + return built; + }).finally(() => { building = null; }); + return building; +} + +const servers = new Map(); // instanceId -> { server, url } +const day = { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }; + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "ai-credits-breakdown", + displayName: "AI Credits Breakdown", + description: "A detailed view of your own GitHub Copilot AI credit usage: dollars by day, repository, model, surface, call type and prompt-cache state, built locally from your Copilot telemetry.", + inputSchema: { + type: "object", + properties: { period: { type: "string", enum: PERIODS, description: "Initial period: this month to date (default), last 7/30/90 days, or all time." } }, + }, + actions: [ + { + name: "get_summary", + description: "Summarize the user's own AI credit usage for a UTC day range: totals in credits and USD, top models, repositories, surfaces, call types, prompt-cache states and busiest days. Defaults to all recorded usage. Session ids, prompts and titles are not included.", + inputSchema: { type: "object", properties: { from: day, to: day } }, + handler: async (ctx) => { + try { + const { from, to } = ctx.input || {}; + return summarizeLedger(await getLedger(), { from, to }); + } catch (error) { + throw new CanvasError("usage_unavailable", error.message); + } + }, + }, + ], + open: async (ctx) => { + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer({ + getLedger, + onError: (error) => session?.log(`ai-credits-breakdown could not read usage: ${error.message}`, { level: "warning", ephemeral: true }), + }); + servers.set(ctx.instanceId, entry); + } + const period = ctx.input?.period; + const url = PERIODS.includes(period) && period !== "mtd" ? `${entry.url}#p=${period}` : entry.url; + return { title: "AI Credits Breakdown", url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) return; + servers.delete(ctx.instanceId); + await stopServer(entry); + }, + }), + ], +}); diff --git a/extensions/ai-credits-breakdown/sample-data.mjs b/extensions/ai-credits-breakdown/sample-data.mjs new file mode 100644 index 000000000..129d4616d --- /dev/null +++ b/extensions/ai-credits-breakdown/sample-data.mjs @@ -0,0 +1,199 @@ +// Deterministic, entirely fictional Copilot telemetry for the tests and the preview screenshot. +// writeSampleHome() lays out a fake COPILOT_HOME with the same files and tables the collector reads. +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +// Credits per 1M tokens: input, cache read, cache write, output. +const MODELS = { + "claude-sonnet-5.5": { rates: [200, 20, 250, 1000], weight: 5, ttl: 300 }, + "claude-opus-5.5": { rates: [400, 20, 500, 2000], weight: 2, ttl: 300 }, + "gpt-5.6-sol": { rates: [400, 40, 500, 2000], weight: 2, ttl: 1800 }, + "gpt-5.6-luna": { rates: [20, 2, 25, 120], weight: 1, ttl: 1800 }, + "claude-haiku-4.5": { rates: [100, 10, 125, 500], weight: 1, ttl: 300 }, +}; +const TYPES = ["input", "cache_read", "cache_write", "output"]; +const REPOS = [ + "contoso/storefront", "contoso/payments-api", "contoso/design-system", "fabrikam/data-pipeline", + "fabrikam/mobile-app", "sample-user/dotfiles", "sample-user/notes", "", +]; +const AUTOMATIONS = [ + ["wf-daily", "Daily standup summary"], ["wf-deps", "Weekly dependency check"], ["wf-docs", "Docs link audit"], +]; +export const SENTINELS = ["SECRET-PROMPT-TEXT", "SECRET-SESSION-TITLE", "/Users/someone/secret/path"]; + +function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function writeSampleHome(home, { seed = 7, end = new Date("2026-10-28T12:00:00Z"), days = 100, oldSessions = 8 } = {}) { + const rand = rng(seed); + const between = (lo, hi) => lo + rand() * (hi - lo); + const int = (lo, hi) => Math.floor(between(lo, hi + 1)); + const pick = (list) => list[Math.floor(rand() * list.length)]; + const weighted = () => { + const total = Object.values(MODELS).reduce((a, m) => a + m.weight, 0); + let x = rand() * total; + for (const [id, m] of Object.entries(MODELS)) if ((x -= m.weight) < 0) return id; + return "claude-sonnet-5.5"; + }; + const uuid = () => [8, 4, 4, 4, 12].map((n) => Array.from({ length: n }, () => int(0, 15).toString(16)).join("")).join("-"); + const iso = (ms) => new Date(ms).toISOString(); + + fs.mkdirSync(path.join(home, "session-state"), { recursive: true }); + const store = new DatabaseSync(path.join(home, "session-store.db")); + const app = new DatabaseSync(path.join(home, "data.db")); + store.exec(` + CREATE TABLE sessions (id TEXT PRIMARY KEY, repository TEXT, summary TEXT, created_at TEXT, updated_at TEXT); + CREATE TABLE assistant_usage_events (id INTEGER PRIMARY KEY, session_id TEXT, model TEXT, input_tokens INTEGER, + output_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER, total_nano_aiu INTEGER, + initiator TEXT, token_details_json TEXT, created_at TEXT, agent_id TEXT, duration_ms INTEGER);`); + app.exec(` + CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT, github_owner TEXT, github_repo TEXT); + CREATE TABLE workspaces (id TEXT PRIMARY KEY, project_id TEXT, session_id TEXT); + CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT, session_type TEXT, model TEXT, created_at TEXT, + updated_at TEXT, total_nano_aiu INTEGER); + CREATE TABLE workflows (id TEXT PRIMARY KEY, name TEXT, prompt TEXT); + CREATE TABLE workflow_runs (id TEXT PRIMARY KEY, task_id TEXT, workspace_id TEXT, session_id TEXT);`); + + const insertSession = store.prepare("INSERT INTO sessions VALUES (?, ?, ?, ?, ?)"); + const insertEvent = store.prepare("INSERT INTO assistant_usage_events (session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_nano_aiu, initiator, token_details_json, created_at, agent_id, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + const insertApp = app.prepare("INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?)"); + const insertProject = app.prepare("INSERT INTO projects VALUES (?, ?, ?, ?)"); + const insertWorkspace = app.prepare("INSERT INTO workspaces VALUES (?, ?, ?)"); + const insertRun = app.prepare("INSERT INTO workflow_runs VALUES (?, ?, ?, ?)"); + for (const [id, name] of AUTOMATIONS) app.prepare("INSERT INTO workflows VALUES (?, ?, ?)").run(id, name, SENTINELS[0]); + store.exec("BEGIN"); + app.exec("BEGIN"); + REPOS.filter(Boolean).forEach((repo, i) => { + const [owner, name] = repo.split("/"); + insertProject.run(`project-${i}`, name, owner, name); + }); + + const cost = (model, tok) => TYPES.reduce((sum, t, i) => sum + tok[t] * MODELS[model].rates[i] / 1e6, 0); + const details = (model, tok) => JSON.stringify(TYPES.filter((t) => tok[t] > 0).map((t) => ({ + tokenType: t, tokenCount: tok[t], batchSize: 1000000, costPerBatch: MODELS[model].rates[TYPES.indexOf(t)] * 1e9, + }))); + + let eventCredits = 0; + const eventsStart = end.getTime() - days * 864e5; + const sessionsMeta = []; + + // One session of metered per-call events across a main agent, a sub-agent and background calls. + const generateSession = (sid, model, startMs) => { + let when = startMs; + let credits = 0; + const streams = [{ agent: "", prompt: int(18000, 30000), calls: int(15, 70) }]; + if (rand() < 0.5) streams.push({ agent: "sub-1", prompt: int(8000, 14000), calls: int(4, 14) }); + for (const stream of streams) { + let t = when + (stream.agent ? int(20, 120) * 1000 : 0); + let prevPrompt = 0; + for (let n = 0; n < stream.calls; n++) { + const duration = int(3000, 40000); + let initiator = n === 0 ? "user" : "agent"; + if (stream.agent) initiator = "sub-agent"; + else if (n > 0 && rand() < 0.06) initiator = "compaction"; + else if (n > 0 && rand() < 0.05) initiator = "user"; + // A pause longer than the cache lifetime means the next call writes the whole prompt again. + const paused = n > 0 && rand() < 0.08; + const gap = paused ? int(MODELS[model].ttl + 30, MODELS[model].ttl + 900) * 1000 : int(500, 15000); + if (n > 0) t += gap; + const delta = int(800, 9000); + const fresh = int(150, 1800); + const first = n === 0; + const prompt = first ? stream.prompt : initiator === "compaction" ? Math.round(stream.prompt * 0.4) : prevPrompt + delta; + const rebuilt = initiator === "compaction"; + const tok = { input: fresh, cache_read: 0, cache_write: 0, output: int(150, 4200) }; + if (first || paused || rebuilt) tok.cache_write = Math.max(0, prompt - fresh); + else { tok.cache_read = prevPrompt; tok.cache_write = Math.max(0, prompt - fresh - prevPrompt); } + const finish = t + duration; + const nano = Math.round(cost(model, tok) * 1e9); + insertEvent.run(sid, model, tok.input + tok.cache_read + tok.cache_write, tok.output, tok.cache_read, tok.cache_write, + nano, initiator, details(model, tok), iso(finish), stream.agent, duration); + credits += nano / 1e9; + prevPrompt = prompt; + t = finish; + } + } + // Title generation and similar background calls carry no initiator. + const tok = { input: int(300, 900), cache_read: 0, cache_write: 0, output: int(20, 80) }; + const nano = Math.round(cost("claude-haiku-4.5", tok) * 1e9); + insertEvent.run(sid, "claude-haiku-4.5", tok.input, tok.output, 0, 0, nano, null, details("claude-haiku-4.5", tok), iso(startMs + 2000), "", 900); + credits += nano / 1e9; + return credits; + }; + + // Metered per-call sessions, a few busy days among many quiet ones. + for (let d = 0; d < days; d++) { + const dayMs = eventsStart + d * 864e5; + const weekday = new Date(dayMs).getUTCDay(); + if ((weekday === 0 || weekday === 6) && rand() < 0.7) continue; + const busy = rand() < 0.12 ? 3 : 1; + const count = int(1, 3) * busy; + for (let s = 0; s < count; s++) { + const sid = uuid(); + const repo = pick(REPOS); + const model = weighted(); + const startMs = dayMs + int(9, 17) * 36e5 + int(0, 59) * 6e4; + const automationIndex = rand() < 0.18 ? int(0, AUTOMATIONS.length - 1) : -1; + const total = generateSession(sid, model, startMs); + eventCredits += total; + insertSession.run(sid, repo, SENTINELS[1], iso(startMs), iso(startMs + 36e5)); + const type = automationIndex >= 0 ? "workflow_workspace" : repo && rand() < 0.5 ? "workspace" : "cli_session"; + if (type !== "cli_session") { + insertApp.run(sid, SENTINELS[1], type, model, iso(startMs), iso(startMs + 36e5), Math.round(total * 1e9)); + if (type === "workspace" && repo) insertWorkspace.run(`ws-${sid}`, `project-${REPOS.filter(Boolean).indexOf(repo)}`, sid); + } + if (automationIndex >= 0) insertRun.run(uuid(), AUTOMATIONS[automationIndex][0], `ws-${sid}`, sid); + sessionsMeta.push({ sid, repo, startMs }); + } + } + + // Older sessions from before per-call telemetry: only shutdown records and Copilot app totals. + for (let i = 0; i < oldSessions; i++) { + const sid = uuid(); + const model = weighted(); + const startMs = eventsStart - int(1, 40) * 864e5 + int(9, 17) * 36e5; + const repo = pick(REPOS); + const dir = path.join(home, "session-state", sid); + fs.mkdirSync(dir, { recursive: true }); + const segments = [int(30, 200), int(10, 120)]; + let tokens = { input: 0, cache_read: 0, cache_write: 0, output: 0 }; + let requests = 0; + const lines = [JSON.stringify({ type: "session.start", data: { title: SENTINELS[1], cwd: SENTINELS[2] } })]; + segments.forEach((calls, n) => { + tokens = { input: tokens.input + calls * 400, cache_read: tokens.cache_read + calls * 60000, cache_write: tokens.cache_write + calls * 3000, output: tokens.output + calls * 900 }; + requests += calls; + const nano = Math.round(cost(model, tokens) * 1e9); + lines.push(JSON.stringify({ + type: "session.shutdown", timestamp: iso(startMs + (n + 1) * 36e5), + data: { + totalNanoAiu: nano, + modelMetrics: { + [model]: { + totalNanoAiu: nano, requests: { count: requests }, + tokenDetails: Object.fromEntries(TYPES.map((t) => [t, { tokenCount: tokens[t] }])), + }, + }, + codeChanges: { filesModified: [SENTINELS[2]] }, + }, + })); + }); + lines.push(JSON.stringify({ type: "session.usage_checkpoint", data: { modelCacheState: [{ modelId: model, cacheTtlSeconds: MODELS[model].ttl }] } })); + fs.writeFileSync(path.join(dir, "events.jsonl"), lines.join("\n") + "\n"); + insertSession.run(sid, repo, SENTINELS[1], iso(startMs), iso(startMs + 72e5)); + sessionsMeta.push({ sid, repo, startMs }); + } + store.exec("COMMIT"); + app.exec("COMMIT"); + store.close(); + app.close(); + return { sentinels: SENTINELS, eventCredits, sessions: sessionsMeta.length }; +} diff --git a/extensions/ai-credits-breakdown/server.mjs b/extensions/ai-credits-breakdown/server.mjs new file mode 100644 index 000000000..a578b59ad --- /dev/null +++ b/extensions/ai-credits-breakdown/server.mjs @@ -0,0 +1,72 @@ +// Loopback-only HTTP server that serves the dashboard UI and the locally built usage ledger. +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { randomBytes, timingSafeEqual } from "node:crypto"; + +const read = (name) => readFileSync(new URL(`./ui/${name}`, import.meta.url), "utf8"); +const ASSETS = { + "": { type: "text/html; charset=utf-8", body: read("index.html") }, + "app.js": { type: "text/javascript; charset=utf-8", body: read("app.js") }, + "styles.css": { type: "text/css; charset=utf-8", body: read("styles.css") }, +}; + +const SECURITY_HEADERS = { + "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", +}; +const CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'"; +const PREFERRED_PORTS = [47820, 47821, 47822, 47823, 47824, 47825, 47826, 47827, 47828, 47829]; + +// Every server has its own secret path prefix and only answers on 127.0.0.1. The page and /data.json +// both need the prefix, so other local users and web pages (DNS rebinding) cannot read your usage. +export async function startServer({ getLedger, onError = () => {}, ports = PREFERRED_PORTS }) { + const token = randomBytes(24).toString("base64url"); + const prefix = `/${token}/`; + let origin = ""; + const authorized = (req, url) => { + if (!origin || req.headers.host !== new URL(origin).host) return false; + if (req.headers.origin && req.headers.origin !== origin) return false; + const got = Buffer.from(url.pathname.slice(0, prefix.length)), want = Buffer.from(prefix); + return got.length === want.length && timingSafeEqual(got, want); + }; + const reply = (res, status, type, body, extra = {}) => { + res.writeHead(status, { "Content-Type": type, ...SECURITY_HEADERS, ...extra }); + res.end(body); + }; + const server = createServer(async (req, res) => { + let url; + try { url = new URL(req.url, "http://127.0.0.1"); } catch { return reply(res, 400, "text/plain; charset=utf-8", "Bad request"); } + if (req.method !== "GET" || !authorized(req, url)) return reply(res, 403, "text/plain; charset=utf-8", "Forbidden"); + const route = url.pathname.slice(prefix.length); + if (route === "data.json") { + try { + return reply(res, 200, "application/json", JSON.stringify(await getLedger())); + } catch (error) { + onError(error); + return reply(res, 500, "application/json", JSON.stringify({ error: error.message })); + } + } + const asset = Object.hasOwn(ASSETS, route) ? ASSETS[route] : null; + if (!asset) return reply(res, 404, "text/plain; charset=utf-8", "Not found"); + reply(res, 200, asset.type, asset.body, route === "" ? { "Content-Security-Policy": CSP } : {}); + }); + const listen = (port) => new Promise((resolve, reject) => { + const onFail = (e) => { server.off("listening", onListening); reject(e); }; + const onListening = () => { server.off("error", onFail); resolve(); }; + server.once("error", onFail); + server.once("listening", onListening); + server.listen(port, "127.0.0.1"); + }); + // Prefer a stable port so a restored panel keeps its address; otherwise let the OS choose. + let bound = false; + for (const port of ports) { + try { await listen(port); bound = true; break; } catch { /* port in use */ } + } + if (!bound) await listen(0); + origin = `http://127.0.0.1:${server.address().port}`; + return { server, url: origin + prefix, origin, prefix }; +} + +export async function stopServer(entry) { + entry.server.closeAllConnections?.(); + await new Promise((resolve) => entry.server.close(() => resolve())); +} diff --git a/extensions/ai-credits-breakdown/server.test.mjs b/extensions/ai-credits-breakdown/server.test.mjs new file mode 100644 index 000000000..c6d609dae --- /dev/null +++ b/extensions/ai-credits-breakdown/server.test.mjs @@ -0,0 +1,87 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { startServer, stopServer } from "./server.mjs"; + +const ledger = { meta: { total: 1 }, facts: [] }; + +function request(base, pathname, { method = "GET", headers = {} } = {}) { + return new Promise((resolve, reject) => { + const url = new URL(pathname, base); + const req = http.request(url, { method, headers }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { body += chunk; }); + res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on("error", reject); + req.end(); + }); +} + +async function withServer(options, run) { + const entry = await startServer({ ports: [], ...options }); + try { + await run(entry); + } finally { + await stopServer(entry); + } +} + +test("serves the page, script, styles and the ledger only under the secret prefix", async () => { + await withServer({ getLedger: async () => ledger }, async ({ url, origin, prefix }) => { + assert.match(origin, /^http:\/\/127\.0\.0\.1:\d+$/); + assert.ok(prefix.length > 20); + + const page = await request(url, url); + assert.equal(page.status, 200); + assert.match(page.body, /AI credits/); + assert.match(page.headers["content-security-policy"], /default-src 'none'/); + assert.match(page.headers["content-security-policy"], /connect-src 'self'/); + assert.equal(page.headers["cache-control"], "no-store"); + + for (const [name, type] of [["app.js", /javascript/], ["styles.css", /css/]]) { + const asset = await request(url, `${prefix}${name}`); + assert.equal(asset.status, 200, name); + assert.match(asset.headers["content-type"], type); + } + + const data = await request(url, `${prefix}data.json`); + assert.equal(data.status, 200); + assert.deepEqual(JSON.parse(data.body), ledger); + + assert.equal((await request(url, `${prefix}nope`)).status, 404); + assert.equal((await request(url, `${prefix}__proto__`)).status, 404); + }); +}); + +test("rejects requests without the prefix, with another host or origin, or that are not GET", async () => { + await withServer({ getLedger: async () => ledger }, async ({ url, prefix, origin }) => { + assert.equal((await request(url, "/")).status, 403); + assert.equal((await request(url, "/wrong-token/data.json")).status, 403); + assert.equal((await request(url, `${prefix}data.json`, { headers: { Host: "evil.example" } })).status, 403); + assert.equal((await request(url, `${prefix}data.json`, { headers: { Origin: "https://evil.example" } })).status, 403); + assert.equal((await request(url, `${prefix}data.json`, { method: "POST" })).status, 403); + assert.equal((await request(url, `${prefix}data.json`, { headers: { Origin: origin } })).status, 200); + }); +}); + +test("reports a failed build as JSON without crashing", async () => { + const seen = []; + await withServer({ getLedger: async () => { throw new Error("database is locked"); }, onError: (e) => seen.push(e.message) }, + async ({ url, prefix }) => { + const res = await request(url, `${prefix}data.json`); + assert.equal(res.status, 500); + assert.deepEqual(JSON.parse(res.body), { error: "database is locked" }); + assert.deepEqual(seen, ["database is locked"]); + }); +}); + +test("each server gets its own secret prefix", async () => { + await withServer({ getLedger: async () => ledger }, async (a) => { + await withServer({ getLedger: async () => ledger }, async (b) => { + assert.notEqual(a.prefix, b.prefix); + assert.notEqual(a.origin, b.origin); + }); + }); +}); diff --git a/extensions/ai-credits-breakdown/ui/app.js b/extensions/ai-credits-breakdown/ui/app.js new file mode 100644 index 000000000..54053d2df --- /dev/null +++ b/extensions/ai-credits-breakdown/ui/app.js @@ -0,0 +1,784 @@ +(function () { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const nf = new Intl.NumberFormat('en-US'); + const fmt = (n) => nf.format(Math.round(n)); + const usd = (c) => { + const v = c * 0.01; + return '$' + v.toLocaleString('en-US', { minimumFractionDigits: v < 1000 ? 2 : 0, maximumFractionDigits: v < 1000 ? 2 : 0 }); + }; + const plural = (n, one, many) => fmt(n) + ' ' + (n === 1 ? one : (many || one + 's')); + const cu = (c) => fmt(c) + ' (' + usd(c) + ')'; + // Averages per call are often a fraction of a credit, so they keep their decimals and sub-cent dollars. + const cuFine = (c) => (c >= 100 ? fmt(c) : c >= 10 ? c.toFixed(1) : c.toFixed(2)) + ' (' + + (c >= 100 ? usd(c) : '$' + (c * 0.01).toFixed(4).replace(/(\.\d\d)0+$/, '$1')) + ')'; + // Totals grouped by names that come from telemetry: a name like "constructor" must stay an ordinary key. + const dict = () => Object.create(null); + const credTxt = (c) => fmt(c) + ' credits (' + usd(c) + ')'; + const pct = (x) => (x >= 0.1 ? Math.round(x * 100) + '%' : x >= 0.01 ? (x * 100).toFixed(1) + '%' : x > 0 ? '<1%' : '0%'); + const mtok = (n) => (n >= 1e9 ? (n / 1e9).toFixed(2) + 'B' : n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'K' : String(n)); + const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const monthName = (mo) => MONTHS[parseInt(mo.slice(5, 7), 10) - 1]; + const longDate = (iso) => { const [y, m, d] = iso.split('-').map(Number); return d + ' ' + MONTHS[m - 1] + ' ' + y; }; + const shortDate = (iso) => { const [, m, d] = iso.split('-').map(Number); return d + ' ' + MONTHS[m - 1]; }; + const FAMILY_COLOR = { 'Claude Opus': 'var(--opus)', 'Claude Sonnet & Haiku': 'var(--sonnet)', GPT: 'var(--gpt)', Other: 'var(--other)' }; + const TIER_NAME = ['Metered per call', 'Metered per session']; + + fetch('data.json', { cache: 'no-store' }) + .then((r) => r.json().then((body) => { if (!r.ok) throw new Error(body.error || r.status); return body; })) + .then(init) + .catch((e) => { + $('loading').textContent = 'Could not read your Copilot usage: ' + e.message; + }); + + function init(data) { + if (!data.facts.length) { + $('loading').textContent = 'No AI credit usage was found in your local Copilot telemetry yet. Run a Copilot session, then reload this panel.'; + return; + } + const D = data.dims, M = data.meta; + const AUTOMATIONS = D.automations || []; + const CACHES = D.caches; + const cacheOf = (v) => v; + const CACHE_TTL = M.cacheTtl; + const ownerOf = (repo) => (!repo ? '(no repository)' : repo.split('/')[0]); + const repoLabel = (repo) => repo || '(no repository)'; + const sessInfo = data.sessions.map((s) => { + const repo = D.repos[s.repo]; + return { repo, owner: ownerOf(repo), surf: D.surfaces[s.surface], automation: s.automation }; + }); + + const facts = data.facts.map((r) => { + const day = D.days[r[0]]; const si = sessInfo[r[1]]; + return { + day, mo: day.slice(0, 7), s: r[1], m: r[2], k: r[3], tier: r[4], c: r[5], + ci: r[6], cr: r[7], cw: r[8], co: r[9], ti: r[10], tr: r[11], tw: r[12], to: r[13], calls: r[14], cache: cacheOf(r[15]), + repo: si.repo, owner: si.owner, surf: si.surf, automation: si.automation, + }; + }); + const hourly = data.hourly.map((r) => { + const si = sessInfo[r[1]]; + return { day: D.days[r[0]], s: r[1], m: r[2], k: r[3], tier: 0, dow: r[4], h: r[5], c: r[6], cache: cacheOf(r[7]), repo: si.repo, owner: si.owner, surf: si.surf }; + }); + const modelName = (i) => D.models[i].name; + const modelFam = (i) => D.models[i].family; + const months = Array.from(new Set(facts.map((f) => f.mo))).sort(); + const lastMonth = M.generated.slice(0, 7); + const START = M.periodStart, END = M.generated.slice(0, 10); + const EVENTS_FROM = M.eventsStart ? longDate(M.eventsStart.slice(0, 10)) : 'the day the Copilot CLI began recording them'; + const TZ = M.timeZone || 'UTC'; + + /* ---------- Dates ---------- */ + const addDays = (iso, n) => { const d = new Date(iso + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); }; + const spanDays = (a, b) => Math.round((Date.parse(b + 'T00:00:00Z') - Date.parse(a + 'T00:00:00Z')) / 864e5) + 1; + const daysInMonth = (mo) => { const [y, m] = mo.split('-').map(Number); return new Date(Date.UTC(y, m, 0)).getUTCDate(); }; + const monthEnd = (mo) => mo + '-' + String(daysInMonth(mo)).padStart(2, '0'); + const prevMonth = (mo) => { const [y, m] = mo.split('-').map(Number); const d = new Date(Date.UTC(y, m - 2, 1)); return d.toISOString().slice(0, 7); }; + const clampDay = (d) => (d < START ? START : d > END ? END : d); + const fullName = (mo) => ({ Jan: 'January', Feb: 'February', Mar: 'March', Apr: 'April', May: 'May', Jun: 'June', Jul: 'July', Aug: 'August', Sep: 'September', Oct: 'October', Nov: 'November', Dec: 'December' })[monthName(mo)]; + const rangeText = (a, b) => (a === b ? longDate(a) : a.slice(0, 4) === b.slice(0, 4) ? shortDate(a) + ' – ' + longDate(b) : longDate(a) + ' – ' + longDate(b)); + + const PRESETS = [ + { id: 'mtd', label: 'This month to date', range: () => [lastMonth + '-01', END] }, + { id: '7d', label: 'Last 7 days', range: () => [addDays(END, -6), END] }, + { id: '30d', label: 'Last 30 days', range: () => [addDays(END, -29), END] }, + { id: '90d', label: 'Last 90 days', range: () => [addDays(END, -89), END] }, + { id: 'all', label: 'All time', range: () => [START, END] }, + ]; + months.slice().reverse().forEach((mo) => PRESETS.push({ id: 'mo:' + mo, group: 'Month', label: fullName(mo) + ' ' + mo.slice(0, 4) + (mo === lastMonth ? ' (to date)' : ''), range: () => [clampDay(mo + '-01'), clampDay(monthEnd(mo))] })); + const presetOf = () => { + const p = PRESETS.find((x) => { const r = x.range(); return r[0] === F.from && r[1] === F.to; }); + return p ? p.id : 'custom'; + }; + + /* ---------- Filter state ---------- */ + const DIMS = [ + { key: 'repo', label: 'Repository', all: 'All repositories', name: repoLabel, enc: (v) => v, dec: (s) => s }, + { key: 'owner', label: 'Owner', all: 'All owners', name: (v) => v, enc: (v) => v, dec: (s) => s }, + { key: 'm', label: 'Model', all: 'All models', num: true, name: modelName, enc: (v) => D.models[v].id, dec: (s) => { const i = D.models.findIndex((x) => x.id === s); return i >= 0 ? i : null; } }, + { key: 'surf', label: 'Surface', all: 'All surfaces', name: (v) => v, enc: (v) => v, dec: (s) => (D.surfaces.indexOf(s) >= 0 ? s : null) }, + { key: 'k', label: 'Call type', all: 'All call types', num: true, name: (v) => D.kinds[v], enc: (v) => D.kinds[v], dec: (s) => { const i = D.kinds.indexOf(s); return i >= 0 ? i : null; } }, + { key: 'tier', label: 'Basis', all: 'All bases', num: true, name: (v) => TIER_NAME[v], enc: String, dec: (s) => (['0', '1'].indexOf(s) >= 0 ? Number(s) : null) }, + { key: 'cache', label: 'Prompt cache', all: 'All cache states', num: true, name: (v) => CACHES[v].name, enc: (v) => CACHES[v].id, dec: (s) => { const i = CACHES.findIndex((x) => x.id === s); return i >= 0 ? i : null; } }, + ]; + const DIM = {}; DIMS.forEach((d) => { DIM[d.key] = d; }); + const DEFAULT = PRESETS[0].range(); + const F = { from: DEFAULT[0], to: DEFAULT[1], repo: null, owner: null, m: null, surf: null, k: null, tier: null, cache: null }; + const match = (f, exclude) => (exclude === 'date' || (f.day >= F.from && f.day <= F.to)) && + DIMS.every((d) => d.key === exclude || F[d.key] === null || f[d.key] === F[d.key]); + const rowsFor = (exclude) => facts.filter((f) => match(f, exclude)); + const rowsBetween = (a, b, exclude) => facts.filter((f) => f.day >= a && f.day <= b && DIMS.every((d) => d.key === exclude || F[d.key] === null || f[d.key] === F[d.key])); + const sum = (rows, key) => rows.reduce((a, f) => a + f[key], 0); + const anyDim = () => DIMS.some((d) => F[d.key] !== null); + + function readHash() { + const q = new URLSearchParams(location.hash.slice(1)); + const isDay = (v) => /^\d{4}-\d{2}-\d{2}$/.test(v || ''); + if (isDay(q.get('from')) && isDay(q.get('to'))) { + let a = clampDay(q.get('from')), b = clampDay(q.get('to')); if (a > b) [a, b] = [b, a]; + F.from = a; F.to = b; + } else if (q.get('p')) { + const p = PRESETS.find((x) => x.id === q.get('p')); if (p) [F.from, F.to] = p.range(); + } + DIMS.forEach((d) => { F[d.key] = q.has(d.key) ? d.dec(q.get(d.key)) : null; }); + } + function writeHash() { + const q = new URLSearchParams(); const p = presetOf(); + if (p === 'custom') { q.set('from', F.from); q.set('to', F.to); } else if (p !== 'mtd') q.set('p', p); + DIMS.forEach((d) => { if (F[d.key] !== null) q.set(d.key, d.enc(F[d.key])); }); + const h = q.toString(); const url = location.pathname + location.search + (h ? '#' + h : ''); + if (url !== location.pathname + location.search + location.hash) history.replaceState(null, '', url); + } + readHash(); + window.addEventListener('hashchange', () => { + if (location.hash === '#provenance' || location.hash === '#main') { + writeHash(); + return; + } + readHash(); render(); + }); + + const updated = new Date(M.generated).toLocaleString(undefined, { + day: 'numeric', month: 'short', year: 'numeric', + hour: 'numeric', minute: '2-digit', timeZoneName: 'short', + }); + $('stamp').textContent = 'Last updated ' + updated + ' · data ' + shortDate(START) + ' – ' + longDate(END) + ' (UTC days)'; + + /* ---------- Filter bar ---------- */ + const preset = $('preset'), fromEl = $('from'), toEl = $('to'); + preset.innerHTML = PRESETS.filter((p) => !p.group).map((p) => '').join('') + + '' + PRESETS.filter((p) => p.group).map((p) => '').join('') + '' + + ''; + [fromEl, toEl].forEach((el) => { el.min = START; el.max = END; }); + preset.addEventListener('change', () => { const p = PRESETS.find((x) => x.id === preset.value); if (p) { [F.from, F.to] = p.range(); render(); } }); + function onDate(e) { + if (!fromEl.value || !toEl.value) return; + let a = clampDay(fromEl.value), b = clampDay(toEl.value); + if (a > b) { if (e.target === fromEl) b = a; else a = b; } + F.from = a; F.to = b; render(); + } + fromEl.addEventListener('change', onDate); toEl.addEventListener('change', onDate); + $('dimbar').innerHTML = DIMS.map((d) => '
').join(''); + $('more').addEventListener('click', () => { + const open = $('more').getAttribute('aria-expanded') !== 'true'; + $('more').setAttribute('aria-expanded', String(open)); $('dimbar').classList.toggle('open', open); + }); + $('dimbar').addEventListener('change', (e) => { + const sel = e.target.closest('select[data-dim]'); if (!sel) return; + const d = DIM[sel.dataset.dim]; const v = sel.value; + F[d.key] = v === '__all' ? null : (d.num ? Number(v) : v); + render(); + }); + $('reset').addEventListener('click', () => { [F.from, F.to] = DEFAULT; clearFilters(); }); + $('csv').addEventListener('click', downloadCsv); + $('refresh').addEventListener('click', () => location.reload()); + + function renderFilterBar() { + const pid = presetOf(); preset.value = pid; + fromEl.value = F.from; toEl.value = F.to; + DIMS.forEach((d) => { + const g = dict(); rowsFor(d.key).forEach((f) => { g[f[d.key]] = (g[f[d.key]] || 0) + f.c; }); + const raw = (v) => (d.num ? Number(v) : v); + const opts = Object.entries(g).map(([v, c]) => ({ v: raw(v), c })).sort((a, b) => b.c - a.c); + if (F[d.key] !== null && !opts.some((o) => o.v === F[d.key])) opts.unshift({ v: F[d.key], c: 0 }); + const sel = $('f-' + d.key); + sel.innerHTML = '' + + opts.map((o) => '').join(''); + sel.value = F[d.key] === null ? '__all' : String(F[d.key]); + sel.closest('.fld').classList.toggle('set', F[d.key] !== null); + }); + $('reset').disabled = !anyDim() && F.from === DEFAULT[0] && F.to === DEFAULT[1]; + const n = DIMS.filter((d) => F[d.key] !== null).length; + $('more').textContent = n ? 'Filters (' + n + ')' : 'Filters'; + } + + // Delegated click-filters from tables and lists + document.addEventListener('click', (e) => { + const b = e.target.closest('[data-f]'); if (!b) return; + const key = b.dataset.f; const val = DIM[key].num ? Number(b.dataset.v) : b.dataset.v; + F[key] = F[key] === val ? null : val; render(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !e.target.closest('select,input')) clearFilters(); + }); + function clearFilters() { DIMS.forEach((d) => { F[d.key] = null; }); render(); } + + function downloadCsv() { + const rows = rowsFor(null).slice().sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : b.c - a.c)); + const q = (v) => { const s = String(v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; + const head = ['day_utc', 'session', 'repository', 'owner', 'surface', 'model', 'call_type', 'basis', 'prompt_cache', 'credits', 'usd', 'calls', 'input_tokens', 'cache_read_tokens', 'cache_write_tokens', 'output_tokens']; + const lines = [head.join(',')].concat(rows.map((f) => [f.day, data.sessions[f.s].id, f.repo, f.owner, f.surf, D.models[f.m].id, D.kinds[f.k], TIER_NAME[f.tier], CACHES[f.cache].name, f.c.toFixed(3), (f.c * 0.01).toFixed(4), f.calls, f.ti, f.tr, f.tw, f.to].map(q).join(','))); + const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' }); + const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'ai-credits_' + F.from + '_' + F.to + '.csv'; + document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 0); + } + + const SECTIONS = ['daterange', 'dimbar', 'stamp', 'statement', 'repos-sheet', 'rain-sheet', 'models-sheet', 'side-stack', 'kinds-sheet', 'anat-sheet', 'hours-sheet', 'cache-sheet', 'sessions-sheet', 'provenance', 'automations-sheet']; + function render() { + $('loading').hidden = true; + SECTIONS.forEach((id) => { $(id).hidden = false; }); + writeHash(); + renderFilterBar(); + renderStatement(); + renderRepoSpend(); + renderRain(); + renderModels(); + renderOwners(); + renderSurfaces(); + renderKinds(); + renderAnatomy(); + renderHours(); + renderCache(); + renderSessions(); + renderAutomations(); + } + + /* ---------- Comparison period ---------- */ + const delta = (now, before) => { + if (!before) return now ? 'new' : ''; + const d = (now - before) / before; const r = Math.round(Math.abs(d) * 100); + if (now / before >= 3) return '▲ ×' + (now / before >= 10 ? Math.round(now / before) : (now / before).toFixed(1)); + return r === 0 ? 'level' : (d > 0 ? '▲ ' : '▼ ') + r + '%'; + }; + // The period the selected range is compared against, or null when it falls before the data starts. + function comparison() { + const mo = F.from.slice(0, 7); const len = spanDays(F.from, F.to); + const wholeMonth = F.from === mo + '-01' && (F.to === monthEnd(mo) || (F.to === END && mo === lastMonth)); + let a, b, label; + if (wholeMonth && F.to.slice(0, 7) === mo) { + const pm = prevMonth(mo); + if (F.to === END && mo === lastMonth && F.to !== monthEnd(mo)) { + const dd = Math.min(Number(F.to.slice(8)), daysInMonth(pm)); + a = pm + '-01'; b = pm + '-' + String(dd).padStart(2, '0'); label = monthName(pm) + ' 1–' + dd; + } else { a = pm + '-01'; b = monthEnd(pm); label = fullName(pm); } + } else { + b = addDays(F.from, -1); a = addDays(F.from, -len); + label = len === 1 ? 'Day before' : 'Previous ' + len + ' days'; + } + if (b < START) return null; + return { a, b, label, partial: a < START }; + } + + /* ---------- Statement (period headline) ---------- */ + function renderStatement() { + const rows = rowsFor(null); + const c = sum(rows, 'c'); + const sess = new Set(rows.map((f) => f.s)).size; + const repos = new Set(rows.map((f) => f.repo).filter(Boolean)).size; + const tiers = [0, 1].map((t) => sum(rows.filter((f) => f.tier === t), 'c')); + const amt = '' + usd(c) + ''; + const len = spanDays(F.from, F.to); + const byDay = dict(); rows.forEach((f) => { byDay[f.day] = (byDay[f.day] || 0) + f.c; }); + const active = Object.keys(byDay).length; + const peak = Object.entries(byDay).sort((a, b) => b[1] - a[1])[0]; + const counts = '' + credTxt(c) + ' · ' + plural(sess, 'session') + ' · ' + plural(repos, 'repository', 'repositories') + ' · ' + plural(active, 'active day') + ' of ' + len + ''; + const pid = presetOf(); const mo = F.from.slice(0, 7); + let lead; + if (pid === 'mtd') lead = 'You have spent ' + amt + ' in ' + fullName(mo) + ' so far'; + else if (pid === 'all') lead = 'You have spent ' + amt + ' since ' + longDate(START); + else if (pid.indexOf('mo:') === 0) lead = 'You spent ' + amt + ' in ' + fullName(mo) + ' ' + mo.slice(0, 4); + else if (/^\d+d$/.test(pid)) lead = 'You spent ' + amt + ' in the last ' + parseInt(pid, 10) + ' days'; + else lead = 'You spent ' + amt + (len === 1 ? ' on ' + longDate(F.from) : ' from ' + (F.from.slice(0, 4) === F.to.slice(0, 4) ? shortDate(F.from) : longDate(F.from)) + ' to ' + longDate(F.to)); + const facts2 = []; + facts2.push(['Range (UTC)', rangeText(F.from, F.to) + ' · ' + plural(len, 'day')]); + facts2.push(['Daily average', usd(c / len) + ' a day']); + if (pid === 'mtd') { + const dim = daysInMonth(mo); + facts2.push(['At this pace', '≈ ' + usd((c / len) * dim) + ' by ' + dim + ' ' + monthName(mo)]); + } + const cmp = comparison(); + if (cmp) { + const pv = sum(rowsBetween(cmp.a, cmp.b), 'c'); const d = delta(c, pv); + facts2.push([cmp.label + (cmp.partial ? ' (partial data)' : ''), (pv ? usd(pv) : 'Nothing') + (d ? ' ' + d + '' : '')]); + } + if (peak && len > 1) facts2.push(['Busiest day', shortDate(peak[0]) + ' · ' + usd(peak[1])]); + const parts = []; + DIMS.forEach((d) => { + if (F[d.key] === null) return; + parts.push('' + esc(d.label) + ': ' + esc(d.name(F[d.key])) + ''); + }); + $('statement').innerHTML = + '

' + lead + '.

' + counts + + (parts.length ? '

Only showing' + parts.join('') + '

' : '') + + '
' + facts2.map((x) => '
' + esc(x[0]) + '
' + x[1] + '
').join('') + '
' + + '

Basis of these numbers

' + + '
' + tiers.map((v, i) => '').join('') + '
' + + '

These are list-price amounts from local telemetry, not an invoice; see where these numbers come from.

' + + '
'; + const clr = $('clear'); if (clr) clr.addEventListener('click', clearFilters); + const pl = $('prov-link'); if (pl) pl.addEventListener('click', () => { $('provenance').open = true; }); + } + + /* ---------- Repository spend ---------- */ + let allRepos = false; + function renderRepoSpend() { + const rows = rowsFor('repo'); + const by = dict(); + rows.forEach((f) => { + const k = f.repo || ''; + const b = by[k] || (by[k] = { c: 0, sess: new Set(), models: dict() }); + b.c += f.c; b.sess.add(f.s); b.models[f.m] = (b.models[f.m] || 0) + f.c; + }); + const cmpP = comparison(); const cmpLabel = cmpP ? cmpP.label : null; const cmp = dict(); + if (cmpP) rowsBetween(cmpP.a, cmpP.b, 'repo').forEach((f) => { cmp[f.repo || ''] = (cmp[f.repo || ''] || 0) + f.c; }); + const items = Object.entries(by).sort((a, b) => b[1].c - a[1].c); + const total = items.reduce((a, [, b]) => a + b.c, 0); const max = items.length ? items[0][1].c : 1; + $('repos-h').textContent = 'Spend by repository, ' + rangeText(F.from, F.to); + const nameCell = (repo) => { + if (!repo) return '(no repository)'; + const [o, n] = repo.split('/'); + return '' + esc(o) + '/' + esc(n); + }; + $('repos-table').innerHTML = + 'RepositorySpentShare of ' + usd(total) + '' + + (cmpLabel ? '' + esc(cmpLabel) + '' : '') + 'CreditsSessionsMain model' + + (items.length ? items.filter(([repo], i) => allRepos || i < 12 || repo === F.repo).map(([repo, b]) => { + const on = F.repo !== null && F.repo === repo; + const mm = Object.entries(b.models).sort((a, c) => c[1] - a[1])[0]; + const pv = cmp[repo] || 0; const d = cmpLabel ? delta(b.c, pv) : ''; + return '' + + '' + usd(b.c) + '' + + '
' + pct(total ? b.c / total : 0) + '
' + + (cmpLabel ? '' + (pv ? usd(pv) : '–') + (d ? ' ' + d + '' : '') + '' : '') + + '' + cu(b.c) + '' + b.sess.size + '' + esc(modelName(Number(mm[0]))) + ''; + }).join('') + (items.length > 12 ? '' : '') : 'No spend under these filters. Widen the date range or clear a filter.') + + '' + (items.length > 1 ? 'Total' + usd(total) + '' + (cmpLabel ? '' + usd(Object.values(cmp).reduce((a, v) => a + v, 0)) + '' : '') + '' + cu(total) + '' : ''); + } + document.addEventListener('click', (e) => { if (e.target.id === 'repos-more') { allRepos = !allRepos; renderRepoSpend(); } }); + function tierFill(t) { + return t === 0 ? 'background:var(--rain)' : 'background:var(--rain-soft)'; + } + + /* ---------- Rain chart ---------- */ + let rainState = { days: [], per: [], cum: [], active: null, geom: null }; + function dayList(a, b) { + const out = []; const d = new Date(a + 'T00:00:00Z'); const end = new Date(b + 'T00:00:00Z'); + while (d <= end) { out.push(d.toISOString().slice(0, 10)); d.setUTCDate(d.getUTCDate() + 1); } + return out; + } + function niceMax(v) { + if (v <= 0) return 1; + const p = Math.pow(10, Math.floor(Math.log10(v))); const n = v / p; + return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p; + } + function renderRain() { + const rows = rowsFor(null); + const days = dayList(F.from, F.to); + const idx = dict(); days.forEach((d, i) => { idx[d] = i; }); + const per = days.map(() => [0, 0]); + const topModel = days.map(() => dict()); + const sessSet = days.map(() => new Set()); + rows.forEach((f) => { + const i = idx[f.day]; if (i === undefined) return; + per[i][f.tier] += f.c; topModel[i][f.m] = (topModel[i][f.m] || 0) + f.c; sessSet[i].add(f.s); + }); + const tot = per.map((p) => p[0] + p[1]); + let run = 0; const cum = tot.map((t) => (run += t)); + rainState = { days, per, tot, cum, topModel, sessSet, active: rainState.active, geom: null }; + + const el = $('rain'); const W = Math.max(320, el.clientWidth || 900); + const narrow = W < 560; + const L = narrow ? 50 : 56, R = narrow ? 56 : 66, T = 22; + const H1 = narrow ? 120 : 150, GAP = 46, H2 = narrow ? 110 : 140, B = 18; + const H = T + H1 + GAP + H2 + B; + const pw = W - L - R; const n = days.length; const step = pw / n; const bw = Math.max(1, Math.min(step - (step > 4 ? 1 : 0), 24)); + const maxDay = niceMax(Math.max(...tot, 1)); const maxCum = niceMax(Math.max(run, 1)); + const x = (i) => L + i * step + (step - bw) / 2; + const xc = (i) => L + i * step + step / 2; + const y1 = (v) => T + (v / maxDay) * H1; // hangs from the top + const y2 = (v) => T + H1 + GAP + H2 - (v / maxCum) * H2; // rises from the bottom + const top2 = T + H1 + GAP; + let s = ''; + // gridlines & axis labels + [0, 0.5, 1].forEach((f) => { + const yy = T + f * H1; s += '' + fmt(f * maxDay) + '' + (f ? '(' + usd(f * maxDay) + ')' : ''); + const y2v = top2 + H2 - f * H2; s += '' + fmt(f * maxCum) + '' + (f ? '(' + usd(f * maxCum) + ')' : ''); + }); + s += ''; + s += 'Daily credits, hanging from the topRunning total, rising'; + s += ''; + // month boundaries + labels (drop a label when the next one is too close to read) + const marks = []; + days.forEach((d, i) => { if (d.slice(8) === '01' || i === 0) marks.push({ d, i, xx: L + i * step }); }); + marks.forEach((mk, j) => { + if (mk.i !== 0) s += ''; + const next = marks[j + 1]; + if (next && next.xx - mk.xx < 44) return; + const label = monthName(mk.d.slice(0, 7)) + (mk.d.slice(5, 7) === '01' || mk.i === 0 ? ' ' + mk.d.slice(0, 4) : ''); + s += '' + label + ''; + }); + // hanging bars (stacked by basis) + for (let i = 0; i < n; i++) { + let acc = 0; + [0, 1].forEach((t) => { + const v = per[i][t]; if (v <= 0) return; + const h = (v / maxDay) * H1; s += ''; acc += h; + }); + } + // cumulative curve + if (n > 0) { + let d = 'M' + xc(0).toFixed(2) + ' ' + y2(cum[0]).toFixed(2); + for (let i = 1; i < n; i++) d += ' L' + xc(i).toFixed(2) + ' ' + y2(cum[i]).toFixed(2); + s += ''; + s += ''; + s += '' + fmt(run) + '(' + usd(run) + ')'; + } + s += ''; + s += ''; + s += ''; + el.innerHTML = s; + rainState.geom = { L, step, n, T, top2, H2, W }; + $('rain-legend').innerHTML = 'Metered per callMetered per session (day = when the session reported)Peak day ' + credTxt(Math.max(...tot, 0)) + ''; + if (rainState.active !== null && rainState.active < n) showDay(rainState.active, false); else rainState.active = null; + } + function showDay(i, fromKey) { + const g = rainState.geom; if (!g) return; + rainState.active = i; + const cross = $('cross'); const xx = g.L + i * g.step + g.step / 2; + cross.setAttribute('x1', xx); cross.setAttribute('x2', xx); cross.removeAttribute('hidden'); + const per = rainState.per[i]; const tot = rainState.tot[i]; + const tm = Object.entries(rainState.topModel[i]).sort((a, b) => b[1] - a[1])[0]; + const tip = $('tip'); + tip.innerHTML = '' + longDate(rainState.days[i]) + '' + + '
Credits' + cu(tot) + '
' + + '
Running total' + cu(rainState.cum[i]) + '
' + + '
Sessions' + rainState.sessSet[i].size + '
' + + (tm ? '
Top model' + esc(modelName(Number(tm[0]))) + '
' : '') + + (per[1] ? '
Basis' + [per[0] && 'call ' + cu(per[0]), 'session ' + cu(per[1])].filter(Boolean).join(' · ') + '
' : ''); + tip.hidden = false; + // Keyboard users get the same detail as a spoken status; pointer movement stays silent. + if (fromKey) { + $('rain-status').textContent = longDate(rainState.days[i]) + '. ' + credTxt(tot) + '. Running total ' + credTxt(rainState.cum[i]) + '. ' + + plural(rainState.sessSet[i].size, 'session') + (tm ? '. Top model ' + modelName(Number(tm[0])) : '') + '.'; + } + const svg = $('rain').querySelector('svg'); const r = svg.getBoundingClientRect(); + const px = r.left + (xx / g.W) * r.width; const py = r.top + 20; + const tw = tip.offsetWidth; const left = Math.min(Math.max(8, px + 14), window.innerWidth - tw - 8); + tip.style.left = (px + 14 + tw > window.innerWidth ? px - tw - 14 : left) + 'px'; tip.style.top = Math.max(8, py) + 'px'; + } + function hideDay() { $('tip').hidden = true; const c = $('cross'); if (c) c.setAttribute('hidden', ''); rainState.active = null; } + const rainEl = $('rain'); + let brush = null; + const dayAt = (e) => { + const g = rainState.geom; if (!g) return -1; const svg = rainEl.querySelector('svg'); const r = svg.getBoundingClientRect(); + const xx = ((e.clientX - r.left) / r.width) * g.W; return Math.floor((xx - g.L) / g.step); + }; + function drawBrush(a, b) { + const g = rainState.geom; const el = $('brush'); if (!g || !el) return; + const lo = Math.min(a, b), hi = Math.max(a, b); + el.setAttribute('x', g.L + lo * g.step); el.setAttribute('width', (hi - lo + 1) * g.step); el.removeAttribute('hidden'); + } + rainEl.addEventListener('pointerdown', (e) => { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + const i = dayAt(e); if (i < 0 || i >= rainState.days.length) return; + brush = { a: i, b: i }; rainEl.setPointerCapture(e.pointerId); e.preventDefault(); + }); + rainEl.addEventListener('pointermove', (e) => { + const g = rainState.geom; if (!g) return; + let i = dayAt(e); + if (brush) { i = Math.max(0, Math.min(g.n - 1, i)); brush.b = i; drawBrush(brush.a, brush.b); } + if (i >= 0 && i < g.n) showDay(i); else hideDay(); + }); + rainEl.addEventListener('pointerup', () => { + if (!brush) return; const lo = Math.min(brush.a, brush.b), hi = Math.max(brush.a, brush.b); brush = null; + if (hi > lo) { F.from = rainState.days[lo]; F.to = rainState.days[hi]; hideDay(); render(); } + else { const el = $('brush'); if (el) el.setAttribute('hidden', ''); } + }); + rainEl.addEventListener('pointercancel', () => { brush = null; const el = $('brush'); if (el) el.setAttribute('hidden', ''); }); + rainEl.addEventListener('pointerleave', () => { if (!brush) hideDay(); }); + rainEl.addEventListener('blur', () => { hideDay(); $('rain-status').textContent = ''; }); + rainEl.addEventListener('keydown', (e) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; e.preventDefault(); + const n = rainState.days.length; let i = rainState.active === null ? (e.key === 'ArrowLeft' ? n - 1 : 0) : rainState.active + (e.key === 'ArrowLeft' ? -1 : 1); + i = Math.max(0, Math.min(n - 1, i)); showDay(i, true); + }); + let rt; window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { renderRain(); renderAutomations(); }, 120); }); + + /* ---------- Automation daily spend ---------- */ + const automationEl = $('automations-chart'); + let automationState = { days: [], series: [], active: null, selected: null, geom: null }; + const automationStyle = (i) => ({ + color: ['var(--rain)', 'var(--ink)', 'var(--other)'][i % 3], + dash: ['', '7 3', '2 3', '10 3 2 3'][Math.floor(i / 3) % 4], + }); + function renderAutomations() { + const days = dayList(F.from, F.to); + const indices = new Map(days.map((day, i) => [day, i])); + const series = AUTOMATIONS.map((a, i) => ({ name: a.name, i, values: days.map(() => 0), total: 0, style: automationStyle(i) })); + rowsFor(null).forEach((f) => { + const a = series[f.automation]; + if (!a) return; + a.values[indices.get(f.day)] += f.c; + a.total += f.c; + }); + const previousDay = automationState.days[automationState.active]; + automationState = { days, series, active: indices.has(previousDay) ? indices.get(previousDay) : days.length - 1, selected: automationState.selected, geom: null }; + const legend = $('automations-legend'); + if (!series.length) { + $('automations-sheet').hidden = !M.automationsUnavailable; + $('automations-summary').textContent = M.automationsUnavailable ? 'Automation attribution is unavailable: ' + M.automationsUnavailable + '.' : ''; + automationEl.innerHTML = ''; + automationEl.removeAttribute('tabindex'); + legend.innerHTML = ''; + return; + } + automationEl.setAttribute('tabindex', '0'); + const total = series.reduce((v, a) => v + a.total, 0); + $('automations-summary').textContent = + (M.automationsUnavailable ? 'Automation attribution is unavailable: ' + M.automationsUnavailable + '. ' : '') + + plural(series.length, 'series', 'series') + ' · ' + credTxt(total) + ' recorded · ' + rangeText(F.from, F.to) + + (total ? '' : '. No automation spend under these filters; widen the date range or clear a filter.'); + const W = Math.max(240, automationEl.clientWidth), L = 72, R = 16, T = 26, PH = 240, H = T + PH + 34; + const pw = W - L - R, n = days.length; + const x = (i) => L + (n === 1 ? pw / 2 : i * pw / (n - 1)); + const max = niceMax(series.reduce((v, a) => Math.max(v, ...a.values), 1)); + const y = (v) => T + PH - v / max * PH; + let h = '' + + 'Daily spend (USD)'; + [0, 0.25, 0.5, 0.75, 1].forEach((f) => { + const yy = y(f * max); + h += '' + usd(f * max) + ''; + }); + const ticks = Math.min(n, Math.max(2, Math.floor(pw / 95))); + for (let t = 0; t < ticks; t++) { + const i = ticks === 1 ? 0 : Math.round(t * (n - 1) / (ticks - 1)); + h += '' + shortDate(days[i]) + ''; + } + series.forEach((a) => { + const path = a.values.map((v, i) => (i ? 'L' : 'M') + x(i).toFixed(2) + ' ' + y(v).toFixed(2)).join(' '); + h += '' + esc(a.name + ' · ' + credTxt(a.total)) + '' + + '' + + a.values.map((v, i) => v > 0 || n === 1 ? '' : '').join('') + ''; + }); + h += ''; + automationEl.innerHTML = h; + automationState.geom = { W, L, pw, n, x }; + legend.innerHTML = 'Automation legend, selected day spend and period totalsAutomationPeriod total' + + series.slice().sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)).map((a) => '' + usd(a.total) + '').join('') + ''; + highlightAutomation(); + showAutomationDay(automationState.active); + } + function showAutomationDay(i) { + const g = automationState.geom; if (!g) return; + automationState.active = i; + $('automations-cross').setAttribute('x1', g.x(i)); + $('automations-cross').setAttribute('x2', g.x(i)); + $('automations-day').textContent = shortDate(automationState.days[i]); + automationState.series.forEach((a) => { $('automation-day-' + a.i).textContent = usd(a.values[i]); }); + automationEl.setAttribute('aria-label', 'Daily spend by automation. ' + longDate(automationState.days[i]) + '. ' + + automationState.series.map((a) => a.name + ': ' + credTxt(a.values[i])).join('; ')); + } + function highlightAutomation() { + automationEl.querySelectorAll('[data-series]').forEach((el) => { + el.classList.toggle('highlighted', Number(el.dataset.series) === automationState.selected); + }); + $('automations-legend').querySelectorAll('[data-automation]').forEach((el) => { + el.setAttribute('aria-pressed', String(Number(el.dataset.automation) === automationState.selected)); + }); + } + $('automations-legend').addEventListener('click', (e) => { + const el = e.target.closest('[data-automation]'); if (!el) return; + const i = Number(el.dataset.automation); + automationState.selected = automationState.selected === i ? null : i; + highlightAutomation(); + }); + function readAutomationPointer(e) { + const g = automationState.geom; if (!g) return; + const r = automationEl.querySelector('svg').getBoundingClientRect(); + const xx = (e.clientX - r.left) * g.W / r.width; + const i = g.n === 1 ? 0 : Math.round((xx - g.L) / g.pw * (g.n - 1)); + showAutomationDay(Math.max(0, Math.min(g.n - 1, i))); + } + automationEl.addEventListener('pointermove', readAutomationPointer); + automationEl.addEventListener('pointerdown', readAutomationPointer); + automationEl.addEventListener('keydown', (e) => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key) || !automationState.geom) return; + e.preventDefault(); + const last = automationState.days.length - 1; + const i = e.key === 'Home' ? 0 : e.key === 'End' ? last : automationState.active + (e.key === 'ArrowLeft' ? -1 : 1); + showAutomationDay(Math.max(0, Math.min(last, i))); + }); + + /* ---------- Ranked list helper ---------- */ + function rankList(items, key, opts) { + opts = opts || {}; + const total = items.reduce((a, i) => a + i.c, 0); const max = Math.max(...items.map((i) => i.c), 1); + if (!items.length) return '

No spend under these filters.

'; + const shown = items.slice(0, opts.limit || items.length); + let h = '
    ' + shown.map((i) => { + const on = F[key] !== null && String(F[key]) === String(i.v); + return '
  • '; + }).join('') + '
'; + const rest = items.slice(shown.length); + if (rest.length) h += '

+ ' + rest.length + ' more, ' + credTxt(rest.reduce((a, i) => a + i.c, 0)) + ' combined.

'; + return h; + } + const groupSum = (rows, keyFn) => { const o = dict(); rows.forEach((f) => { const k = keyFn(f); o[k] = (o[k] || 0) + f.c; }); return o; }; + + function renderModels() { + const rows = rowsFor('m'); const g = groupSum(rows, (f) => f.m); + const items = Object.entries(g).map(([m, c]) => ({ v: m, label: modelName(Number(m)), c, color: FAMILY_COLOR[modelFam(Number(m))] })).sort((a, b) => b.c - a.c); + const tok = dict(); rows.forEach((f) => { tok[f.m] = (tok[f.m] || 0) + f.ti + f.tr + f.tw + f.to; }); + $('models').innerHTML = rankList(items, 'm', { limit: 10 }) + + '

' + Object.keys(FAMILY_COLOR).map((k) => '' + esc(k) + '').join('') + '

' + + (items.length ? '

' + esc(items[0].label) + ' alone accounts for ' + pct(items[0].c / items.reduce((a, i) => a + i.c, 0)) + ' of the credits shown.

' : ''); + } + function renderOwners() { + const ro = rowsFor('owner'); const go = groupSum(ro, (f) => f.owner); + $('owners').innerHTML = rankList(Object.entries(go).map(([v, c]) => ({ v, label: v, c })).sort((a, b) => b.c - a.c), 'owner', { limit: 8 }); + } + function renderSurfaces() { + const rows = rowsFor('surf'); const g = groupSum(rows, (f) => f.surf); + $('surfaces').innerHTML = rankList(Object.entries(g).map(([v, c]) => ({ v, label: v, c })).sort((a, b) => b.c - a.c), 'surf') + + '

Coding agent and code review sessions are not metered per token; see “Where these numbers come from”.

'; + } + function renderKinds() { + const rows = rowsFor('k'); const g = groupSum(rows, (f) => f.k); + const UNITEMISED = ['Session total']; + const items = Object.entries(g).map(([v, c]) => { + const name = D.kinds[Number(v)]; + return { v: Number(v), label: UNITEMISED.indexOf(name) >= 0 ? name + ' (not itemised)' : name, c, color: UNITEMISED.indexOf(name) >= 0 ? 'var(--rule-strong)' : null }; + }).sort((a, b) => (a.color ? 1 : 0) - (b.color ? 1 : 0) || b.c - a.c); + $('kinds').innerHTML = rankList(items, 'k', { noDot: true }) + + '

Call types come from per-call telemetry, available from ' + EVENTS_FROM + '. “Your prompt” is the first call after you hit enter; “Agent turn” covers the tool loop that follows.

'; + } + // What credits pay for, shared by the anatomy panel and the prompt-cache table. + const TOKEN_PARTS = [ + { k: 'cr', label: 'Cache reads', color: '#1b64c8', why: 'Re-reading the conversation so far on every model call.' }, + { k: 'cw', label: 'Cache writes', color: '#6fa2ea', why: 'Storing new context for later calls to reuse.' }, + { k: 'co', label: 'Output', color: '#0b2540', why: 'Tokens the model generates, including reasoning.' }, + { k: 'ci', label: 'Fresh input', color: '#b7d1f4', why: 'New, uncached input tokens.' }, + ]; + const UNITEMISED_PART = { label: 'Not itemised', color: '#d9e2ea', why: 'Session-level totals and early-period residuals carry no token split.' }; + function renderAnatomy() { + const rows = rowsFor(null); const c = sum(rows, 'c'); + const parts = TOKEN_PARTS.map((p) => Object.assign({}, p, { c: sum(rows, p.k) })); + const itemised = parts.reduce((a, p) => a + p.c, 0); + parts.push(Object.assign({}, UNITEMISED_PART, { c: Math.max(0, c - itemised) })); + $('anatomy').innerHTML = '' + + '
    ' + parts.map((p) => '
  • ' + p.label + '' + cu(p.c) + '' + pct(c ? p.c / c : 0) + '' + p.why + '
  • ').join('') + '
'; + } + function renderHours() { + const grid = Array.from({ length: 7 }, () => new Array(24).fill(0)); + hourly.forEach((r) => { if (match(r, null)) grid[r.dow][r.h] += r.c; }); + const total = grid.flat().reduce((a, v) => a + v, 0); + const max = Math.max(...grid.flat(), 1); const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const shade = (v) => { if (v <= 0) return 'var(--grid)'; const t = Math.sqrt(v / max); return 'color-mix(in srgb, var(--rain) ' + Math.round(12 + t * 88) + '%, #fff)'; }; + let h = '' + + (peak ? '

Busiest slot: ' + days[peak.d] + ' ' + String(peak.i).padStart(2, '0') + ':00, ' + credTxt(peak.v) + '. Grid covers ' + credTxt(total) + ' of per-call telemetry.

' : '

No per-call telemetry under these filters.

') + + '

' + esc(TZ) + ' time, per-call telemetry only (from ' + EVENTS_FROM + '). Darker = more credits. Follows every filter.

' + hourlyTable; + } + + /* ---------- Prompt cache ---------- */ + const mins = (s) => (s >= 3600 && s % 3600 === 0 ? s / 3600 + (s === 3600 ? ' hour' : ' hours') : Math.round(s / 60) + ' min'); + const CACHE_DESC = { + warm: 'Read the conversation so far back from the cache.', + expired: 'Came back after the cache lifetime (' + mins(CACHE_TTL.claude) + ' for Claude, ' + mins(CACHE_TTL.other) + ' for other models), so the whole conversation was written again.', + rebuilt: 'Within the cache lifetime, but most of the prompt was written again: a model switch, a compaction or a changed prompt.', + new: 'First call of a session, sub-agent or background task: instructions and tools are written to the cache.', + none: 'Session totals have no per-call record.', + }; + function paidBar(b) { + const parts = TOKEN_PARTS.map((p) => ({ label: p.label, color: p.color, v: b[p.k] })); + parts.push({ label: UNITEMISED_PART.label, color: UNITEMISED_PART.color, v: Math.max(0, b.c - parts.reduce((a, p) => a + p.v, 0)) }); + const said = parts.filter((p) => b.c && p.v / b.c >= 0.005).map((p) => p.label + ' ' + pct(p.v / b.c)).join(', '); + return '
' + + parts.map((p) => '').join('') + '' + + '' + (b.c && b.cw ? pct(b.cw / b.c) + ' writes' : '') + '
'; + } + function renderCache() { + const rows = rowsFor('cache'); + const by = CACHES.map(() => ({ c: 0, calls: 0, cr: 0, cw: 0, co: 0, ci: 0 })); + rows.forEach((f) => { const b = by[f.cache]; b.c += f.c; b.calls += f.calls; b.cr += f.cr; b.cw += f.cw; b.co += f.co; b.ci += f.ci; }); + const total = by.reduce((a, b) => a + b.c, 0); const max = Math.max(...by.map((b) => b.c), 1); + const idOf = (id) => CACHES.findIndex((x) => x.id === id); + const shown = CACHES.map((s, i) => i).filter((i) => by[i].c > 0 || F.cache === i); + const metered = shown.filter((i) => CACHES[i].id !== 'none'); + const mCredits = metered.reduce((a, i) => a + by[i].c, 0); const mCalls = metered.reduce((a, i) => a + by[i].calls, 0); + $('cache-h').textContent = 'Prompt cache: reused or rebuilt, ' + rangeText(F.from, F.to); + $('cache-table').innerHTML = + 'Prompt cacheSpentShare of ' + usd(total) + 'CallsPer callWhat it paid forCredits' + + (shown.length ? shown.map((i) => { + const s = CACHES[i], b = by[i], on = F.cache === i, none = s.id === 'none', perCall = !none && b.calls; + return '' + esc(CACHE_DESC[s.id] || '') + '' + + '' + usd(b.c) + '' + + '
' + pct(total ? b.c / total : 0) + '
' + + '' + (perCall ? fmt(b.calls) : '–') + '' + (perCall ? cuFine(b.c / b.calls) : '–') + '' + + '' + paidBar(b) + '' + cu(b.c) + ''; + }).join('') : 'No spend under these filters. Widen the date range or clear a filter.') + + '' + (shown.length > 1 ? 'Total' + usd(total) + '' + (mCalls ? fmt(mCalls) : '') + '' + cu(total) + '' : ''); + const W = by[idOf('warm')], X = by[idOf('expired')]; + let insight = ''; + if (W && X && W.calls && X.calls) { + const pw = W.c / W.calls, px = X.c / X.calls, r = px / pw; + insight = 'After a pause, a call cost ' + cuFine(px) + ' on average' + + (r >= 1.5 ? ': ' + (r >= 10 ? Math.round(r) : r.toFixed(1)) + ' times the ' + cuFine(pw) + ' of a call that reused the cache' : '') + + '. Those ' + plural(X.calls, 'call') + ' came to ' + credTxt(X.c) + ', ' + pct(mCredits ? X.c / mCredits : 0) + ' of the per-call metered credits shown.'; + } + $('cache-notes').innerHTML = + '
What it paid for:' + TOKEN_PARTS.concat([UNITEMISED_PART]).map((p) => '' + esc(p.label) + '').join('') + '
' + + (insight ? '

' + insight + '

' : '') + + '

Per-call telemetry, available from ' + EVENTS_FROM + '. How each call is classed is under “Where these numbers come from”.

'; + } + + /* ---------- Sessions ---------- */ + function renderSessions() { + const rows = rowsFor(null); const by = dict(); + rows.forEach((f) => { + const b = by[f.s] || (by[f.s] = { c: 0, calls: 0, tok: 0, first: f.day, models: {}, t: [0, 0] }); + b.c += f.c; b.calls += f.calls; b.tok += f.ti + f.tr + f.tw + f.to; if (f.day < b.first) b.first = f.day; b.models[f.m] = (b.models[f.m] || 0) + f.c; b.t[f.tier] += f.c; + }); + const top = Object.entries(by).sort((a, b) => b[1].c - a[1].c).slice(0, 10); + const all = rows.length ? sum(rows, 'c') : 0; + $('sessions-table').innerHTML = 'SessionFirst matching usageRepositorySurfaceMain modelCredits (USD)ShareCallsTokensBasis' + + top.map(([s, b]) => { + const sess = data.sessions[s]; const mm = Object.entries(b.models).sort((a, c) => c[1] - a[1])[0]; + return '' + esc(sess.id) + '' + shortDate(b.first) + '' + esc(repoLabel(D.repos[sess.repo])) + '' + esc(D.surfaces[sess.surface]) + '' + esc(modelName(Number(mm[0]))) + (Object.keys(b.models).length > 1 ? ' +' + (Object.keys(b.models).length - 1) + '' : '') + '' + + '' + cu(b.c) + '' + pct(all ? b.c / all : 0) + '' + (b.calls ? fmt(b.calls) : '–') + '' + (b.tok ? mtok(b.tok) : '–') + '' + + '
' + b.t.map((v, i) => '').join('') + '
'; + }).join('') + (top.length ? '' : 'No sessions under these filters.') + ''; + $('sess-h').textContent = 'Heaviest sessions, ' + rangeText(F.from, F.to) + (Object.keys(by).length > top.length ? ' (top ' + top.length + ' of ' + Object.keys(by).length + ')' : ''); + } + + /* ---------- Provenance ---------- */ + (function provenance() { + const t = M.tierTotals; + $('prov-body').innerHTML = + '

Consumption, not an invoice. These totals are your usage priced at list per-token rates, taken from the telemetry the Copilot CLI and Copilot app keep on this machine. GitHub’s own billing is the source of truth; usage on other machines, in the cloud or in IDEs is not in this ledger.

' + + '
' + + '

Two kinds of number

    ' + + '
  • Metered per call (' + cu(t['0'] || 0) + '). Exact nano-AIU recorded for every model call since ' + EVENTS_FROM + ', with day, model and call type.
  • ' + + '
  • Metered per session (' + cu(t['1'] || 0) + '). Exact credits, but only as a session total from the CLI shutdown log or the Copilot app. Placed on the day the session reported (or started), split by model where the log says so.
' + + '

Where sources overlap

A session can appear in several sources. Each session takes the largest total among them, and per-call rows win where they exist. Source totals before merging: per-call ' + cu(M.sourceTotals.events) + ', CLI shutdown logs ' + cu(M.sourceTotals.shutdown) + ', Copilot app ' + cu(M.sourceTotals.app) + '.

' + + '
' + + '

Not captured

    ' + + '
  • Cloud sessions. Copilot coding agent and code review sessions run in the cloud and leave no local per-token telemetry. Per GitHub’s billing rules their credits go to the requester or the organization.
  • ' + + '
  • Other surfaces and machines. Chat in IDEs, and any machine whose sessions are not in this computer’s telemetry. Code completions do not use credits.
' + + '

Assumptions

    ' + + (M.assumedRateModels.length ? '
  • Where a session has only a total, its “what credits pay for” split uses published per-token rates; models missing from the published table (' + M.assumedRateModels.map(esc).join(', ') + ') use their nearest sibling’s rate.
  • ' : '') + + '
  • Where per-session log totals dip between resumes, the running maximum is used. That matched the Copilot app’s own totals to within 4% on sessions that have both.
  • ' + + '
  • Prompt cache: a call reused the cache when at least ' + pct(M.cacheReuseMin) + ' of the previous call’s prompt (same session, agent and model) came back as cache reads. Otherwise its prompt was written again: “Expired after a pause” when that previous call went out longer ago than the model’s cache lifetime as recorded by the Copilot CLI (' + mins(CACHE_TTL.claude) + ' for Claude, ' + mins(CACHE_TTL.other) + ' for other models), else “Rebuilt mid-conversation”. The main agent, each sub-agent and the background calls are separate conversations.
  • ' + + '
  • Everything is read locally and read-only; prompts, session titles and file paths are never read into this view.
' + + '
'; + })(); + + render(); + } +})(); diff --git a/extensions/ai-credits-breakdown/ui/index.html b/extensions/ai-credits-breakdown/ui/index.html new file mode 100644 index 000000000..7a119aa8b --- /dev/null +++ b/extensions/ai-credits-breakdown/ui/index.html @@ -0,0 +1,95 @@ + + + + + + +AI Credits Breakdown + + + + + +
+
+

AI credits GitHub Copilot

+ +
+ + +
+ +
+

Reading your local Copilot usage…

+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+

1 AI credit = 1 AIU = $0.01. Months are UTC (as GitHub bills); the hour-of-week grid uses your local time zone. Built on this computer from your local Copilot telemetry; nothing leaves it, and no prompts, session titles or file paths are read.

+
+ + + + diff --git a/extensions/ai-credits-breakdown/ui/styles.css b/extensions/ai-credits-breakdown/ui/styles.css new file mode 100644 index 000000000..3aaac33ee --- /dev/null +++ b/extensions/ai-credits-breakdown/ui/styles.css @@ -0,0 +1,331 @@ +:root { + --paper: #f1f5f8; + --sheet: #ffffff; + --ink: #0b2540; + --ink-2: #3a4f64; + --ink-3: #566b7f; + --rule: #d3dce4; + --rule-strong: #9fb1c1; + --grid: #e6edf3; + --rain: #1b64c8; + --rain-soft: #8db7ee; + --rain-wash: #e6effb; + --opus: #5e35b1; + --sonnet: #b45a00; + --gpt: #00796b; + --other: #5f7284; + --focus: #0b57d0; + --warn: #8a4b00; + --warn-wash: #fff4e0; + --radius: 3px; + --font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --step--1: 0.8125rem; + --step-0: 0.875rem; + --step-1: 1rem; + --step-2: 1.125rem; + --step-3: 1.5rem; +} + +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: 400 var(--step-0)/1.5 var(--font); + font-variant-numeric: tabular-nums; +} +code { font: 0.92em ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--rain-wash); padding: 0.05em 0.35em; border-radius: 2px; } +a { color: var(--rain); } +button { font: inherit; color: inherit; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; } + +.skip { position: absolute; left: -999px; top: 0; background: var(--ink); color: #fff; padding: 0.5rem 0.75rem; z-index: 20; } +.skip:focus { left: 0.5rem; top: 0.5rem; } + +/* Masthead */ +.masthead { + background: var(--paper); + border-bottom: 2px solid var(--ink); + padding: 0.75rem clamp(1rem, 3vw, 2rem) 0; +} +@media (min-width: 901px) { + .masthead { position: sticky; top: 0; z-index: 10; box-shadow: 0 4px 12px -8px rgba(11, 37, 64, 0.3); } +} +.masthead-row { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +h1 { margin: 0; font-size: var(--step-2); font-weight: 700; letter-spacing: -0.01em; } +h1 .who { font-weight: 400; color: var(--ink-3); margin-left: 0.5rem; } +.stamp { + margin: 0 0 0.75rem; + padding: 0.5rem 0.75rem; + background: var(--rain-wash); + color: var(--ink); + font-size: var(--step-0); + font-weight: 600; +} + +/* Filter bar */ +.masthead-row { align-items: flex-end; padding-bottom: 0.55rem; } +.daterange { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem 0.6rem; } +.dimbar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 0.6rem; padding: 0 0 0.7rem; } +.fld { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; } +.fld label { font-size: 0.75rem; font-weight: 600; color: var(--ink-3); } +.fld select, .fld input { + font: inherit; font-size: var(--step--1); color: var(--ink); background: var(--sheet); + border: 1px solid var(--rule-strong); border-radius: var(--radius); height: 2rem; padding: 0 0.45rem; min-width: 0; width: 100%; +} +.fld select { padding-right: 1.4rem; text-overflow: ellipsis; } +.fld-period { width: 11.5rem; } +.fld-date { width: 9.25rem; } +.fld select:hover, .fld input:hover { border-color: var(--ink-3); } +.fld.set select { border-color: var(--rain); background: var(--rain-wash); font-weight: 600; } +.btn-more { display: none; } +.factions { display: flex; gap: 0.4rem; margin-left: 0.4rem; } +.btn { + appearance: none; height: 2rem; padding: 0 0.75rem; border: 1px solid var(--rule-strong); border-radius: var(--radius); + background: var(--sheet); color: var(--ink); font-size: var(--step--1); font-weight: 600; cursor: pointer; + transition: background 120ms ease-out, border-color 120ms ease-out; +} +.btn:hover:not(:disabled) { background: var(--rain-wash); border-color: var(--rain); } +.btn:disabled { color: var(--ink-3); cursor: default; opacity: 0.7; } + +/* Page */ +main { max-width: 1440px; margin: 0 auto; padding: 1.25rem clamp(1rem, 3vw, 2rem) 2rem; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 1.25rem; align-items: start; } +.span-12 { grid-column: span 12; } +.span-8 { grid-column: span 8; } +.span-4 { grid-column: span 4; } +.stack { display: grid; gap: 1.25rem; min-width: 0; } +@media (max-width: 1100px) { + .span-8, .span-4 { grid-column: span 12; } + .stack { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } + #kinds-sheet, #anat-sheet { grid-column: span 6; } +} +.loading { color: var(--ink-3); } +[hidden] { display: none !important; } + +.statement { padding: 0.5rem 0 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem 2.5rem; align-items: end; } +@media (min-width: 1101px) { .statement { grid-template-columns: minmax(0, 1fr) minmax(20rem, 27rem); } } +.basis-h { margin: 0 0 0.35rem; font-size: var(--step--1); font-weight: 600; color: var(--ink-2); } +.statement .lead { margin: 0; font-size: clamp(1.375rem, 2.4vw + 0.7rem, 2rem); font-weight: 400; line-height: 1.25; letter-spacing: -0.015em; max-width: 40ch; text-wrap: balance; } +.statement .amt { font-size: clamp(2rem, 4.5vw + 0.8rem, 3.25rem); font-weight: 700; letter-spacing: -0.03em; color: var(--ink); white-space: nowrap; } +.statement .cr { display: block; margin-top: 0.35rem; color: var(--ink-3); font-size: var(--step--1); } +.facts { display: flex; flex-wrap: wrap; margin: 1rem 0 0; border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); max-width: 960px; } +.facts > div { padding: 0.55rem 1rem 0.6rem 0; margin-right: 1rem; border-right: 1px solid var(--rule); } +.facts > div:last-child { border-right: 0; margin-right: 0; } +.facts dt { font-size: var(--step--1); color: var(--ink-3); } +.facts dd { margin: 0.1rem 0 0; font-size: var(--step-1); font-weight: 600; color: var(--ink); } +.chg { font-size: var(--step--1); font-weight: 600; color: var(--ink-2); white-space: nowrap; } +.statement .note { margin-top: 0.5rem; } +.filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0 0; align-items: center; } +.filters .label { color: var(--ink-3); font-size: var(--step--1); } +.tag { + display: inline-flex; gap: 0.4rem; align-items: center; background: var(--rain-wash); border: 1px solid var(--rain-soft); + border-radius: var(--radius); padding: 0.15rem 0.3rem 0.15rem 0.55rem; font-size: var(--step--1); color: var(--ink); +} +.tag button { appearance: none; border: 0; background: transparent; cursor: pointer; padding: 0.1rem 0.4rem; color: var(--ink-2); font-size: 1rem; line-height: 1; border-radius: 2px; } +.tag button:hover { background: #fff; color: var(--ink); } +.linkbtn { appearance: none; background: transparent; border: 0; color: var(--rain); text-decoration: underline; cursor: pointer; padding: 0.15rem 0.25rem; font-size: var(--step--1); } + +.basis { max-width: 760px; } +.basis-bar { display: flex; height: 10px; border: 1px solid var(--rule-strong); background: #fff; } +.basis-bar span { display: block; height: 100%; } +.basis-key { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin-top: 0.4rem; font-size: var(--step--1); color: var(--ink-2); } +.sw { display: inline-block; width: 0.85rem; height: 0.7rem; vertical-align: -0.05rem; margin-right: 0.35rem; border: 1px solid var(--rain); } +.sw.t0 { background: var(--rain); } +.sw.t1 { background: var(--rain-soft); border-color: var(--rain-soft); } + +/* Sheets */ +.sheet { background: var(--sheet); border-top: 2px solid var(--ink); border-bottom: 1px solid var(--rule); padding: 0.9rem clamp(0.75rem, 2vw, 1.25rem) 1.1rem; min-width: 0; } +.sheet h2 { margin: 0 0 0.15rem; font-size: var(--step-1); font-weight: 700; } +.sheet .sub { margin: 0 0 0.75rem; color: var(--ink-3); font-size: var(--step--1); max-width: 70ch; } +.stack .sheet h2, #models-sheet h2, #kinds-sheet h2, #anat-sheet h2, #hours-sheet h2 { margin-bottom: 0.6rem; } +.empty { color: var(--ink-3); font-size: var(--step--1); margin: 0.25rem 0 0; } +.keybtn { appearance: none; background: transparent; border: 0; padding: 0.1rem 0.25rem; margin-left: -0.25rem; border-radius: 2px; cursor: pointer; font-size: inherit; color: inherit; } +.keybtn:hover { background: var(--rain-wash); } +.keybtn[aria-pressed="true"] { background: var(--rain-wash); outline: 1px solid var(--rain-soft); color: var(--ink); font-weight: 600; } + +/* Rain chart */ +.rain { position: relative; outline-offset: 4px; } +.rain svg { display: block; width: 100%; height: auto; overflow: visible; } +.rain text { font: 400 11px var(--font); fill: var(--ink-3); font-variant-numeric: tabular-nums; } +.rain .ax { stroke: var(--rule-strong); stroke-width: 1; } +.rain .gl { stroke: var(--grid); stroke-width: 1; } +.rain .mo { stroke: var(--rule); stroke-width: 1; stroke-dasharray: 2 3; } +.rain .cross { stroke: var(--ink); stroke-width: 1; pointer-events: none; } +.rain svg { cursor: crosshair; touch-action: pan-y; user-select: none; -webkit-user-select: none; } +.rain .brush { fill: var(--rain); fill-opacity: 0.12; stroke: var(--rain); stroke-width: 1; pointer-events: none; } +.rain .t0 { fill: var(--rain); } +.rain .t1 { fill: var(--rain-soft); } +.rain .curve { fill: none; stroke: var(--ink); stroke-width: 2; stroke-linejoin: round; } +.rain .wash { fill: var(--rain-wash); } +.rain .endlabel { fill: var(--ink); font-weight: 700; } +.rain .sub { fill: var(--ink-3); font-size: 10px; } +.rain .moname { fill: var(--ink-2); font-weight: 600; } +.rain .cap { fill: var(--ink-2); font-weight: 600; } +.legend { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin-top: 0.5rem; font-size: var(--step--1); color: var(--ink-2); } + +/* Automation chart */ +.automation-summary { margin: 0 0 0.75rem; max-width: none; } +.automation-chart { min-width: 0; } +.automation-chart svg { display: block; width: 100%; height: auto; cursor: crosshair; touch-action: pan-y; } +.automation-chart text { font: 400 11px var(--font); fill: var(--ink-3); } +.automation-chart .cap { fill: var(--ink-2); font-weight: 600; } +.automation-chart .gl { stroke: var(--grid); stroke-width: 1; } +.automation-chart .cross { stroke: var(--rule-strong); stroke-width: 1; stroke-dasharray: 3 3; pointer-events: none; } +.automation-line { fill: none; stroke: var(--series-color); stroke-width: 2; stroke-linejoin: round; } +.automation-series circle { fill: var(--series-color); } +.automation-series.highlighted .automation-line { stroke-width: 4; } +.automation-series.highlighted circle { r: 4; } +.automation-legend { margin-top: 0.75rem; } +.automation-legend th[scope="row"] { text-align: left; font-weight: 400; white-space: normal; min-width: 10rem; } +.automation-key { display: inline-flex; gap: 0.6rem; align-items: center; text-align: left; min-height: 2rem; } +.automation-swatch { flex: 0 0 2rem; width: 2rem; height: 0.75rem; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } + +/* Tables */ +.scroll { overflow-x: auto; } +.tbl { width: 100%; border-collapse: collapse; font-size: var(--step-0); } +.tbl th, .tbl td { padding: 0.45rem 0.6rem; text-align: right; border-bottom: 1px solid var(--rule); white-space: nowrap; } +.tbl th:first-child, .tbl td:first-child, .tbl .left { text-align: left; } +.tbl thead th { font-size: var(--step--1); font-weight: 600; color: var(--ink-2); border-bottom: 1px solid var(--rule-strong); vertical-align: bottom; } +.tbl tbody tr:hover { background: #f7fafc; } +.tbl tfoot td { font-weight: 700; border-top: 2px solid var(--ink); border-bottom: 0; } +.tbl .rowbtn { appearance: none; background: transparent; border: 0; padding: 0; cursor: pointer; font-weight: 600; text-align: left; text-decoration: underline; text-decoration-color: var(--rule-strong); text-underline-offset: 3px; } +.tbl .rowbtn:hover { text-decoration-color: var(--rain); } +.tbl tr.on { background: var(--rain-wash); } +.tbl .bar { width: 9rem; min-width: 6rem; } +.repos td { padding-top: 0.5rem; padding-bottom: 0.5rem; } +.repos .rowbtn { font-weight: 600; } +.repos .owner { color: var(--ink-3); font-weight: 400; } +.repos .spent, .caches .spent { font-size: var(--step-1); font-weight: 700; color: var(--ink); } +.repos .share { width: 22%; min-width: 8rem; text-align: left; } +@media (max-width: 1499px) and (min-width: 1101px) { .repos .opt.left { display: none; } } +.sharewrap { display: flex; align-items: center; gap: 0.6rem; } +.sharebar { flex: 1; height: 10px; background: var(--grid); display: block; } +.sharebar i { display: block; height: 100%; background: var(--rain); } +.repos .pct, .caches .pct { min-width: 2.6rem; color: var(--ink-2); font-size: var(--step--1); text-align: right; } +.repos tfoot .spent, .caches tfoot .spent { font-size: var(--step-1); } + +/* Prompt cache table */ +.caches td { padding-top: 0.55rem; padding-bottom: 0.55rem; vertical-align: baseline; } +.caches .state { white-space: normal; min-width: 15rem; } +.caches .desc { display: block; margin-top: 0.15rem; max-width: 52ch; color: var(--ink-3); font-size: var(--step--1); line-height: 1.4; } +.caches .share { width: 17%; min-width: 7rem; text-align: left; } +.caches .paid { width: 19%; min-width: 11rem; } +.caches .paid .pct { min-width: 5.5rem; text-align: left; } +.caches tr.none .sharebar i { background: var(--rule-strong); } +.paidwrap { display: flex; align-items: center; gap: 0.6rem; } +.paidbar { flex: 1; display: flex; height: 10px; min-width: 5rem; border: 1px solid var(--rule-strong); background: #fff; } +.paidbar i { display: block; height: 100%; } +.cache-legend { margin-top: 0.75rem; } +.cache-legend .label { font-weight: 600; } +.hang { position: relative; height: 14px; } +.hang i { position: absolute; left: 0; top: 0; height: 100%; background: var(--rain); display: block; } +.hang i.t1 { background: var(--rain-soft); } +.mini { display: flex; width: 4.5rem; height: 8px; border: 1px solid var(--rule-strong); margin-left: auto; } +.mini i { display: block; height: 100%; } +.mini i.t0 { background: var(--rain); } +.mini i.t1 { background: var(--rain-soft); } +.muted { color: var(--ink-3); } +.partial { color: var(--ink-3); font-weight: 400; font-size: var(--step--1); } + +/* Ranked lists */ +.rank { list-style: none; margin: 0; padding: 0; display: grid; gap: 1px; } +.rank li { margin: 0; } +.rank button { + appearance: none; width: 100%; background: transparent; border: 0; text-align: left; cursor: pointer; + display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 0.15rem 0.75rem; align-items: baseline; + padding: 0.4rem 0.5rem; border-radius: var(--radius); position: relative; +} +.rank button:hover { background: #f4f8fb; } +.rank button[aria-pressed="true"] { background: var(--rain-wash); outline: 1px solid var(--rain-soft); } +.rank .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +.rank .val { font-weight: 600; } +.rank .pct { color: var(--ink-3); min-width: 3.2rem; text-align: right; font-size: var(--step--1); } +.rank .track { grid-column: 1 / -1; height: 6px; background: var(--grid); } +.rank .track i { display: block; height: 100%; background: var(--c, var(--rain)); } +.dot { display: inline-block; width: 0.6rem; height: 0.6rem; margin-right: 0.45rem; background: var(--c, var(--rain)); border-radius: 1px; } +.fam { margin: 0.75rem 0 0; display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; font-size: var(--step--1); color: var(--ink-2); } +.sub-h { margin: 1rem 0 0.35rem; font-size: var(--step--1); font-weight: 600; color: var(--ink-2); } +.more { margin-top: 0.4rem; } + +/* Anatomy */ +.anat-bar { display: flex; height: 22px; border: 1px solid var(--rule-strong); } +.anat-bar i { display: block; height: 100%; border-right: 1px solid #fff; } +.anat-bar i:last-child { border-right: 0; } +.anat-list { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.35rem; } +.anat-list li { display: grid; grid-template-columns: 1fr auto auto; gap: 0.75rem; align-items: baseline; } +.anat-list .why { grid-column: 1 / -1; color: var(--ink-3); font-size: var(--step--1); margin-top: -0.2rem; } +.note { color: var(--ink-3); font-size: var(--step--1); margin: 0.75rem 0 0; max-width: 62ch; } + +/* Hours */ +.hours { display: grid; grid-template-columns: 2.2rem repeat(24, minmax(0, 1fr)); gap: 2px; align-items: center; } +.hours .d, .hours .h { font-size: 0.6875rem; color: var(--ink-3); } +.hours .h { text-align: left; } +.hours .cell { aspect-ratio: 1 / 1; background: var(--grid); border-radius: 1px; } + +/* Notes */ +.notes > summary { cursor: pointer; list-style: none; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.75rem; } +.notes > summary::-webkit-details-marker { display: none; } +.notes > summary h2 { margin: 0; display: inline; } +.notes > summary h2::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--ink-3); transition: transform 120ms ease-out; } +.notes[open] > summary h2::before { transform: rotate(90deg); } +.notes > summary .sub { margin: 0; } +.notes[open] > summary { margin-bottom: 0.9rem; } +.notes .cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; } +.notes h3 { margin: 0 0 0.35rem; font-size: var(--step-0); } +.notes p, .notes li { max-width: 62ch; margin: 0 0 0.5rem; color: var(--ink-2); } +.notes ul { margin: 0 0 0.5rem; padding-left: 1.1rem; } +.callout { background: var(--warn-wash); border: 1px solid #e7c78f; padding: 0.7rem 0.85rem; border-radius: var(--radius); color: var(--ink); max-width: 70ch; } +.callout strong { color: var(--warn); } +.callout p { color: var(--ink); margin: 0 0 0.3rem; } +.callout p:last-child { margin: 0; } + +.foot { max-width: 1440px; margin: 0 auto; padding: 0 clamp(1rem, 3vw, 2rem) 2rem; color: var(--ink-3); font-size: var(--step--1); } + +.tip { + position: fixed; z-index: 30; pointer-events: none; background: var(--ink); color: #fff; padding: 0.5rem 0.65rem; border-radius: var(--radius); + font-size: var(--step--1); line-height: 1.4; max-width: 18rem; box-shadow: 0 4px 14px rgba(11, 37, 64, 0.25); +} +.tip b { font-weight: 700; } +.tip .row { display: flex; justify-content: space-between; gap: 1rem; } + +@media (max-width: 900px) { + .notes .cols { grid-template-columns: minmax(0, 1fr); } + .board > * { grid-column: span 12 !important; } + .stack { grid-template-columns: minmax(0, 1fr); } + .dimbar { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .factions { margin-left: 0; } + h1 .who { display: block; margin-left: 0; } + .tbl .bar, .repos .opt, .caches .opt { display: none; } + .facts > div { border-right: 0; flex: 1 1 100%; margin-right: 0; padding-right: 0; } + .facts > div + div { border-top: 1px solid var(--grid); } +} +@media (max-width: 640px) { + .masthead-row { flex-direction: column; align-items: stretch; } + .daterange { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + .daterange[hidden] { display: none; } + .fld-period, .fld-date { width: auto; } + .fld-period, .factions { grid-column: span 2; } + .factions .btn { flex: 1; padding: 0 0.4rem; } + .btn-more { display: inline-block; } + .btn-more[aria-expanded="true"] { background: var(--rain-wash); border-color: var(--rain); } + .dimbar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dimbar:not(.open) { display: none; } + .repos .share, .repos .cred, .caches .share, .caches .cred { display: none; } + .caches th, .caches td { padding-left: 0.45rem; padding-right: 0.45rem; } + .caches .state { min-width: 9.5rem; } + .repos td:first-child { white-space: normal; } + .repos .owner { display: block; font-size: var(--step--1); } +} +@media (max-width: 520px) { + body { font-size: 0.8125rem; } + .hours { grid-template-columns: 1.8rem repeat(24, minmax(0, 1fr)); gap: 1px; } +} +@media (prefers-reduced-motion: reduce) { + * { transition: none !important; } +} +@media print { + .masthead { position: static; } + .daterange, .dimbar { display: none; } +} diff --git a/plugins/ai-credits-breakdown/README.md b/plugins/ai-credits-breakdown/README.md new file mode 100644 index 000000000..1369d0f12 --- /dev/null +++ b/plugins/ai-credits-breakdown/README.md @@ -0,0 +1,22 @@ +# AI Credits Breakdown Plugin + +See exactly where your own GitHub Copilot AI credits go: dollars by day, repository, model, call type and prompt-cache state, built privately on your computer from your Copilot telemetry. + +## Requirements + +- GitHub Copilot app with canvas support +- Node 22.13+ (the extension uses `node:sqlite` and will not load on older Node versions) + +## Installation + +```bash +copilot plugin install ai-credits-breakdown@awesome-copilot +``` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). See the [extension README](../../extensions/ai-credits-breakdown/README.md) for what it shows and exactly what it reads. + +## License + +MIT diff --git a/plugins/ai-credits-breakdown/plugin.json b/plugins/ai-credits-breakdown/plugin.json new file mode 100644 index 000000000..9cfa59a75 --- /dev/null +++ b/plugins/ai-credits-breakdown/plugin.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "ai-credits-breakdown", + "description": "See exactly where your own GitHub Copilot AI credits go: dollars by day, repository, model, call type and prompt-cache state, built privately on your computer from your Copilot telemetry.", + "version": "1.0.0", + "author": { + "name": "Roza Jiang", + "url": "https://github.com/rozajay" + }, + "repository": "https://github.com/github/awesome-copilot", + "license": "MIT", + "keywords": [ + "ai-credits", + "canvas", + "copilot-extension", + "cost-tracking", + "token-usage", + "usage-analytics" + ], + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + }, + "com.github.awesome-copilot": { + "extensions": [ + "./extensions/ai-credits-breakdown" + ] + } + } +}