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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ GitHub Copilot CLI ──OTel spans (JSONL)──▶ ~/.copilot/otel/copilot-ote
export COPILOT_OTEL_FILE_EXPORTER_PATH="$HOME/.copilot/otel/copilot-otel.jsonl"
```
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.
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.
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.
4. **Render** — a one-line statusline, plus an optional local web dashboard.

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

---

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

---

Expand All @@ -157,7 +157,7 @@ More on the underlying telemetry pipeline: [Copilot OpenTelemetry observability]
- **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/`.
- **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.
- **The dashboard will not bind.** Use a local host only, e.g. `copilot-cost dashboard --host 127.0.0.1 --port 4567`.
- **Pricing looks stale.** Run `copilot-cost refresh-pricing --force`.
- **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.

---

Expand Down
88 changes: 44 additions & 44 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@
"tailwindcss": "^3.4.13",
"tsup": "^8.3.0",
"typescript": "^5.6.2",
"vitest": "^3.2.4"
"vitest": "^3.2.7"
}
}
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ async function readStdin(): Promise<string> {
return Buffer.concat(chunks).toString("utf-8");
}

async function refreshStalePricing(): Promise<void> {
if (process.env.COPILOT_COST_PRICING || process.env.COPILOT_COST_AUTO_REFRESH === "0") return;
await refreshPricing();
}

async function renderCommand(): Promise<void> {
try {
const payload = JSON.parse(await readStdin()) as unknown;
await refreshStalePricing();
console.log(renderPayload(payload));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -54,6 +60,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
.option("--host <host>", "host to listen on")
.option("--no-open", "do not open the dashboard in a browser")
.action(async (opts: { port?: number; host?: string; open?: boolean }) => {
await refreshStalePricing();
await cmdDashboard({ port: opts.port, host: opts.host, noOpen: opts.open === false });
});
try {
Expand Down
5 changes: 3 additions & 2 deletions src/otel/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ export function normalizeSpan(record: unknown): NormalizedCall | null {
if (!isObject(record) || !isChatSpan(record)) return null;
const a = attrs(record);
const rawModel = str(a["gen_ai.request.model"] ?? a["gen_ai.response.model"] ?? a["model"] ?? record.model);
const model = normalizeModel(rawModel) ?? rawModel;
const pricedModel = getModelPrice(rawModel);
const model = pricedModel.price ? pricedModel.model : normalizeModel(rawModel) ?? rawModel;
if (!model) return null;

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

return {
Expand Down
Loading
Loading