Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/analyzer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const EMPTY_USAGE = Object.freeze({
totalTokens: 0,
});

export const ANALYZER_VERSION = 11;
export const ANALYZER_VERSION = 12;

function projectNameFromCwd(value) {
const name = String(value || "").replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop()?.trim();
Expand Down Expand Up @@ -232,6 +232,7 @@ export async function parseSessionFile(filePath, threadNames = new Map()) {
let currentTurnId = null;
let currentModel = "unknown";
let currentEffort = null;
let currentSettingsEffort = null;
let currentServiceTier = "default";
let userMessages = 0;
let assistantMessages = 0;
Expand Down Expand Up @@ -273,7 +274,7 @@ export async function parseSessionFile(filePath, threadNames = new Map()) {
const payload = row.payload || {};
currentTurnId = payload.turn_id || currentTurnId;
currentModel = payload.model || currentModel;
currentEffort = payload.effort || currentEffort;
currentEffort = payload.effort || currentSettingsEffort || null;
currentServiceTier = payload.service_tier || currentServiceTier;
const turn = turns.get(currentTurnId);
if (turn) {
Expand All @@ -296,9 +297,17 @@ export async function parseSessionFile(filePath, threadNames = new Map()) {
}

if (payload.type === "thread_settings_applied") {
currentServiceTier = payload.thread_settings?.service_tier || currentServiceTier;
const settings = payload.thread_settings || {};
currentServiceTier = settings.service_tier || currentServiceTier;
if (Object.hasOwn(settings, "reasoning_effort")) {
currentSettingsEffort = settings.reasoning_effort || null;
currentEffort = currentSettingsEffort;
}
const turn = currentTurnId ? turns.get(currentTurnId) : null;
if (turn && turn.calls === 0) turn.serviceTier = currentServiceTier;
if (turn && turn.calls === 0) {
turn.serviceTier = currentServiceTier;
if (Object.hasOwn(settings, "reasoning_effort")) turn.effort = currentEffort;
}
} else if (payload.type === "task_started") {
currentTurnId = payload.turn_id || `turn-${turns.size + 1}`;
if (!turns.has(currentTurnId)) {
Expand Down
42 changes: 38 additions & 4 deletions test/analyzer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ test("rebuilds cached quota history without Spark points while retaining token u
assert.deepEqual(rebuilt.weeklyQuotaHistory[0].observations.map(point => point.usedPercent), [82, 83]);
assert.equal(rebuilt.sessions[0].modelCalls, 4);
assert.equal(rebuilt.sessions[0].usage.totalTokens, 440);
assert.match(await usageFingerprint({ codexHome }), /^11:/);
assert.match(await usageFingerprint({ codexHome }), /^12:/);
});

test("merges nearby reset observations, retains plan transitions, and ignores unused drifting windows", () => {
Expand Down Expand Up @@ -134,6 +134,40 @@ test("parses turns, model calls and duration without message contents", async ()
assert.equal(JSON.stringify(result).includes("secret"), false);
});

test("does not carry an earlier turn's effort into a turn that omits it", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "codex-usage-effort-"));
const file = path.join(directory, "session.jsonl");
const rows = [
{ timestamp: "2026-09-28T08:00:00.000Z", type: "session_meta", payload: { id: "session-effort" } },
{ timestamp: "2026-09-28T08:00:01.000Z", type: "turn_context", payload: { turn_id: "turn-1", model: "gpt-6-sol", effort: "high" } },
{ timestamp: "2026-09-28T08:00:02.000Z", type: "event_msg", payload: { type: "task_started", turn_id: "turn-1" } },
{ timestamp: "2026-09-28T08:00:03.000Z", type: "event_msg", payload: { type: "token_count", info: { last_token_usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 } } } },
{ timestamp: "2026-09-28T08:01:01.000Z", type: "turn_context", payload: { turn_id: "turn-2", model: "gpt-5.6-sol" } },
{ timestamp: "2026-09-28T08:01:02.000Z", type: "event_msg", payload: { type: "task_started", turn_id: "turn-2" } },
{ timestamp: "2026-09-28T08:01:03.000Z", type: "event_msg", payload: { type: "token_count", info: { last_token_usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 } } } },
];
await writeFile(file, rows.map(JSON.stringify).join("\n"));
const result = await parseSessionFile(file);
assert.deepEqual(result.turns.map((turn) => turn.effort), ["high", null]);
assert.deepEqual(result.calls.map((call) => call.effort), ["high", null]);
});

test("recovers effort from applied thread settings when turn context omits it", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "codex-usage-settings-effort-"));
const file = path.join(directory, "session.jsonl");
const rows = [
{ timestamp: "2026-09-28T08:00:00.000Z", type: "session_meta", payload: { id: "session-settings-effort" } },
{ timestamp: "2026-09-28T08:00:01.000Z", type: "turn_context", payload: { turn_id: "turn-1", model: "gpt-5.6-sol" } },
{ timestamp: "2026-09-28T08:00:02.000Z", type: "event_msg", payload: { type: "thread_settings_applied", thread_settings: { reasoning_effort: "xhigh" } } },
{ timestamp: "2026-09-28T08:00:03.000Z", type: "event_msg", payload: { type: "task_started", turn_id: "turn-1" } },
{ timestamp: "2026-09-28T08:00:04.000Z", type: "event_msg", payload: { type: "token_count", info: { last_token_usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 } } } },
];
await writeFile(file, rows.map(JSON.stringify).join("\n"));
const result = await parseSessionFile(file);
assert.equal(result.turns[0].effort, "xhigh");
assert.equal(result.calls[0].effort, "xhigh");
});

test("canonicalizes only credential-free GitHub repository URLs", () => {
assert.equal(normalizeGitHubRepositoryUrl("https://TOKEN@GitHub.com/OpenAI/Example.git?ignored=1"), "https://github.com/openai/example");
assert.equal(normalizeGitHubRepositoryUrl("ssh://git@github.com/OpenAI/Example.git"), "https://github.com/openai/example");
Expand Down Expand Up @@ -167,7 +201,7 @@ test("reuses persisted per-file analysis when a session has not changed", async

const first = await analyzeCodexUsage({ codexHome });
const second = await analyzeCodexUsage({ codexHome, previousData: first });
assert.equal(first.analyzerVersion, 11);
assert.equal(first.analyzerVersion, 12);
assert.equal(second.sessions[0], first.sessions[0]);
assert.equal(second.sessions[0].fileSize > 0, true);
assert.equal(Number.isFinite(second.sessions[0].fileModifiedAtMs), true);
Expand Down Expand Up @@ -202,7 +236,7 @@ test("supports least-privilege scoped sources without a Codex home mount", async
const result = await analyzeCodexUsage(options);
assert.equal(result.sessions[0].title, "Scoped source");
assert.equal(result.source.mode, "scoped");
assert.match(await usageFingerprint(options), /^11:1:/);
assert.match(await usageFingerprint(options), /^12:1:/);
});

test("fingerprints include the analyzer version so persisted snapshots migrate after upgrades", async () => {
Expand All @@ -213,7 +247,7 @@ test("fingerprints include the analyzer version so persisted snapshots migrate a
await mkdir(archivedSessionsPath);
await writeFile(path.join(sessionsPath, "session.jsonl"), `${JSON.stringify({ type: "session_meta", payload: { id: "versioned" } })}\n`);
const fingerprint = await usageFingerprint({ sessionsPath, archivedSessionsPath, sessionIndexPath: path.join(root, "missing-index.jsonl") });
assert.match(fingerprint, /^11:/);
assert.match(fingerprint, /^12:/);
});

test("rejects a source with no readable session directory", async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/dated-pricing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ test("version 7 persisted sessions are reparsed to recover cache-write counters"
const legacy = structuredClone(first); legacy.analyzerVersion = 7;
delete legacy.sessions[0].calls[0].usage.cacheWriteInputTokens;
const migrated = await analyzeCodexUsage({ ...options, previousData: legacy });
assert.equal(migrated.analyzerVersion, 11);
assert.equal(migrated.analyzerVersion, 12);
assert.equal(migrated.sessions[0].calls[0].usage.cacheWriteInputTokens, 40);
} finally { await rm(dir, { recursive: true, force: true }); }
});
Expand Down
2 changes: 1 addition & 1 deletion test/quota-observations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ test("version 8 cached sessions are reparsed to recover historical quota observa
legacy.analyzerVersion = 8;
for (const period of legacy.sessions[0].weeklyQuotaHistory) delete period.observations;
const recovered = await analyzeCodexUsage({ ...options, previousData: legacy });
assert.equal(recovered.analyzerVersion, 11);
assert.equal(recovered.analyzerVersion, 12);
assert.deepEqual(recovered.weeklyQuotaHistory[0].observations, compactQuotaObservations(observations));
} finally { await rm(dir, { recursive: true, force: true }); }
});
Expand Down
Loading