Skip to content

Commit 3ef5b8e

Browse files
committed
feat: add usage tracking per model and update exit summary display
1 parent dbe859e commit 3ef5b8e

5 files changed

Lines changed: 241 additions & 54 deletions

File tree

src/session.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,29 @@ function accumulateUsage(current: ModelUsage | null, next: unknown | null | unde
7272
return addUsageValue(current, next) as ModelUsage;
7373
}
7474

75+
function usageWithRequestCount(usage: ModelUsage): ModelUsage {
76+
const totalReqs = typeof usage.total_reqs === "number" ? usage.total_reqs + 1 : 1;
77+
return {
78+
...usage,
79+
total_reqs: totalReqs,
80+
};
81+
}
82+
83+
function accumulateUsagePerModel(
84+
current: Record<string, ModelUsage> | null | undefined,
85+
model: string,
86+
next: ModelUsage | null | undefined
87+
): Record<string, ModelUsage> | null {
88+
if (next == null) {
89+
return current ?? null;
90+
}
91+
92+
const usagePerModel = { ...(current ?? {}) };
93+
const modelName = model.trim() || "unknown";
94+
usagePerModel[modelName] = accumulateUsage(usagePerModel[modelName] ?? null, usageWithRequestCount(next))!;
95+
return usagePerModel;
96+
}
97+
7598
function getExtensionRoot(): string {
7699
if (typeof __dirname !== "undefined") {
77100
return path.resolve(__dirname, "..");
@@ -112,6 +135,7 @@ export type SessionEntry = {
112135
status: SessionStatus;
113136
failReason: string | null;
114137
usage: ModelUsage | null;
138+
usagePerModel: Record<string, ModelUsage> | null;
115139
activeTokens: number;
116140
createTime: string;
117141
updateTime: string;
@@ -847,6 +871,7 @@ The candidate skills are as follows:\n\n`;
847871
status: "pending",
848872
failReason: null,
849873
usage: null,
874+
usagePerModel: null,
850875
activeTokens: 0,
851876
createTime: now,
852877
updateTime: now,
@@ -1087,6 +1112,7 @@ ${skillMd}
10871112
assistantRefusal: refusal,
10881113
toolCalls,
10891114
usage: accumulateUsage(entry.usage, responseUsage),
1115+
usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage),
10901116
activeTokens: getTotalTokens(responseUsage),
10911117
status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed",
10921118
failReason: refusal ? refusal : entry.failReason,
@@ -1196,6 +1222,7 @@ ${skillMd}
11961222
this.updateSessionEntry(sessionId, (entry) => ({
11971223
...entry,
11981224
usage: accumulateUsage(entry.usage, responseUsage),
1225+
usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage),
11991226
activeTokens: getTotalTokens(responseUsage),
12001227
updateTime: now,
12011228
}));
@@ -2108,6 +2135,7 @@ ${skillMd}
21082135
status: this.normalizeSessionStatus(value.status),
21092136
failReason: typeof value.failReason === "string" ? value.failReason : null,
21102137
usage: (value.usage as ModelUsage) ?? null,
2138+
usagePerModel: this.normalizeUsagePerModel(value),
21112139
activeTokens: typeof value.activeTokens === "number" ? value.activeTokens : 0,
21122140
createTime: typeof value.createTime === "string" ? value.createTime : new Date().toISOString(),
21132141
updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(),
@@ -2129,6 +2157,23 @@ ${skillMd}
21292157
return "pending";
21302158
}
21312159

2160+
private normalizeUsagePerModel(entry: Record<string, unknown>): Record<string, ModelUsage> | null {
2161+
if (!Object.prototype.hasOwnProperty.call(entry, "usagePerModel")) {
2162+
return null;
2163+
}
2164+
if (!isUsageRecord(entry.usagePerModel)) {
2165+
return null;
2166+
}
2167+
const usagePerModel: Record<string, ModelUsage> = {};
2168+
for (const [model, usage] of Object.entries(entry.usagePerModel)) {
2169+
if (!model || !isUsageRecord(usage)) {
2170+
continue;
2171+
}
2172+
usagePerModel[model] = usage as ModelUsage;
2173+
}
2174+
return usagePerModel;
2175+
}
2176+
21322177
private deserializeProcesses(value: unknown): Map<string, { startTime: string; command: string }> | null {
21332178
if (!value || typeof value !== "object") {
21342179
return null;

src/tests/exitSummary.test.ts

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
33
import { buildExitSummaryText } from "../ui";
4-
import type { SessionEntry, SessionMessage, ModelUsage } from "../session";
4+
import type { ModelUsage, SessionEntry } from "../session";
55

66
const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, "");
77

88
test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => {
99
const summary = stripAnsi(
1010
buildExitSummaryText({
11-
session: buildSession({
12-
prompt_tokens: 11_966,
13-
completion_tokens: 236,
14-
total_tokens: 12_202,
15-
prompt_tokens_details: { cached_tokens: 11_776 },
16-
completion_tokens_details: { reasoning_tokens: 144 },
11+
session: buildSession(null, {
12+
"mimo-v2.5-pro": {
13+
prompt_tokens: 11_966,
14+
completion_tokens: 236,
15+
total_tokens: 12_202,
16+
prompt_tokens_details: { cached_tokens: 11_776 },
17+
completion_tokens_details: { reasoning_tokens: 144 },
18+
total_reqs: 2,
19+
},
1720
}),
18-
messages: [buildAssistantMessage("assistant-1"), buildAssistantMessage("assistant-2")],
19-
model: "mimo-v2.5-pro",
2021
})
2122
);
2223

@@ -33,7 +34,63 @@ test("buildExitSummaryText only shows Goodbye and model usage with cached tokens
3334
assert.doesNotMatch(summary, /Reasoning Tokens/);
3435
});
3536

36-
function buildSession(usage: ModelUsage | null): SessionEntry {
37+
test("buildExitSummaryText shows all usagePerModel rows sorted by request count", () => {
38+
const summary = stripAnsi(
39+
buildExitSummaryText({
40+
session: buildSession(
41+
{
42+
prompt_tokens: 999,
43+
completion_tokens: 999,
44+
total_tokens: 1_998,
45+
},
46+
{
47+
"deepseek-v4-pro": {
48+
prompt_tokens: 100,
49+
completion_tokens: 10,
50+
total_tokens: 110,
51+
total_reqs: 1,
52+
},
53+
"deepseek-v4-flash": {
54+
prompt_tokens: 300,
55+
completion_tokens: 30,
56+
total_tokens: 330,
57+
prompt_cache_hit_tokens: 111,
58+
total_reqs: 3,
59+
},
60+
}
61+
),
62+
})
63+
);
64+
65+
const flashIndex = summary.indexOf("deepseek-v4-flash");
66+
const proIndex = summary.indexOf("deepseek-v4-pro");
67+
68+
assert.notEqual(flashIndex, -1);
69+
assert.notEqual(proIndex, -1);
70+
assert.ok(flashIndex < proIndex);
71+
assert.match(summary, /deepseek-v4-flash\s+3\s+300\s+30\s+111/);
72+
assert.match(summary, /deepseek-v4-pro\s+1\s+100\s+10\s+0/);
73+
assert.doesNotMatch(summary, /999/);
74+
});
75+
76+
test("buildExitSummaryText does not derive usage rows from legacy aggregate usage", () => {
77+
const summary = stripAnsi(
78+
buildExitSummaryText({
79+
session: buildSession({
80+
prompt_tokens: 11_966,
81+
completion_tokens: 236,
82+
total_tokens: 12_202,
83+
total_reqs: 2,
84+
}),
85+
})
86+
);
87+
88+
assert.match(summary, /Goodbye!/);
89+
assert.doesNotMatch(summary, /Model Usage/);
90+
assert.doesNotMatch(summary, /11,966/);
91+
});
92+
93+
function buildSession(usage: ModelUsage | null, usagePerModel: Record<string, ModelUsage> | null = null): SessionEntry {
3794
return {
3895
id: "session-1",
3996
summary: null,
@@ -44,24 +101,10 @@ function buildSession(usage: ModelUsage | null): SessionEntry {
44101
status: "completed",
45102
failReason: null,
46103
usage,
104+
usagePerModel,
47105
activeTokens: 0,
48106
createTime: "2026-01-01T00:00:00.000Z",
49107
updateTime: "2026-01-01T00:00:01.000Z",
50108
processes: null,
51109
};
52110
}
53-
54-
function buildAssistantMessage(id: string): SessionMessage {
55-
return {
56-
id,
57-
sessionId: "session-1",
58-
role: "assistant",
59-
content: "",
60-
contentParams: null,
61-
messageParams: null,
62-
compacted: false,
63-
visible: true,
64-
createTime: "2026-01-01T00:00:00.000Z",
65-
updateTime: "2026-01-01T00:00:00.000Z",
66-
};
67-
}

src/tests/session.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,20 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", (
275275
const manager = createSessionManager(workspace, "machine-id-legacy");
276276

277277
assert.equal(manager.getSession("legacy-session")?.activeTokens, 0);
278+
assert.equal(manager.getSession("legacy-session")?.usagePerModel, null);
279+
});
280+
281+
test("SessionManager keeps usagePerModel null until response usage is available", async () => {
282+
const workspace = createTempDir("deepcode-null-usage-per-model-workspace-");
283+
const home = createTempDir("deepcode-null-usage-per-model-home-");
284+
process.env.HOME = home;
285+
286+
const manager = createMockedClientSessionManager(workspace, [{ choices: [{ message: { content: "no usage" } }] }]);
287+
288+
const sessionId = await manager.createSession({ text: "" });
289+
290+
assert.equal(manager.getSession(sessionId)?.usage, null);
291+
assert.equal(manager.getSession(sessionId)?.usagePerModel, null);
278292
});
279293

280294
test("SessionManager marks skills loaded from existing session messages", async () => {
@@ -1122,6 +1136,7 @@ test("SessionManager accumulates response usage while active tokens track the la
11221136

11231137
const session = manager.getSession(sessionId);
11241138
const usage = session?.usage as Record<string, any>;
1139+
const usagePerModel = session?.usagePerModel?.["test-model"] as Record<string, any>;
11251140
assert.equal(session?.activeTokens, 27);
11261141
assert.equal(usage.prompt_tokens, 30);
11271142
assert.equal(usage.completion_tokens, 12);
@@ -1130,6 +1145,75 @@ test("SessionManager accumulates response usage while active tokens track the la
11301145
assert.equal(usage.completion_tokens_details.reasoning_tokens, 7);
11311146
assert.equal(usage.prompt_cache_hit_tokens, 18);
11321147
assert.equal(usage.prompt_cache_miss_tokens, 12);
1148+
assert.equal(usagePerModel.prompt_tokens, 30);
1149+
assert.equal(usagePerModel.completion_tokens, 12);
1150+
assert.equal(usagePerModel.total_tokens, 42);
1151+
assert.equal(usagePerModel.prompt_tokens_details.cached_tokens, 18);
1152+
assert.equal(usagePerModel.completion_tokens_details.reasoning_tokens, 7);
1153+
assert.equal(usagePerModel.prompt_cache_hit_tokens, 18);
1154+
assert.equal(usagePerModel.prompt_cache_miss_tokens, 12);
1155+
assert.equal(usagePerModel.total_reqs, 2);
1156+
});
1157+
1158+
test("SessionManager stores usage per model across model changes", async () => {
1159+
const workspace = createTempDir("deepcode-usage-per-model-workspace-");
1160+
const home = createTempDir("deepcode-usage-per-model-home-");
1161+
process.env.HOME = home;
1162+
1163+
let currentModel = "deepseek-v4-pro";
1164+
const responses = [
1165+
createChatResponse("pro response", {
1166+
prompt_tokens: 10,
1167+
completion_tokens: 5,
1168+
total_tokens: 15,
1169+
}),
1170+
createChatResponse("flash response", {
1171+
prompt_tokens: 20,
1172+
completion_tokens: 7,
1173+
total_tokens: 27,
1174+
prompt_cache_hit_tokens: 6,
1175+
}),
1176+
];
1177+
const client = {
1178+
chat: {
1179+
completions: {
1180+
create: async () => {
1181+
const response = responses.shift();
1182+
assert.ok(response, "expected a queued chat response");
1183+
return response;
1184+
},
1185+
},
1186+
},
1187+
};
1188+
const manager = new SessionManager({
1189+
projectRoot: workspace,
1190+
createOpenAIClient: () => ({
1191+
client: client as any,
1192+
model: currentModel,
1193+
baseURL: "https://api.deepseek.com",
1194+
thinkingEnabled: false,
1195+
}),
1196+
getResolvedSettings: () => ({ model: currentModel }),
1197+
renderMarkdown: (text) => text,
1198+
onAssistantMessage: () => {},
1199+
});
1200+
1201+
const sessionId = await manager.createSession({ text: "" });
1202+
currentModel = "deepseek-v4-flash";
1203+
await manager.replySession(sessionId, { text: "" });
1204+
1205+
const session = manager.getSession(sessionId);
1206+
assert.deepEqual(Object.keys(session?.usagePerModel ?? {}).sort(), ["deepseek-v4-flash", "deepseek-v4-pro"]);
1207+
assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.prompt_tokens, 10);
1208+
assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.completion_tokens, 5);
1209+
assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.total_reqs, 1);
1210+
assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_tokens, 20);
1211+
assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.completion_tokens, 7);
1212+
assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_cache_hit_tokens, 6);
1213+
assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.total_reqs, 1);
1214+
assert.equal(session?.usage?.prompt_tokens, 30);
1215+
assert.equal(session?.usage?.completion_tokens, 12);
1216+
assert.equal(session?.usage?.total_tokens, 42);
11331217
});
11341218

11351219
test("SessionManager resets active tokens to latest post-compaction response usage", async () => {
@@ -1163,10 +1247,15 @@ test("SessionManager resets active tokens to latest post-compaction response usa
11631247

11641248
const session = manager.getSession(sessionId);
11651249
const usage = session?.usage as Record<string, any>;
1250+
const usagePerModel = session?.usagePerModel?.["test-model"] as Record<string, any>;
11661251
assert.equal(session?.activeTokens, 7);
11671252
assert.equal(usage.prompt_tokens, 140_095);
11681253
assert.equal(usage.completion_tokens, 35);
11691254
assert.equal(usage.total_tokens, 140_130);
1255+
assert.equal(usagePerModel.prompt_tokens, 140_095);
1256+
assert.equal(usagePerModel.completion_tokens, 35);
1257+
assert.equal(usagePerModel.total_tokens, 140_130);
1258+
assert.equal(usagePerModel.total_reqs, 3);
11701259
});
11711260

11721261
test("SessionManager streams chat completions and counts reasoning progress", async () => {

src/ui/App.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
148148
setTimeout(() => {
149149
const activeSessionId = sessionManager.getActiveSessionId();
150150
const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null;
151-
const allMessages = activeSessionId
152-
? sessionManager.listSessionMessages(activeSessionId)
153-
: messagesRef.current;
154-
const resolved = resolveCurrentSettings(projectRoot);
155-
const summary = buildExitSummaryText({ session, messages: allMessages, model: resolved.model });
151+
const summary = buildExitSummaryText({ session });
156152
process.stdout.write("\n");
157153
process.stdout.write(chalk.green("> /exit "));
158154
process.stdout.write("\n\n");
@@ -256,7 +252,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R
256252
setRunningProcesses(null);
257253
}
258254
},
259-
[exit, onRestart, projectRoot, sessionManager, refreshSkills, refreshSessionsList]
255+
[exit, onRestart, sessionManager, refreshSkills, refreshSessionsList]
260256
);
261257

262258
const handleInterrupt = useCallback(() => {

0 commit comments

Comments
 (0)