Skip to content

Commit a0fbf40

Browse files
committed
fix(usage): scan full sessions for model names
1 parent 43c8f14 commit a0fbf40

2 files changed

Lines changed: 212 additions & 6 deletions

File tree

cli.js

Lines changed: 151 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5481,6 +5481,129 @@ async function listSessionBrowse(params = {}) {
54815481
}
54825482

54835483
async function listSessionUsage(params = {}) {
5484+
function normalizeSessionModelList(values = []) {
5485+
const models = [];
5486+
for (const value of values) {
5487+
if (typeof value !== 'string') {
5488+
continue;
5489+
}
5490+
const normalized = value.trim();
5491+
if (!normalized || models.includes(normalized)) {
5492+
continue;
5493+
}
5494+
models.push(normalized);
5495+
}
5496+
return models;
5497+
}
5498+
5499+
function readSessionModelsFromFile(filePath) {
5500+
const targetPath = typeof filePath === 'string' ? filePath.trim() : '';
5501+
if (!targetPath) {
5502+
return [];
5503+
}
5504+
5505+
const cache = listSessionUsage.__modelsByFileCache instanceof Map
5506+
? listSessionUsage.__modelsByFileCache
5507+
: new Map();
5508+
listSessionUsage.__modelsByFileCache = cache;
5509+
5510+
let stat;
5511+
try {
5512+
stat = fs.statSync(targetPath);
5513+
} catch (e) {
5514+
return [];
5515+
}
5516+
5517+
const cacheKey = `${targetPath}:${stat.size}:${stat.mtimeMs}`;
5518+
if (cache.has(cacheKey)) {
5519+
return [...cache.get(cacheKey)];
5520+
}
5521+
5522+
let content = '';
5523+
try {
5524+
content = fs.readFileSync(targetPath, 'utf-8');
5525+
} catch (e) {
5526+
return [];
5527+
}
5528+
5529+
const models = [];
5530+
const pushModel = (value) => {
5531+
if (typeof value !== 'string') {
5532+
return;
5533+
}
5534+
const normalized = value.trim();
5535+
if (!normalized || models.includes(normalized)) {
5536+
return;
5537+
}
5538+
models.push(normalized);
5539+
};
5540+
5541+
for (const line of content.split(/\r?\n/)) {
5542+
if (!line.trim()) {
5543+
continue;
5544+
}
5545+
let record;
5546+
try {
5547+
record = JSON.parse(line);
5548+
} catch (e) {
5549+
continue;
5550+
}
5551+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
5552+
continue;
5553+
}
5554+
const payload = record.payload && typeof record.payload === 'object' && !Array.isArray(record.payload)
5555+
? record.payload
5556+
: null;
5557+
const info = payload && payload.info && typeof payload.info === 'object' && !Array.isArray(payload.info)
5558+
? payload.info
5559+
: null;
5560+
const collaborationMode = payload && payload.collaboration_mode && typeof payload.collaboration_mode === 'object' && !Array.isArray(payload.collaboration_mode)
5561+
? payload.collaboration_mode
5562+
: null;
5563+
const collaborationSettings = collaborationMode && collaborationMode.settings && typeof collaborationMode.settings === 'object' && !Array.isArray(collaborationMode.settings)
5564+
? collaborationMode.settings
5565+
: null;
5566+
const message = record.message && typeof record.message === 'object' && !Array.isArray(record.message)
5567+
? record.message
5568+
: null;
5569+
const candidates = [
5570+
payload && payload.model,
5571+
payload && payload.model_name,
5572+
payload && payload.model_id,
5573+
payload && payload.modelId,
5574+
info && info.model,
5575+
info && info.model_name,
5576+
info && info.model_id,
5577+
info && info.modelId,
5578+
collaborationSettings && collaborationSettings.model,
5579+
collaborationSettings && collaborationSettings.model_name,
5580+
collaborationSettings && collaborationSettings.model_id,
5581+
collaborationSettings && collaborationSettings.modelId,
5582+
message && message.model,
5583+
message && message.model_name,
5584+
message && message.model_id,
5585+
message && message.modelId,
5586+
record.model,
5587+
record.modelName,
5588+
record.model_name,
5589+
record.model_id,
5590+
record.modelId
5591+
];
5592+
for (const candidate of candidates) {
5593+
pushModel(candidate);
5594+
}
5595+
}
5596+
5597+
cache.set(cacheKey, models);
5598+
if (cache.size > 500) {
5599+
const firstKey = cache.keys().next().value;
5600+
if (firstKey) {
5601+
cache.delete(firstKey);
5602+
}
5603+
}
5604+
return [...models];
5605+
}
5606+
54845607
const source = params.source === 'codex' || params.source === 'claude'
54855608
? params.source
54865609
: 'all';
@@ -5500,15 +5623,28 @@ async function listSessionUsage(params = {}) {
55005623
}
55015624
const normalized = { ...item };
55025625
delete normalized.__messageCountExact;
5626+
const filePath = typeof normalized.filePath === 'string' ? normalized.filePath.trim() : '';
5627+
const fullFileModels = filePath ? readSessionModelsFromFile(filePath) : [];
5628+
const mergedModels = normalizeSessionModelList([
5629+
...(Array.isArray(normalized.models) ? normalized.models : []),
5630+
...fullFileModels,
5631+
normalized.model,
5632+
normalized.modelName,
5633+
normalized.modelId
5634+
]);
5635+
if (mergedModels.length > 0) {
5636+
normalized.models = mergedModels;
5637+
if ((!normalized.model || !String(normalized.model).trim())) {
5638+
normalized.model = mergedModels[0];
5639+
}
5640+
}
55035641
const hasModel = [normalized.model, normalized.modelName, normalized.modelId]
55045642
.some((value) => typeof value === 'string' && value.trim());
55055643
const hasModels = Array.isArray(normalized.models)
55065644
&& normalized.models.some((value) => typeof value === 'string' && value.trim());
55075645
if (hasModel || hasModels) {
55085646
return normalized;
55095647
}
5510-
5511-
const filePath = typeof normalized.filePath === 'string' ? normalized.filePath.trim() : '';
55125648
if (!filePath) {
55135649
return normalized;
55145650
}
@@ -5532,10 +5668,19 @@ async function listSessionUsage(params = {}) {
55325668
if (typeof summary.model === 'string' && summary.model.trim()) {
55335669
normalized.model = summary.model.trim();
55345670
}
5535-
if (Array.isArray(summary.models)) {
5536-
normalized.models = summary.models
5537-
.filter((value, index, list) => typeof value === 'string' && value.trim() && list.indexOf(value) === index)
5538-
.map((value) => value.trim());
5671+
const summaryModels = Array.isArray(summary.models) ? summary.models : [];
5672+
const allModels = normalizeSessionModelList([
5673+
...summaryModels,
5674+
...fullFileModels,
5675+
normalized.model,
5676+
normalized.modelName,
5677+
normalized.modelId
5678+
]);
5679+
if (allModels.length > 0) {
5680+
normalized.models = allModels;
5681+
if ((!normalized.model || !String(normalized.model).trim())) {
5682+
normalized.model = allModels[0];
5683+
}
55395684
}
55405685
if ((!normalized.provider || !String(normalized.provider).trim()) && typeof summary.provider === 'string' && summary.provider.trim()) {
55415686
normalized.provider = summary.provider.trim();

tests/unit/session-usage-backend.test.mjs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ test('listSessionUsage uses lightweight session listing without exact hydration'
142142
throw new Error('should not call listAllSessionsData');
143143
};
144144
const listSessionUsage = instantiateListSessionUsage({
145+
fs,
145146
MAX_SESSION_USAGE_LIST_SIZE: 2000,
146147
listSessionBrowse,
147148
listAllSessionsData,
@@ -191,6 +192,7 @@ test('listSessionUsage normalizes source and default limit for lightweight usage
191192
return [];
192193
};
193194
const listSessionUsage = instantiateListSessionUsage({
195+
fs,
194196
MAX_SESSION_USAGE_LIST_SIZE: 2000,
195197
listSessionBrowse,
196198
SESSION_BROWSE_SUMMARY_READ_BYTES: 65536,
@@ -230,6 +232,7 @@ test('listSessionUsage backfills missing model metadata from parsed session summ
230232
const codexParses = [];
231233
const claudeParses = [];
232234
const listSessionUsage = instantiateListSessionUsage({
235+
fs,
233236
MAX_SESSION_USAGE_LIST_SIZE: 2000,
234237
SESSION_BROWSE_SUMMARY_READ_BYTES: 65536,
235238
async listSessionBrowse() {
@@ -293,6 +296,64 @@ test('listSessionUsage backfills missing model metadata from parsed session summ
293296
]);
294297
});
295298

299+
test('listSessionUsage scans the full session file so middle model names are not dropped', async () => {
300+
const tempDir = fs.mkdtempSync(path.join(__dirname, 'tmp-session-model-scan-'));
301+
const filePath = path.join(tempDir, 'codex-middle.jsonl');
302+
fs.writeFileSync(filePath, [
303+
JSON.stringify({
304+
timestamp: '2026-04-12T09:07:24.127Z',
305+
type: 'session_meta',
306+
payload: { id: 'codex-middle', cwd: '/repo' }
307+
}),
308+
JSON.stringify({
309+
timestamp: '2026-04-12T09:08:00.000Z',
310+
type: 'turn_context',
311+
payload: { model: 'gpt-5.3-codex' }
312+
}),
313+
JSON.stringify({
314+
timestamp: '2026-04-12T09:09:00.000Z',
315+
type: 'event_msg',
316+
payload: { info: { model_name: 'gpt-5.2-codex' } }
317+
})
318+
].join('\n'));
319+
320+
const codexParses = [];
321+
const listSessionUsage = instantiateListSessionUsage({
322+
fs,
323+
MAX_SESSION_USAGE_LIST_SIZE: 2000,
324+
SESSION_BROWSE_SUMMARY_READ_BYTES: 65536,
325+
async listSessionBrowse() {
326+
return [
327+
{
328+
source: 'codex',
329+
sessionId: 'codex-middle',
330+
filePath,
331+
model: 'gpt-5.3-codex'
332+
}
333+
];
334+
},
335+
parseCodexSessionSummary(...args) {
336+
codexParses.push(args);
337+
return null;
338+
},
339+
parseClaudeSessionSummary() {
340+
return null;
341+
},
342+
listAllSessionsData: async () => {
343+
throw new Error('should not call listAllSessionsData');
344+
}
345+
});
346+
347+
try {
348+
const result = await listSessionUsage({ source: 'codex', limit: 50, forceRefresh: true });
349+
assert.deepStrictEqual(result[0].models, ['gpt-5.3-codex', 'gpt-5.2-codex']);
350+
assert.strictEqual(result[0].model, 'gpt-5.3-codex');
351+
assert.deepStrictEqual(codexParses, []);
352+
} finally {
353+
fs.rmSync(tempDir, { recursive: true, force: true });
354+
}
355+
});
356+
296357
test('parseCodexSessionSummary reads token totals and model data from tail records', () => {
297358
const parseCodexSessionSummary = instantiateFunctionBundle(
298359
[

0 commit comments

Comments
 (0)