feat(gsd): show per-prompt token cost in footer behind show_token_cost preference (#2357)
Adds opt-in per-prompt cost display to the interactive footer. Users enable it by setting `show_token_cost: true` in their preferences.md. Disabled by default — the footer behavior is unchanged unless opted in. Fixes #1515 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
21f66058ad
commit
eb30d3afd4
7 changed files with 171 additions and 0 deletions
|
|
@ -255,6 +255,10 @@ export class AgentSession {
|
|||
private _cumulativeOutputTokens = 0;
|
||||
private _cumulativeToolCalls = 0;
|
||||
|
||||
/** Cost of the most recent assistant response (for per-prompt display). */
|
||||
private _lastTurnCost = 0;
|
||||
|
||||
|
||||
// Bash execution state
|
||||
private _bashAbortController: AbortController | undefined = undefined;
|
||||
private _pendingBashMessages: BashExecutionMessage[] = [];
|
||||
|
|
@ -454,6 +458,7 @@ export class AgentSession {
|
|||
|
||||
// Accumulate session stats that survive compaction (#1423)
|
||||
const assistantMsg = event.message as AssistantMessage;
|
||||
this._lastTurnCost = assistantMsg.usage?.cost?.total ?? 0;
|
||||
this._cumulativeCost += assistantMsg.usage?.cost?.total ?? 0;
|
||||
this._cumulativeInputTokens += assistantMsg.usage?.input ?? 0;
|
||||
this._cumulativeOutputTokens += assistantMsg.usage?.output ?? 0;
|
||||
|
|
@ -2780,6 +2785,14 @@ export class AgentSession {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cost of the most recent assistant response.
|
||||
* Returns 0 if no assistant message has been received yet.
|
||||
*/
|
||||
getLastTurnCost(): number {
|
||||
return this._lastTurnCost;
|
||||
}
|
||||
|
||||
getContextUsage(): ContextUsage | undefined {
|
||||
const model = this.model;
|
||||
if (!model) return undefined;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ function formatTokens(count: number): string {
|
|||
return `${Math.round(count / 1000000)}M`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a cost value for compact display.
|
||||
* Uses fewer decimal places for larger amounts.
|
||||
* @internal Exported for testing only.
|
||||
*/
|
||||
export function formatPromptCost(cost: number): string {
|
||||
if (cost < 0.001) return `$${cost.toFixed(4)}`;
|
||||
if (cost < 0.01) return `$${cost.toFixed(3)}`;
|
||||
if (cost < 1) return `$${cost.toFixed(3)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer component that shows pwd, token stats, and context usage.
|
||||
* Computes token/context stats from session, gets git branch and extension statuses from provider.
|
||||
|
|
@ -112,6 +124,14 @@ export class FooterComponent implements Component {
|
|||
statsParts.push(costStr);
|
||||
}
|
||||
|
||||
// Per-prompt cost annotation (opt-in via show_token_cost preference, #1515)
|
||||
if (process.env.GSD_SHOW_TOKEN_COST === "1") {
|
||||
const lastTurnCost = this.session.getLastTurnCost();
|
||||
if (lastTurnCost > 0) {
|
||||
statsParts.push(`(last: ${formatPromptCost(lastTurnCost)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Colorize context percentage based on usage
|
||||
let contextPercentStr: string;
|
||||
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ export function registerHooks(pi: ExtensionAPI): void {
|
|||
resetWriteGateState();
|
||||
resetToolCallLoopGuard();
|
||||
await syncServiceTierStatus(ctx);
|
||||
|
||||
// Apply show_token_cost preference (#1515)
|
||||
try {
|
||||
const { loadEffectiveGSDPreferences } = await import("../preferences.js");
|
||||
const prefs = loadEffectiveGSDPreferences();
|
||||
process.env.GSD_SHOW_TOKEN_COST = prefs?.preferences.show_token_cost ? "1" : "";
|
||||
} catch { /* non-fatal */ }
|
||||
if (isFirstSession) {
|
||||
isFirstSession = false;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"github",
|
||||
"service_tier",
|
||||
"forensics_dedup",
|
||||
"show_token_cost",
|
||||
]);
|
||||
|
||||
/** Canonical list of all dispatch unit types. */
|
||||
|
|
@ -226,6 +227,8 @@ export interface GSDPreferences {
|
|||
service_tier?: "priority" | "flex";
|
||||
/** Opt-in: search existing issues and PRs before filing from /gsd forensics. Uses additional AI tokens. */
|
||||
forensics_dedup?: boolean;
|
||||
/** Opt-in: show per-prompt and cumulative session token cost in the footer. Default: false. */
|
||||
show_token_cost?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadedGSDPreferences {
|
||||
|
|
|
|||
|
|
@ -747,5 +747,14 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Show Token Cost ──────────────────────────────────────────────
|
||||
if (preferences.show_token_cost !== undefined) {
|
||||
if (typeof preferences.show_token_cost === "boolean") {
|
||||
validated.show_token_cost = preferences.show_token_cost;
|
||||
} else {
|
||||
errors.push("show_token_cost must be a boolean");
|
||||
}
|
||||
}
|
||||
|
||||
return { preferences: validated, errors, warnings };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -342,6 +342,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
|
|||
: undefined,
|
||||
service_tier: override.service_tier ?? base.service_tier,
|
||||
forensics_dedup: override.forensics_dedup ?? base.forensics_dedup,
|
||||
show_token_cost: override.show_token_cost ?? base.show_token_cost,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
118
src/resources/extensions/gsd/tests/token-cost-display.test.ts
Normal file
118
src/resources/extensions/gsd/tests/token-cost-display.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* Tests for the show_token_cost preference (#1515).
|
||||
*
|
||||
* Covers:
|
||||
* - Preference recognition and validation
|
||||
* - Cost formatting accuracy (inline re-implementation for test isolation)
|
||||
* - Disabled-by-default behavior
|
||||
* - Preference parsing from markdown frontmatter
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
validatePreferences,
|
||||
parsePreferencesMarkdown,
|
||||
} from "../preferences.ts";
|
||||
import { KNOWN_PREFERENCE_KEYS } from "../preferences-types.ts";
|
||||
|
||||
// Re-implement formatPromptCost here for test isolation (avoids pi-coding-agent build dep).
|
||||
// The canonical implementation lives in footer.ts.
|
||||
function formatPromptCost(cost: number): string {
|
||||
if (cost < 0.001) return `$${cost.toFixed(4)}`;
|
||||
if (cost < 0.01) return `$${cost.toFixed(3)}`;
|
||||
if (cost < 1) return `$${cost.toFixed(3)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// ── Preference recognition ──────────────────────────────────────────────────
|
||||
|
||||
test("show_token_cost is a known preference key", () => {
|
||||
assert.ok(KNOWN_PREFERENCE_KEYS.has("show_token_cost"));
|
||||
});
|
||||
|
||||
test("show_token_cost: true validates without errors", () => {
|
||||
const { errors, preferences } = validatePreferences({ show_token_cost: true });
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal(preferences.show_token_cost, true);
|
||||
});
|
||||
|
||||
test("show_token_cost: false validates without errors", () => {
|
||||
const { errors, preferences } = validatePreferences({ show_token_cost: false });
|
||||
assert.equal(errors.length, 0);
|
||||
assert.equal(preferences.show_token_cost, false);
|
||||
});
|
||||
|
||||
test("show_token_cost: non-boolean produces validation error", () => {
|
||||
const { errors } = validatePreferences({ show_token_cost: "yes" as any });
|
||||
assert.ok(errors.length > 0);
|
||||
assert.ok(errors[0].includes("show_token_cost"));
|
||||
assert.ok(errors[0].includes("boolean"));
|
||||
});
|
||||
|
||||
test("show_token_cost does not produce unknown-key warning", () => {
|
||||
const { warnings } = validatePreferences({ show_token_cost: true });
|
||||
const unknownWarnings = warnings.filter(w => w.includes("show_token_cost"));
|
||||
assert.equal(unknownWarnings.length, 0);
|
||||
});
|
||||
|
||||
// ── Disabled by default ─────────────────────────────────────────────────────
|
||||
|
||||
test("show_token_cost defaults to undefined (disabled) when not set", () => {
|
||||
const { preferences } = validatePreferences({});
|
||||
assert.equal(preferences.show_token_cost, undefined);
|
||||
});
|
||||
|
||||
test("empty preferences.md does not enable show_token_cost", () => {
|
||||
const prefs = parsePreferencesMarkdown("---\nversion: 1\n---\n");
|
||||
assert.ok(prefs);
|
||||
assert.equal(prefs.show_token_cost, undefined);
|
||||
});
|
||||
|
||||
test("preferences.md with show_token_cost: true enables the preference", () => {
|
||||
const prefs = parsePreferencesMarkdown("---\nshow_token_cost: true\n---\n");
|
||||
assert.ok(prefs);
|
||||
assert.equal(prefs.show_token_cost, true);
|
||||
});
|
||||
|
||||
// ── Cost formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
test("formatPromptCost formats sub-cent amounts with 4 decimals", () => {
|
||||
assert.equal(formatPromptCost(0.0003), "$0.0003");
|
||||
assert.equal(formatPromptCost(0.0009), "$0.0009");
|
||||
});
|
||||
|
||||
test("formatPromptCost formats cent-range amounts with 3 decimals", () => {
|
||||
assert.equal(formatPromptCost(0.003), "$0.003");
|
||||
assert.equal(formatPromptCost(0.012), "$0.012");
|
||||
assert.equal(formatPromptCost(0.1), "$0.100");
|
||||
});
|
||||
|
||||
test("formatPromptCost formats dollar-range amounts with 2 decimals", () => {
|
||||
assert.equal(formatPromptCost(1.5), "$1.50");
|
||||
assert.equal(formatPromptCost(12.345), "$12.35");
|
||||
});
|
||||
|
||||
test("formatPromptCost handles zero", () => {
|
||||
assert.equal(formatPromptCost(0), "$0.0000");
|
||||
});
|
||||
|
||||
// ── Cost calculation correctness ────────────────────────────────────────────
|
||||
|
||||
test("cost calculation formula matches Model cost structure", () => {
|
||||
// Simulates: usage.input * model.cost.input / 1_000_000 + usage.output * model.cost.output / 1_000_000
|
||||
// Model.cost fields are $/million tokens
|
||||
const modelCost = { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }; // claude-opus-4 pricing
|
||||
const usage = { input: 2000, output: 500, cacheRead: 10000, cacheWrite: 1000 };
|
||||
|
||||
const cost =
|
||||
(usage.input * modelCost.input / 1_000_000) +
|
||||
(usage.output * modelCost.output / 1_000_000) +
|
||||
(usage.cacheRead * modelCost.cacheRead / 1_000_000) +
|
||||
(usage.cacheWrite * modelCost.cacheWrite / 1_000_000);
|
||||
|
||||
// 2000*15/1M + 500*75/1M + 10000*1.5/1M + 1000*18.75/1M
|
||||
// = 0.03 + 0.0375 + 0.015 + 0.01875 = 0.10125
|
||||
assert.ok(Math.abs(cost - 0.10125) < 0.0001, `Expected ~$0.10125 but got $${cost}`);
|
||||
assert.equal(formatPromptCost(cost), "$0.101");
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue