Skip to content

Commit 43d5f32

Browse files
authored
Refresh pricing dynamically from GitHub Docs (#9)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1adb4444-9149-4f9b-97b6-6dcd0847ba45
1 parent ac6dd32 commit 43d5f32

11 files changed

Lines changed: 281 additions & 76 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ GitHub Copilot CLI ──OTel spans (JSONL)──▶ ~/.copilot/otel/copilot-ote
122122
export COPILOT_OTEL_FILE_EXPORTER_PATH="$HOME/.copilot/otel/copilot-otel.jsonl"
123123
```
124124
2. **Aggregate** — on every render, recent spans are read, `gen_ai.usage.*` counters and `gen_ai.request.model` are extracted, and rolled up by session / model / day.
125-
3. **Estimate** — token counts are multiplied by a bundled pricing snapshot from GitHub's public [Copilot models & pricing](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing) table to estimate USD and GitHub AI Credits (AIC). This is not billing data; run `copilot-cost refresh-pricing --force` to refresh the local pricing cache when GitHub updates its published prices.
125+
3. **Estimate** — token counts are multiplied by prices from GitHub's public [Copilot models & pricing](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing) table to estimate USD and GitHub AI Credits (AIC). The local cache refreshes automatically when it is older than seven days, retains the last-known-good data if GitHub is unavailable, and falls back to the bundled snapshot when no cache exists. Long-context rates are selected from the published input-token thresholds. This is not billing data.
126126
4. **Render** — a one-line statusline, plus an optional local web dashboard.
127127

128128
More on the underlying telemetry pipeline: [Copilot OpenTelemetry observability](https://docs.github.com/en/copilot/how-tos/copilot-sdk/observability/opentelemetry).
@@ -134,7 +134,7 @@ More on the underlying telemetry pipeline: [Copilot OpenTelemetry observability]
134134
- ✅ Usage data **stays on your machine**.
135135
- ✅ The dashboard only supports local binds (`127.0.0.1` or `localhost`).
136136
- ✅ This package emits **no telemetry or analytics** of its own.
137-
- 🌐 Pricing refresh contacts `docs.github.com` only when requested or when the cache needs refreshing.
137+
- 🌐 Pricing refresh contacts GitHub's public Docs pricing source only when requested or when the cache needs refreshing.
138138

139139
---
140140

@@ -147,7 +147,7 @@ More on the underlying telemetry pipeline: [Copilot OpenTelemetry observability]
147147
| `uninstall [--yes]` | Remove settings installed by this package when they point at this tool. |
148148
| `doctor` | Check statusline setup, OpenTelemetry output, pricing, and dashboard readiness. |
149149
| `dashboard [--port <n>] [--host <h>] [--no-open]` | Serve the local dashboard. |
150-
| `refresh-pricing [--force]` | Refresh model pricing; `--force` bypasses the cache TTL. |
150+
| `refresh-pricing [--force]` | Refresh model pricing now; `--force` bypasses the seven-day cache TTL. |
151151

152152
---
153153

@@ -157,7 +157,7 @@ More on the underlying telemetry pipeline: [Copilot OpenTelemetry observability]
157157
- **No usage shows up yet.** Make sure you're on the latest Copilot CLI, restart your shell and `copilot`, send a prompt, then check for JSONL files in `~/.copilot/otel/`.
158158
- **I do not want profile edits.** Use `copilot-cost install --no-otel-profile` and paste the printed OpenTelemetry block into the shell profile you choose.
159159
- **The dashboard will not bind.** Use a local host only, e.g. `copilot-cost dashboard --host 127.0.0.1 --port 4567`.
160-
- **Pricing looks stale.** Run `copilot-cost refresh-pricing --force`.
160+
- **Pricing looks stale.** Run `copilot-cost refresh-pricing --force`. Set `COPILOT_COST_REFRESH_DAYS` to change the automatic refresh interval, or `COPILOT_COST_AUTO_REFRESH=0` to disable automatic refreshes.
161161

162162
---
163163

package-lock.json

Lines changed: 44 additions & 44 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,6 @@
6161
"tailwindcss": "^3.4.13",
6262
"tsup": "^8.3.0",
6363
"typescript": "^5.6.2",
64-
"vitest": "^3.2.4"
64+
"vitest": "^3.2.7"
6565
}
6666
}

src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@ async function readStdin(): Promise<string> {
1313
return Buffer.concat(chunks).toString("utf-8");
1414
}
1515

16+
async function refreshStalePricing(): Promise<void> {
17+
if (process.env.COPILOT_COST_PRICING || process.env.COPILOT_COST_AUTO_REFRESH === "0") return;
18+
await refreshPricing();
19+
}
20+
1621
async function renderCommand(): Promise<void> {
1722
try {
1823
const payload = JSON.parse(await readStdin()) as unknown;
24+
await refreshStalePricing();
1925
console.log(renderPayload(payload));
2026
} catch (error) {
2127
const message = error instanceof Error ? error.message : String(error);
@@ -54,6 +60,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
5460
.option("--host <host>", "host to listen on")
5561
.option("--no-open", "do not open the dashboard in a browser")
5662
.action(async (opts: { port?: number; host?: string; open?: boolean }) => {
63+
await refreshStalePricing();
5764
await cmdDashboard({ port: opts.port, host: opts.host, noOpen: opts.open === false });
5865
});
5966
try {

src/otel/parser.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ export function normalizeSpan(record: unknown): NormalizedCall | null {
111111
if (!isObject(record) || !isChatSpan(record)) return null;
112112
const a = attrs(record);
113113
const rawModel = str(a["gen_ai.request.model"] ?? a["gen_ai.response.model"] ?? a["model"] ?? record.model);
114-
const model = normalizeModel(rawModel) ?? rawModel;
114+
const pricedModel = getModelPrice(rawModel);
115+
const model = pricedModel.price ? pricedModel.model : normalizeModel(rawModel) ?? rawModel;
115116
if (!model) return null;
116117

117118
const rawInput = num(a["gen_ai.usage.input_tokens"], a.input_tokens, record.input_tokens);
@@ -134,7 +135,7 @@ export function normalizeSpan(record: unknown): NormalizedCall | null {
134135
const responseId = str(a["gen_ai.response.id"] ?? record["gen_ai.response.id"]);
135136
const existingDedup = str(record.dedup_key);
136137
const dedupKey = existingDedup ?? (traceId && spanId ? `${traceId}:${spanId}` : responseId ?? (isLogRecord ? `${model}:${JSON.stringify(record.hrTime)}` : spanId ?? hashRecord(record)));
137-
const { price } = getModelPrice(model);
138+
const { price } = pricedModel;
138139
const usdCost = price ? computeCost({ input: freshInput + cacheRead + cacheCreation, cache_read: cacheRead, cache_write: cacheCreation, output }, price) : 0;
139140

140141
return {

0 commit comments

Comments
 (0)