Skip to content

Commit c7956bb

Browse files
committed
Update README and statusline preview to reflect GitHub AI Credits (AIC) terminology; enhance model normalization and rendering logic for AIC in payloads
1 parent d37390a commit c7956bb

7 files changed

Lines changed: 163 additions & 28 deletions

File tree

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
> ⚠️ **Unofficial.** This is a community project and is **not affiliated with, endorsed by, or supported by GitHub**. It reads the OpenTelemetry traces the [GitHub Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-copilot-cli) already emits — nothing more.
1111
12-
A zero-config **statusline + local dashboard** that turns Copilot CLI's OpenTelemetry traces into a real-time, model-aware view of your **token usage and estimated spend** — without your data ever leaving your machine.
12+
A zero-config **statusline + local dashboard** that turns Copilot CLI's OpenTelemetry traces into a real-time, model-aware view of your **token usage and estimated GitHub AI Credits (AIC)** — without your data ever leaving your machine.
1313

1414
![copilot-cost statusline in the GitHub Copilot CLI](docs/ghcpcli.png)
1515

@@ -49,9 +49,9 @@ Three styles, controlled by `COPILOT_COST_FORMAT`:
4949

5050
| Format | Aliases | Example |
5151
| --- | --- | --- |
52-
| `standard` | _default_ | `$1.2522 · 1.5M in / 7.9k out · 1.5M cache` |
53-
| `compact` | `minimal` | `$1.2522` |
54-
| `full` | `verbose` | `$1.2522 (125.22 aic) · 38.4k fresh / 1.4M cache rd / 62.1k cache wr / 7.9k out · Σ 1.5M · 1.6k reason` |
52+
| `standard` | _default_ | `125.22 AIC · 1.5M in / 7.9k out · 1.5M cache` |
53+
| `compact` | `minimal` | `125.22 AIC` |
54+
| `full` | `verbose` | `125.22 AIC ($1.2522) · 38.4k fresh / 1.4M cache rd / 62.1k cache wr / 7.9k out · Σ 1.5M · 1.6k reason` |
5555

5656
```bash
5757
export COPILOT_COST_FORMAT=compact
@@ -110,7 +110,7 @@ GitHub Copilot CLI ──OTel spans (JSONL)──▶ ~/.copilot/otel/copilot-ote
110110
export COPILOT_OTEL_FILE_EXPORTER_PATH="$HOME/.copilot/otel/copilot-otel.jsonl"
111111
```
112112
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.
113-
3. **Price** — token counts are multiplied by a bundled pricing snapshot (refreshable from [Copilot models & pricing](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing)) to produce an **estimated** USD cost.
113+
3. **Price** — token counts are multiplied by a bundled pricing snapshot (refreshable from [Copilot models & pricing](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing)) to produce **estimated** GitHub AI Credits (AIC). The `full` statusline also shows the equivalent USD conversion.
114114
4. **Render** — a one-line statusline, plus an optional local web dashboard.
115115

116116
More on the underlying telemetry pipeline: [Copilot OpenTelemetry observability](https://docs.github.com/en/copilot/how-tos/copilot-sdk/observability/opentelemetry).

docs/statusline-preview.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
standard: $1.2522 · 1.5M in / 7.9k out · 1.5M cache
2-
compact: $1.2522
3-
full: $1.2522 (125.22 aic) · 38.4k fresh / 1.4M cache rd / 62.1k cache wr / 7.9k out · Σ 1.5M · 1.6k reason
1+
standard: 125.22 AIC · 1.5M in / 7.9k out · 1.5M cache
2+
compact: 125.22 AIC
3+
full: 125.22 AIC ($1.2522) · 38.4k fresh / 1.4M cache rd / 62.1k cache wr / 7.9k out · Σ 1.5M · 1.6k reason

scripts/generate-demo-screenshots.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ function writeFixture() {
102102
const lines = syntheticSpans().map((span) => JSON.stringify(span));
103103
writeFileSync(fixturePath, `${lines.join("\n")}\n`);
104104
writeFileSync(statuslinePath, [
105-
"standard: $1.2522 · 1.5M in / 7.9k out · 1.5M cache",
106-
"compact: $1.2522",
107-
"full: $1.2522 (125.22 aic) · 38.4k fresh / 1.4M cache rd / 62.1k cache wr / 7.9k out · Σ 1.5M · 1.6k reason",
105+
"standard: 125.22 AIC · 1.5M in / 7.9k out · 1.5M cache",
106+
"compact: 125.22 AIC",
107+
"full: 125.22 AIC ($1.2522) · 38.4k fresh / 1.4M cache rd / 62.1k cache wr / 7.9k out · Σ 1.5M · 1.6k reason",
108108
"",
109109
].join("\n"));
110110
}

src/pricing/loader.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,24 @@ const pricingCache = new Map<string, PricingCacheEntry>();
4242
export function normalizeModel(modelId: string | undefined | null): string | null {
4343
if (!modelId) return null;
4444
let model = String(modelId).trim();
45+
const parentheticalModel = /^auto\b.*\(([^)]+)\)/i.exec(model);
46+
if (parentheticalModel?.[1]) {
47+
model = parentheticalModel[1];
48+
}
49+
model = model
50+
.replace(/\[\^[^\]]+\]/g, "")
51+
.trim()
52+
.toLowerCase()
53+
.replace(/[\s_]+/g, "-")
54+
.replace(/[^a-z0-9.+-]+/g, "-")
55+
.replace(/-+/g, "-")
56+
.replace(/^-|-$/g, "");
4557
for (const suffix of ["-1m-internal", "-fast"]) {
4658
if (model.endsWith(suffix)) {
4759
model = model.slice(0, -suffix.length);
4860
}
4961
}
50-
return model;
62+
return model || null;
5163
}
5264

5365
function parseYaml(text: string): RawPricing {

src/render.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ function intValue(source: JsonObject, key: string): number {
1414
return Number.isFinite(n) ? Math.trunc(n) : 0;
1515
}
1616

17+
function numValue(source: JsonObject, key: string): number | null {
18+
const value = source[key];
19+
if (value === undefined || value === null || value === "") return null;
20+
const n = Number(value);
21+
return Number.isFinite(n) ? n : null;
22+
}
23+
1724
function strValue(source: JsonObject, key: string): string | null {
1825
const value = source[key];
1926
if (value === undefined || value === null || value === "") return null;
@@ -26,10 +33,57 @@ function short(n: number): string {
2633
return String(n);
2734
}
2835

36+
function firstNumber(source: JsonObject, keys: string[]): number | null {
37+
for (const key of keys) {
38+
const value = numValue(source, key);
39+
if (value !== null) return value;
40+
}
41+
return null;
42+
}
43+
44+
function payloadAic(root: JsonObject): number | null {
45+
const cost = asObject(root.cost);
46+
return firstNumber(cost, [
47+
"total_ai_credits",
48+
"total_aic",
49+
"ai_credits",
50+
"aic",
51+
"total_cost_ai_credits",
52+
]) ?? firstNumber(root, [
53+
"total_ai_credits",
54+
"total_aic",
55+
"ai_credits",
56+
"aic",
57+
]);
58+
}
59+
60+
function formatAic(aic: number | null, model: string | null): string {
61+
if (aic !== null) return `${aic.toFixed(2)} AIC`;
62+
return model ? `? AIC (${model})` : "? AIC";
63+
}
64+
65+
function modelCandidates(root: JsonObject): string[] {
66+
const modelInfo = asObject(root.model);
67+
const values = [
68+
strValue(modelInfo, "id"),
69+
strValue(modelInfo, "resolved_id"),
70+
strValue(modelInfo, "selected_id"),
71+
strValue(modelInfo, "selected_model"),
72+
strValue(modelInfo, "display_name"),
73+
strValue(modelInfo, "name"),
74+
strValue(root, "model_id"),
75+
strValue(root, "resolved_model"),
76+
strValue(root, "selected_model"),
77+
];
78+
return [...new Set(values.filter((value): value is string => value !== null))];
79+
}
80+
2981
export function renderPayload(payload: unknown, opts: { persist?: boolean } = {}): string {
3082
const root = asObject(payload);
31-
const modelInfo = asObject(root.model);
32-
const rawModel = typeof modelInfo.id === "string" ? modelInfo.id : modelInfo.id == null ? undefined : String(modelInfo.id);
83+
const candidates = modelCandidates(root);
84+
const fallbackModel = candidates[0];
85+
const pricedModel = candidates.map((candidate) => ({ raw: candidate, ...getModelPrice(candidate) })).find((candidate) => candidate.price);
86+
const rawModel = pricedModel?.raw ?? fallbackModel;
3387
const cw = asObject(root.context_window);
3488
const totalInput = intValue(cw, "total_input_tokens");
3589
const output = intValue(cw, "total_output_tokens");
@@ -54,32 +108,35 @@ export function renderPayload(payload: unknown, opts: { persist?: boolean } = {}
54108
return "";
55109
}
56110

57-
const { model, price } = getModelPrice(rawModel);
58-
let usd = 0;
111+
const { price } = pricedModel ?? getModelPrice(rawModel);
112+
let usd: number | null = 0;
59113
if (price) {
60114
usd = computeCost({ input: totalInput, cache_read: cacheRead, cache_write: cacheWrite, output }, price);
61115
} else if (!isEmpty) {
62-
const shown = normalizeModel(rawModel) ?? "unknown";
63-
return `$? (${shown})`;
116+
usd = null;
64117
}
118+
const explicitAic = payloadAic(root);
119+
const aic = explicitAic ?? (usd === null ? null : usd * 100);
65120
const fmt = process.env.COPILOT_COST_FORMAT ?? "standard";
66121
const reasoning = intValue(cw, "total_reasoning_tokens");
67122
const fresh = Math.max(totalInput - cacheRead - cacheWrite, 0);
123+
const shownModel = normalizeModel(rawModel) ?? (rawModel ? String(rawModel).trim() : null);
124+
const aicText = formatAic(aic, usd === null ? shownModel : null);
125+
const displayUsd = explicitAic === null ? usd : explicitAic / 100;
68126

69127
let body: string;
70128
if (fmt === "compact" || fmt === "minimal") {
71-
body = `$${usd.toFixed(4)}`;
129+
body = aicText;
72130
} else if (fmt === "full" || fmt === "verbose") {
73-
const credits = usd * 100;
74131
const parts = [
75-
`$${usd.toFixed(4)} (${credits.toFixed(2)} aic)`,
132+
displayUsd === null ? aicText : `${aicText} ($${displayUsd.toFixed(4)})`,
76133
`${short(fresh)} fresh / ${short(cacheRead)} cache rd / ${short(cacheWrite)} cache wr / ${short(output)} out`,
77134
${short(totalInput + output)}`,
78135
];
79136
if (reasoning) parts.push(`${short(reasoning)} reason`);
80137
body = parts.join(" · ");
81138
} else {
82-
const parts = [`$${usd.toFixed(4)}`, `${short(totalInput)} in / ${short(output)} out`];
139+
const parts = [aicText, `${short(totalInput)} in / ${short(output)} out`];
83140
if (cacheRead || cacheWrite) parts.push(`${short(cacheRead + cacheWrite)} cache`);
84141
body = parts.join(" · ");
85142
}

tests-ts/pricing.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ describe("pricing loader", () => {
7777
expect(normalizeModel("gpt-5-mini-fast")).toBe("gpt-5-mini");
7878
});
7979

80+
it("normalizes display names and auto labels", () => {
81+
expect(normalizeModel("Claude Opus 4.7")).toBe("claude-opus-4.7");
82+
expect(normalizeModel("GPT-5 mini")).toBe("gpt-5-mini");
83+
expect(normalizeModel("Auto (Claude Sonnet 4.6)")).toBe("claude-sonnet-4.6");
84+
});
85+
8086
it("computes cost using fresh, cache read, cache write, and output tokens", () => {
8187
const price = { vendor: "anthropic", input: 5, cached_input: 0.5, cache_write: 6.25, output: 25 };
8288
const cost = computeCost({ input: 38_200, cache_read: 12_000, cache_write: 3_100, output: 6_100 }, price);

tests-ts/render.test.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ describe("renderPayload", () => {
2626
it("renders default standard format", () => {
2727
process.env.COPILOT_COST_NO_COLOR = "1";
2828
const output = renderPayload(payload, { persist: false });
29-
expect(output).toContain("$");
29+
expect(output).toContain("29.34 AIC");
30+
expect(output).not.toContain("$");
3031
expect(output).toContain("38.2k in / 6.1k out");
3132
});
3233

@@ -36,31 +37,90 @@ describe("renderPayload", () => {
3637
expect(existsSync(path.join(process.env.COPILOT_OTEL_DIR ?? "", "copilot-cost-meta.jsonl"))).toBe(false);
3738
});
3839

39-
it("renders a numeric amount for non-numeric token counts", () => {
40+
it("renders a numeric AIC amount for non-numeric token counts", () => {
4041
process.env.COPILOT_COST_NO_COLOR = "1";
4142
const malformedPayload = JSON.parse(JSON.stringify(payload)) as { context_window: { total_input_tokens: string } };
4243
malformedPayload.context_window.total_input_tokens = "not-a-number";
4344

4445
const output = renderPayload(malformedPayload, { persist: false });
4546

46-
expect(output).toMatch(/\$\d+\.\d{4}/);
47+
expect(output).toMatch(/\d+\.\d{2} AIC/);
4748
expect(output).not.toContain("NaN");
4849
});
4950

5051
it("renders compact format", () => {
5152
process.env.COPILOT_COST_NO_COLOR = "1";
5253
process.env.COPILOT_COST_FORMAT = "compact";
53-
expect(renderPayload(payload, { persist: false })).toMatch(/^\$\d+\.\d{4}$/);
54+
expect(renderPayload(payload, { persist: false })).toBe("29.34 AIC");
5455
});
5556

5657
it("renders verbose format", () => {
5758
process.env.COPILOT_COST_NO_COLOR = "1";
5859
process.env.COPILOT_COST_FORMAT = "verbose";
5960
const output = renderPayload(payload, { persist: false });
61+
expect(output).toContain("29.34 AIC ($0.2934)");
6062
expect(output).toContain("fresh / 12.0k cache rd / 3.1k cache wr / 6.1k out");
6163
expect(output).toContain("900 reason");
6264
});
6365

66+
it("uses explicit payload AI credits when provided", () => {
67+
process.env.COPILOT_COST_NO_COLOR = "1";
68+
const payloadWithCredits = JSON.parse(JSON.stringify(payload)) as { cost: { total_ai_credits: number } };
69+
payloadWithCredits.cost.total_ai_credits = 12.345;
70+
71+
expect(renderPayload(payloadWithCredits, { persist: false })).toContain("12.35 AIC");
72+
});
73+
74+
it("keeps rendering token usage when auto model pricing is unavailable", () => {
75+
process.env.COPILOT_COST_NO_COLOR = "1";
76+
const autoPayload = JSON.parse(JSON.stringify(payload)) as { model: { id: string; display_name: string } };
77+
autoPayload.model.id = "auto";
78+
autoPayload.model.display_name = "Auto";
79+
80+
const output = renderPayload(autoPayload, { persist: false });
81+
82+
expect(output).toContain("? AIC (auto)");
83+
expect(output).toContain("38.2k in / 6.1k out");
84+
expect(output).not.toContain("$?");
85+
});
86+
87+
it.each([
88+
["standard", "29.34 AIC · 38.2k in / 6.1k out · 15.1k cache"],
89+
["compact", "29.34 AIC"],
90+
["full", "29.34 AIC ($0.2934) · 23.1k fresh / 12.0k cache rd / 3.1k cache wr / 6.1k out · Σ 44.3k · 900 reason"],
91+
])("prices auto model from display name in %s format", (format, expected) => {
92+
process.env.COPILOT_COST_NO_COLOR = "1";
93+
process.env.COPILOT_COST_FORMAT = format;
94+
const autoPayload = JSON.parse(JSON.stringify(payload)) as { model: { id: string } };
95+
autoPayload.model.id = "auto";
96+
97+
expect(renderPayload(autoPayload, { persist: false })).toBe(expected);
98+
});
99+
100+
it.each([
101+
["standard", "? AIC (auto) · 38.2k in / 6.1k out · 15.1k cache"],
102+
["compact", "? AIC (auto)"],
103+
["full", "? AIC (auto) · 23.1k fresh / 12.0k cache rd / 3.1k cache wr / 6.1k out · Σ 44.3k · 900 reason"],
104+
])("renders auto model without pricing in %s format", (format, expected) => {
105+
process.env.COPILOT_COST_NO_COLOR = "1";
106+
process.env.COPILOT_COST_FORMAT = format;
107+
const autoPayload = JSON.parse(JSON.stringify(payload)) as { model: { id: string; display_name: string } };
108+
autoPayload.model.id = "auto";
109+
autoPayload.model.display_name = "Auto";
110+
111+
expect(renderPayload(autoPayload, { persist: false })).toBe(expected);
112+
});
113+
114+
it("converts explicit AI credits to dollars only in verbose format", () => {
115+
process.env.COPILOT_COST_NO_COLOR = "1";
116+
process.env.COPILOT_COST_FORMAT = "verbose";
117+
const autoPayload = JSON.parse(JSON.stringify(payload)) as { model: { id: string }; cost: { total_ai_credits: number } };
118+
autoPayload.model.id = "auto";
119+
autoPayload.cost.total_ai_credits = 12.345;
120+
121+
expect(renderPayload(autoPayload, { persist: false })).toContain("12.35 AIC ($0.1235)");
122+
});
123+
64124
it("strips ANSI color when color is disabled", () => {
65125
process.env.COPILOT_COST_NO_COLOR = "1";
66126
const output = renderPayload(payload, { persist: false });
@@ -69,7 +129,7 @@ describe("renderPayload", () => {
69129

70130
it("renders a zero placeholder by default when payload has no usage", () => {
71131
process.env.COPILOT_COST_NO_COLOR = "1";
72-
expect(renderPayload({}, { persist: false })).toBe("$0.0000 · 0 in / 0 out");
132+
expect(renderPayload({}, { persist: false })).toBe("0.00 AIC · 0 in / 0 out");
73133
});
74134

75135
it("returns empty string when payload has no usage and COPILOT_COST_HIDE_ZERO is set", () => {
@@ -81,6 +141,6 @@ describe("renderPayload", () => {
81141
it("renders a zero placeholder in compact format by default", () => {
82142
process.env.COPILOT_COST_NO_COLOR = "1";
83143
process.env.COPILOT_COST_FORMAT = "compact";
84-
expect(renderPayload({}, { persist: false })).toBe("$0.0000");
144+
expect(renderPayload({}, { persist: false })).toBe("0.00 AIC");
85145
});
86146
});

0 commit comments

Comments
 (0)