fix: complete extension usage accounting

closes #6509
This commit is contained in:
Mario Zechner
2026-07-20 17:04:12 +02:00
parent 2fd3868401
commit f8b74a4507
20 changed files with 267 additions and 36 deletions
@@ -581,6 +581,37 @@ export async function generateSummary(
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<string> {
return (
await generateSummaryWithUsage(
currentMessages,
model,
reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
env,
)
).text;
}
/** Generate or update a conversation summary and return its provider usage. */
export async function generateSummaryWithUsage(
currentMessages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string | undefined,
headers?: Record<string, string>,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
env?: Record<string, string>,
): Promise<{ text: string; usage: Usage }> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
@@ -790,7 +821,7 @@ export async function compact(
let historyText = "No prior history.";
let historyUsage: Usage | undefined;
if (messagesToSummarize.length > 0) {
const historyResult = await generateSummary(
const historyResult = await generateSummaryWithUsage(
messagesToSummarize,
model,
settings.reserveTokens,
@@ -822,7 +853,7 @@ export async function compact(
summaryUsage = historyUsage ? combineUsage(historyUsage, turnPrefixResult.usage) : turnPrefixResult.usage;
} else {
// Just generate history summary
const result = await generateSummary(
const result = await generateSummaryWithUsage(
messagesToSummarize,
model,
settings.reserveTokens,
@@ -1,4 +1,5 @@
import type { Usage } from "@earendil-works/pi-ai/compat";
import type { SessionEntry } from "./session-manager.ts";
export interface UsageTotals {
input: number;
@@ -25,3 +26,45 @@ export function addUsageToTotals(totals: UsageTotals, usage: Usage): void {
totals.cacheWrite += usage.cacheWrite;
totals.cost += usage.cost.total;
}
export interface UsageCostBreakdownEntry {
key: string;
cost: number;
tokens: number;
}
/** Group attributable assistant usage by model and all other usage into a separate bucket. */
export function getUsageCostBreakdown(entries: SessionEntry[]): UsageCostBreakdownEntry[] {
const totalsByKey = new Map<string, UsageTotals>();
for (const entry of entries) {
let key: string | undefined;
let usage: Usage | undefined;
if (entry.type === "message" && entry.message.role === "assistant") {
key = `${entry.message.provider}/${entry.message.responseModel ?? entry.message.model}`;
usage = entry.message.usage;
} else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
key = "Tools/summaries";
usage = entry.message.usage;
} else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
key = "Tools/summaries";
usage = entry.usage;
}
if (!key || !usage) continue;
let totals = totalsByKey.get(key);
if (!totals) {
totals = createUsageTotals();
totalsByKey.set(key, totals);
}
addUsageToTotals(totals, usage);
}
return Array.from(totalsByKey, ([key, totals]) => ({
key,
cost: totals.cost,
tokens: totals.input + totals.output + totals.cacheRead + totals.cacheWrite,
}))
.filter((entry) => entry.cost > 0 || entry.tokens > 0)
.sort((a, b) => b.cost - a.cost);
}
+1
View File
@@ -42,6 +42,7 @@ export {
type GenerateBranchSummaryOptions,
generateBranchSummary,
generateSummary,
generateSummaryWithUsage,
getLastAssistantUsage,
prepareBranchEntries,
serializeConversation,
@@ -86,6 +86,7 @@ import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getUsageCostBreakdown } from "../../core/usage-totals.ts";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
@@ -5597,22 +5598,9 @@ export class InteractiveMode {
const cacheWaste = computeCacheWaste(entries, this.session.modelRuntime);
// Cost/token totals per provider/model actually used (e.g. OpenRouter `auto`
// resolves to a concrete responseModel), sorted by cost descending.
const perModelMap = new Map<string, { key: string; cost: number; tokens: number }>();
for (const entry of entries) {
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
const message = entry.message;
const usage = message.usage;
const key = `${message.provider}/${message.responseModel ?? message.model}`;
let bucket = perModelMap.get(key);
if (!bucket) {
bucket = { key, cost: 0, tokens: 0 };
perModelMap.set(key, bucket);
}
bucket.cost += usage.cost.total;
bucket.tokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
const perModel = Array.from(perModelMap.values()).sort((a, b) => b.cost - a.cost);
// resolves to a concrete responseModel). Usage without model attribution is
// grouped separately so the breakdown reconciles with the session total.
const usageBreakdown = getUsageCostBreakdown(entries);
let info = `${theme.bold("Session Info")}\n\n`;
if (sessionName) {
@@ -5646,8 +5634,8 @@ export class InteractiveMode {
if (stats.cost > 0 || cacheWaste.missedTokens > 0) {
info += `\n${theme.bold("Cost")}\n`;
info += `${theme.fg("dim", "Total:")} $${stats.cost.toFixed(3)}`;
if (perModel.length > 1) {
for (const entry of perModel) {
if (usageBreakdown.length > 1) {
for (const entry of usageBreakdown) {
info += `\n ${theme.fg("dim", `${entry.key}:`)} $${entry.cost.toFixed(3)} ${theme.fg("dim", `(${formatTokens(entry.tokens)} tokens)`)}`;
}
}