diff --git a/README.md b/README.md index 2035768..2868805 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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. --- @@ -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 ] [--host ] [--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. | --- @@ -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. --- diff --git a/package-lock.json b/package-lock.json index 0581d80..286507d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "tailwindcss": "^3.4.13", "tsup": "^8.3.0", "typescript": "^5.6.2", - "vitest": "^3.2.4" + "vitest": "^3.2.7" }, "engines": { "node": ">=18" @@ -967,15 +967,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -984,13 +984,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -1011,9 +1011,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1024,13 +1024,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -1039,13 +1039,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -1054,9 +1054,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1067,13 +1067,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -2632,13 +2632,13 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -2761,20 +2761,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -2804,8 +2804,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, diff --git a/package.json b/package.json index 03f6c4e..b07e243 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,6 @@ "tailwindcss": "^3.4.13", "tsup": "^8.3.0", "typescript": "^5.6.2", - "vitest": "^3.2.4" + "vitest": "^3.2.7" } } diff --git a/src/cli.ts b/src/cli.ts index 0c947a2..340b763 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,9 +13,15 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString("utf-8"); } +async function refreshStalePricing(): Promise { + if (process.env.COPILOT_COST_PRICING || process.env.COPILOT_COST_AUTO_REFRESH === "0") return; + await refreshPricing(); +} + async function renderCommand(): Promise { 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); @@ -54,6 +60,7 @@ export async function main(argv = process.argv.slice(2)): Promise { .option("--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 { diff --git a/src/otel/parser.ts b/src/otel/parser.ts index 7d01541..952f21c 100644 --- a/src/otel/parser.ts +++ b/src/otel/parser.ts @@ -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); @@ -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 { diff --git a/src/pricing/fetcher.ts b/src/pricing/fetcher.ts index 19fe3a1..ad47b08 100644 --- a/src/pricing/fetcher.ts +++ b/src/pricing/fetcher.ts @@ -1,7 +1,7 @@ -import { copyFileSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; -import { CACHE_PRICING, SNAPSHOT, type ModelPrice, type Pricing } from "./loader.js"; +import { CACHE_PRICING, SNAPSHOT, clearPricingCache, type ModelPrice, type Pricing } from "./loader.js"; import { parseDollar, splitLines, stripComment, unquote } from "./yaml-utils.js"; export type PricingData = Pricing; @@ -18,10 +18,13 @@ const VENDOR_ALIASES: Record = { google: "google", xai: "xai", github: "github", + microsoft: "microsoft", + moonshot_ai: "moonshot_ai", }; function normalizeModelName(raw: string): string { return unquote(raw) + .replace(/\s*\(fast mode\)\s*\(preview\)\s*$/i, " fast") .replace(/\[\^[^\]]+\]/g, "") .trim() .toLowerCase() @@ -32,12 +35,23 @@ interface RawEntry { model?: string; provider?: string; category?: string; + threshold?: string; + tier?: string; input?: string; cached_input?: string; output?: string; cache_write?: string; } +function parseTokenThreshold(raw: string | undefined): number | null { + if (!raw) return null; + const match = /(\d+(?:\.\d+)?)\s*([km])?/i.exec(unquote(raw).replace(/,/g, "")); + if (!match?.[1]) return null; + const value = Number.parseFloat(match[1]); + const multiplier = match[2]?.toLowerCase() === "m" ? 1_000_000 : match[2]?.toLowerCase() === "k" ? 1_000 : 1; + return Number.isFinite(value) ? Math.trunc(value * multiplier) : null; +} + export function parsePricingYaml(text: string): PricingData { const lines = splitLines(text); const entries: RawEntry[] = []; @@ -65,12 +79,17 @@ export function parsePricingYaml(text: string): PricingData { } const models: Record = {}; + const longContextRows = new Map(); for (const entry of entries) { if (!entry.model || !entry.provider) continue; const vendor = VENDOR_ALIASES[entry.provider.trim().toLowerCase()]; if (!vendor) continue; const id = normalizeModelName(entry.model); if (!id) continue; + if (unquote(entry.tier ?? "").trim().toLowerCase() === "long context") { + longContextRows.set(id, entry); + continue; + } const row: ModelPrice = { vendor, input: parseDollar(entry.input), @@ -81,11 +100,24 @@ export function parsePricingYaml(text: string): PricingData { row.cache_write = parseDollar(entry.cache_write); } if (entry.category) { - (row as unknown as Record).category = unquote(entry.category).toLowerCase(); + row.category = unquote(entry.category).toLowerCase(); } models[id] = row; } + for (const [id, entry] of longContextRows) { + const row = models[id]; + const threshold = parseTokenThreshold(entry.threshold); + if (!row || threshold == null) continue; + row.long_context_threshold = threshold; + row.long_context_input = parseDollar(entry.input); + row.long_context_cached_input = parseDollar(entry.cached_input); + row.long_context_output = parseDollar(entry.output); + if (entry.cache_write != null) { + row.long_context_cache_write = parseDollar(entry.cache_write); + } + } + if (Object.keys(models).length < 3) { throw new Error("could not parse enough model pricing rows"); } @@ -109,28 +141,54 @@ export function dumpYaml(data: PricingData): string { } export function cacheIsFresh(pricingPath = CACHE_PRICING): boolean { - const days = Number.parseInt(process.env.COPILOT_COST_REFRESH_DAYS ?? "7", 10); + const configuredDays = Number.parseFloat(process.env.COPILOT_COST_REFRESH_DAYS ?? "7"); + const days = Number.isFinite(configuredDays) && configuredDays >= 0 ? configuredDays : 7; if (!existsSync(pricingPath)) return false; const ageMs = Date.now() - statSync(pricingPath).mtimeMs; return ageMs < days * 24 * 60 * 60 * 1000; } +function retryMarker(dest: string): string { + return `${dest}.last-attempt`; +} + +function refreshAttemptIsRecent(dest: string): boolean { + const marker = retryMarker(dest); + if (!existsSync(marker)) return false; + const configuredMinutes = Number.parseFloat(process.env.COPILOT_COST_REFRESH_RETRY_MINUTES ?? "60"); + const minutes = Number.isFinite(configuredMinutes) && configuredMinutes >= 0 ? configuredMinutes : 60; + return Date.now() - statSync(marker).mtimeMs < minutes * 60 * 1000; +} + +function writeAtomically(dest: string, contents: string): void { + const temporary = `${dest}.${process.pid}.tmp`; + try { + writeFileSync(temporary, contents, "utf-8"); + renameSync(temporary, dest); + } finally { + rmSync(temporary, { force: true }); + } +} + export async function refreshPricing(opts: { force?: boolean; dest?: string } = {}): Promise { const dest = opts.dest ?? CACHE_PRICING; - mkdirSync(path.dirname(dest), { recursive: true }); - if (!opts.force && cacheIsFresh(dest)) return dest; - try { + mkdirSync(path.dirname(dest), { recursive: true }); + if (!opts.force && cacheIsFresh(dest)) return dest; + if (!opts.force && refreshAttemptIsRecent(dest)) return existsSync(dest) ? dest : SNAPSHOT; + + writeFileSync(retryMarker(dest), new Date().toISOString(), "utf-8"); const response = await fetch(URL, { signal: AbortSignal.timeout(10_000) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const html = await response.text(); - const data = parsePricingPage(html); - writeFileSync(dest, dumpYaml(data), "utf-8"); + const source = await response.text(); + const data = parsePricingPage(source); + writeAtomically(dest, dumpYaml(data)); + clearPricingCache(); return dest; } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(`warning: pricing refresh failed (${message}); using bundled snapshot`); - copyFileSync(SNAPSHOT, dest); - return dest; + const fallback = existsSync(dest) ? dest : SNAPSHOT; + console.error(`warning: pricing refresh failed (${message}); using ${fallback === dest ? "existing cache" : "bundled snapshot"}`); + return fallback; } } diff --git a/src/pricing/loader.ts b/src/pricing/loader.ts index f5458e9..0ea5b8a 100644 --- a/src/pricing/loader.ts +++ b/src/pricing/loader.ts @@ -11,6 +11,12 @@ export interface ModelPrice { cached_input: number; output: number; cache_write?: number; + category?: string; + long_context_threshold?: number; + long_context_input?: number; + long_context_cached_input?: number; + long_context_output?: number; + long_context_cache_write?: number; } export interface Pricing { @@ -47,6 +53,7 @@ export function normalizeModel(modelId: string | undefined | null): string | nul model = parentheticalModel[1]; } model = model + .replace(/\s*\(fast mode\)\s*\(preview\)\s*$/i, " fast") .replace(/\[\^[^\]]+\]/g, "") .trim() .toLowerCase() @@ -54,7 +61,7 @@ export function normalizeModel(modelId: string | undefined | null): string | nul .replace(/[^a-z0-9.+-]+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); - for (const suffix of ["-1m-internal", "-fast"]) { + for (const suffix of ["-1m-internal", "-preview"]) { if (model.endsWith(suffix)) { model = model.slice(0, -suffix.length); } @@ -121,6 +128,12 @@ function coercePricing(raw: RawPricing): Pricing { cached_input: Number(row.cached_input ?? 0), output: Number(row.output ?? 0), ...(row.cache_write == null ? {} : { cache_write: Number(row.cache_write) }), + ...(row.category == null ? {} : { category: String(row.category) }), + ...(row.long_context_threshold == null ? {} : { long_context_threshold: Number(row.long_context_threshold) }), + ...(row.long_context_input == null ? {} : { long_context_input: Number(row.long_context_input) }), + ...(row.long_context_cached_input == null ? {} : { long_context_cached_input: Number(row.long_context_cached_input) }), + ...(row.long_context_output == null ? {} : { long_context_output: Number(row.long_context_output) }), + ...(row.long_context_cache_write == null ? {} : { long_context_cache_write: Number(row.long_context_cache_write) }), }; } return { @@ -158,8 +171,11 @@ export function getModelPrice( ): { model: string | null; price: ModelPrice | null } { const model = normalizeModel(modelId); if (!model) return { model: null, price: null }; - const row = loadPricing(pricingPath).models[model] ?? null; - return { model, price: row }; + const pricing = loadPricing(pricingPath); + const exact = pricing.models[model]; + if (exact) return { model, price: exact }; + const fallbackModel = model.endsWith("-fast") ? model.slice(0, -"-fast".length) : model; + return { model: fallbackModel, price: pricing.models[fallbackModel] ?? null }; } export function computeCost( @@ -171,10 +187,17 @@ export function computeCost( const cacheWrite = Math.trunc(tokens.cache_write || 0); const output = Math.trunc(tokens.output || 0); const fresh = Math.max(totalInput - cacheRead - cacheWrite, 0); + const longContext = price.long_context_threshold != null && totalInput > price.long_context_threshold; + const inputPrice = longContext ? price.long_context_input ?? price.input : price.input; + const cachedInputPrice = longContext ? price.long_context_cached_input ?? price.cached_input : price.cached_input; + const cacheWritePrice = longContext + ? price.long_context_cache_write ?? price.cache_write ?? inputPrice + : price.cache_write ?? inputPrice; + const outputPrice = longContext ? price.long_context_output ?? price.output : price.output; return ( - (fresh / 1_000_000) * Number(price.input || 0) + - (cacheRead / 1_000_000) * Number(price.cached_input || 0) + - (cacheWrite / 1_000_000) * Number(price.cache_write ?? price.input ?? 0) + - (output / 1_000_000) * Number(price.output || 0) + (fresh / 1_000_000) * Number(inputPrice || 0) + + (cacheRead / 1_000_000) * Number(cachedInputPrice || 0) + + (cacheWrite / 1_000_000) * Number(cacheWritePrice || 0) + + (output / 1_000_000) * Number(outputPrice || 0) ); } diff --git a/src/render.ts b/src/render.ts index fa5cdec..22a8b23 100644 --- a/src/render.ts +++ b/src/render.ts @@ -134,7 +134,7 @@ export function renderPayload(payload: unknown, opts: { persist?: boolean } = {} const metric = process.env.COPILOT_COST_METRIC ?? (fmt === "compact" || fmt === "minimal" ? "usd" : "both"); const reasoning = intValue(cw, "total_reasoning_tokens"); const fresh = Math.max(totalInput - cacheRead - cacheWrite, 0); - const shownModel = normalizeModel(rawModel) ?? (rawModel ? String(rawModel).trim() : null); + const shownModel = pricedModel?.model ?? normalizeModel(rawModel) ?? (rawModel ? String(rawModel).trim() : null); const aicText = formatAic(aic, usd === null ? shownModel : null); const displayUsd = explicitAic === null ? usd : explicitAic / 100; const usdText = formatUsd(displayUsd, displayUsd === null ? shownModel : null); diff --git a/tests-ts/fetcher.test.ts b/tests-ts/fetcher.test.ts index 72287e5..e355b15 100644 --- a/tests-ts/fetcher.test.ts +++ b/tests-ts/fetcher.test.ts @@ -1,5 +1,15 @@ -import { describe, expect, it } from "vitest"; -import { parsePricingYaml } from "../src/pricing/fetcher.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { parsePricingYaml, refreshPricing } from "../src/pricing/fetcher.js"; + +const root = path.resolve(".test-work", "pricing-fetcher"); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + rmSync(root, { recursive: true, force: true }); +}); describe("pricing fetcher", () => { it("parses upstream YAML rows and maps vendors", () => { @@ -12,6 +22,15 @@ describe("pricing fetcher", () => { cached_input: $0.50 output: $8.00 +- model: GPT-4.1 + provider: openai + category: Versatile + threshold: '> 272K' + tier: Long context + input: $4.00 + cached_input: $1.00 + output: $12.00 + - model: Claude Opus 4.7 provider: anthropic release_status: GA @@ -29,13 +48,77 @@ describe("pricing fetcher", () => { cached_input: $0.125 output: $10.00 notes: "Prompts \\u2264200K tokens" + +- model: MAI-Code-1-Flash + provider: microsoft + input: $0.75 + cached_input: $0.075 + output: $4.50 + +- model: Kimi K2.7 Code + provider: moonshot_ai + input: $0.95 + cached_input: $0.19 + output: $4.00 + +- model: Claude Opus 4.8 (fast mode) (preview) + provider: anthropic + input: $10.00 + cached_input: $1.00 + output: $50.00 + cache_write: $12.50 `; const data = parsePricingYaml(yaml); expect(data.models["gpt-4.1"]?.vendor).toBe("openai"); expect(data.models["gpt-4.1"]?.input).toBe(2); + expect(data.models["gpt-4.1"]?.long_context_threshold).toBe(272_000); + expect(data.models["gpt-4.1"]?.long_context_input).toBe(4); expect(data.models["claude-opus-4.7"]?.vendor).toBe("anthropic"); expect(data.models["claude-opus-4.7"]?.cache_write).toBe(6.25); expect(data.models["gemini-2.5-pro"]?.vendor).toBe("google"); expect(data.models["gemini-2.5-pro"]?.cached_input).toBe(0.125); + expect(data.models["mai-code-1-flash"]?.vendor).toBe("microsoft"); + expect(data.models["kimi-k2.7-code"]?.vendor).toBe("moonshot_ai"); + expect(data.models["claude-opus-4.8-fast"]?.input).toBe(10); + }); + + it("preserves a stale last-known-good cache when refresh fails", async () => { + mkdirSync(root, { recursive: true }); + const dest = path.join(root, "pricing.yaml"); + writeFileSync(dest, "last known good", "utf-8"); + const stale = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + utimesSync(dest, stale, stale); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(await refreshPricing({ dest })).toBe(dest); + expect(readFileSync(dest, "utf-8")).toBe("last known good"); + }); + + it("writes refreshed pricing from GitHub atomically", async () => { + mkdirSync(root, { recursive: true }); + const dest = path.join(root, "pricing.yaml"); + const upstream = ` +- model: GPT-5 mini + provider: openai + input: $0.25 + cached_input: $0.025 + output: $2.00 +- model: Claude Sonnet 5 + provider: anthropic + input: $2.00 + cached_input: $0.20 + output: $10.00 +- model: Gemini 3.5 Flash + provider: google + input: $1.50 + cached_input: $0.15 + output: $9.00 +`; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(upstream, { status: 200 }))); + + expect(await refreshPricing({ force: true, dest })).toBe(dest); + expect(readFileSync(dest, "utf-8")).toContain("claude-sonnet-5:"); + expect(readFileSync(dest, "utf-8")).toContain("fetched_at:"); }); }); diff --git a/tests-ts/otel-parser.test.ts b/tests-ts/otel-parser.test.ts index 6001bcf..cde1256 100644 --- a/tests-ts/otel-parser.test.ts +++ b/tests-ts/otel-parser.test.ts @@ -46,6 +46,22 @@ describe("OTel parser", () => { expect(call).toMatchObject({ input_tokens: 600, cache_read: 250, cache_creation: 150, output_tokens: 50 }); }); + it("does not bill Anthropic cache creation as fresh input", () => { + const call = normalizeSpan({ + traceId: "issue-6", + spanId: "cache-write", + attributes: { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "claude-opus-4.5", + "gen_ai.usage.input_tokens": 20_000, + "gen_ai.usage.cache_creation.input_tokens": 19_000, + "gen_ai.usage.output_tokens": 200, + }, + }); + expect(call?.input_tokens).toBe(1_000); + expect(call?.usd_cost).toBeCloseTo(0.12875, 9); + }); + it("returns null for metric records", () => { expect(normalizeSpan({ scopeMetrics: [], attributes: { "gen_ai.operation.name": "chat" } })).toBeNull(); }); diff --git a/tests-ts/pricing.test.ts b/tests-ts/pricing.test.ts index 46aab5e..2ce02bf 100644 --- a/tests-ts/pricing.test.ts +++ b/tests-ts/pricing.test.ts @@ -72,9 +72,11 @@ describe("pricing loader", () => { expect(second.models["gpt-5-mini"]?.input).toBe(1); }); - it("normalizes internal and fast suffixes", () => { + it("normalizes internal suffixes while preserving fast model pricing", () => { expect(normalizeModel("claude-opus-4.7-1m-internal")).toBe("claude-opus-4.7"); - expect(normalizeModel("gpt-5-mini-fast")).toBe("gpt-5-mini"); + expect(normalizeModel("gemini-3.1-pro-preview")).toBe("gemini-3.1-pro"); + expect(normalizeModel("gpt-5-mini-fast")).toBe("gpt-5-mini-fast"); + expect(normalizeModel("Claude Opus 4.8 (fast mode) (preview)")).toBe("claude-opus-4.8-fast"); }); it("normalizes display names and auto labels", () => { @@ -88,4 +90,19 @@ describe("pricing loader", () => { const cost = computeCost({ input: 38_200, cache_read: 12_000, cache_write: 3_100, output: 6_100 }, price); expect(cost).toBeCloseTo(0.293375, 9); }); + + it("uses long-context rates only above the published threshold", () => { + const price = { + vendor: "openai", + input: 2.5, + cached_input: 0.25, + output: 15, + long_context_threshold: 272_000, + long_context_input: 5, + long_context_cached_input: 0.5, + long_context_output: 22.5, + }; + expect(computeCost({ input: 272_000, cache_read: 0, cache_write: 0, output: 1_000 }, price)).toBeCloseTo(0.695); + expect(computeCost({ input: 273_000, cache_read: 0, cache_write: 0, output: 1_000 }, price)).toBeCloseTo(1.3875); + }); });