Skip to content
Open
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
20 changes: 14 additions & 6 deletions apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,7 @@ describe("UsageService", () => {
);
await NodeFSP.mkdir(NodePath.join(codexHome, "sessions"), { recursive: true });
await NodeFSP.symlink(codexHome, alias, "junction");
await NodeFSP.writeFile(
NodePath.join(codexHome, "sessions", "rollout.jsonl"),
const codexRollout =
[
{ type: "session_meta", payload: { id: "codex-account-session" } },
{ type: "turn_context", payload: { model: "gpt-5.6-sol" } },
Expand All @@ -141,7 +140,16 @@ describe("UsageService", () => {
},
]
.map((line) => encodeUnknownJsonString(line))
.join("\n") + "\n",
.join("\n") + "\n";
await NodeFSP.writeFile(
NodePath.join(codexHome, "sessions", "rollout.jsonl"),
codexRollout,
);
// An identical archived copy must be scanned but charged only once.
await NodeFSP.mkdir(NodePath.join(codexHome, "archived_sessions"), { recursive: true });
await NodeFSP.writeFile(
NodePath.join(codexHome, "archived_sessions", "rollout.jsonl"),
codexRollout,
);
await NodeFSP.mkdir(NodePath.join(grokHome, "sessions", "session"), { recursive: true });
await NodeFSP.writeFile(
Expand Down Expand Up @@ -200,14 +208,14 @@ describe("UsageService", () => {
const summary = yield* service.readSummary(WINDOW);
assert.strictEqual(totalOutputTokens(summary), 36);
const sources = summary.sources.filter((source) => source.status === "ok");
assert.strictEqual(sources.length, 4);
assert.strictEqual(sources.length, 5);
assert.strictEqual(
sources.reduce((sum, source) => sum + source.scannedFiles, 0),
4,
5,
);
assert.strictEqual(
sources.filter((source) => source.fingerprint.provider === "codex").length,
1,
2,
);
}).pipe(Effect.scoped),
);
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/usage/UsageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,20 @@ export const make = Effect.gen(function* () {
if (seen.has(key)) continue;
seen.add(key);
dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) });
// Codex moves rolled-out sessions to archived_sessions; without this
// their cost never reaches usage. Copies across the two directories
// are charged once via the record dedupeKey in the aggregator.
if (provider === "codex") {
const archivedDirectory = path.resolve(home, "archived_sessions");
const archivedDir = yield* fileSystem
.realPath(archivedDirectory)
.pipe(Effect.orElseSucceed(() => archivedDirectory));
const archivedKey = `${provider}\0${archivedDir}`;
if (!seen.has(archivedKey)) {
seen.add(archivedKey);
dirs.push({ provider, dir: archivedDir });
}
}
}
}
return dirs;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/usage/usageScanCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe("scan cache round trip", () => {

it("rejects a document from the previous cache version", () => {
const encoded = encodeScanCache(cacheWith([["/a.jsonl", 100, [record()]]]));
const previous = { ...encoded, version: 2 };
const previous = { ...encoded, version: 3 };

expect(decodeScanCache(JSON.parse(JSON.stringify(previous))).size).toBe(0);
});
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/usage/usageScanCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts";
// entries would keep serving double-counted records forever.
// v3: entries carry the parse position and reducer state so a grown file
// re-parses only its appended bytes instead of starting over.
const USAGE_SCAN_CACHE_VERSION = 3 as const;
// v4: Codex events carry a stable dedupeKey so archived copies dedupe;
// v3 entries with null Codex keys would double count them.
const USAGE_SCAN_CACHE_VERSION = 4 as const;

export interface CachedFile {
readonly size: number;
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/usage/usageTranscripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,24 @@ describe("parseCodexLine", () => {
expect(record?.totals.reasoningTokens).toBe(116);
});

it("gives identical events a stable identity across rollout copies", () => {
const firstState = initialCodexScanState();
parseCodexLine(sessionMeta, firstState);
parseCodexLine(turnContext, firstState);
const first = parseCodexLine(tokenCount(19239, 11008, 299, 116), firstState);

// A second parse of the same copied file (e.g. sessions/ vs
// archived_sessions) must produce the same key so the aggregator
// charges it once.
const secondState = initialCodexScanState();
parseCodexLine(sessionMeta, secondState);
parseCodexLine(turnContext, secondState);
const second = parseCodexLine(tokenCount(19239, 11008, 299, 116), secondState);

expect(first?.dedupeKey).not.toBeNull();
expect(second?.dedupeKey).toBe(first?.dedupeKey);
});

it("skips a repeated token_count so deltas are not double counted", () => {
const state = initialCodexScanState();
parseCodexLine(turnContext, state);
Expand Down
18 changes: 15 additions & 3 deletions apps/server/src/usage/usageTranscripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,21 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord
totals,
// Codex does not report cost in the rollout.
reportedCostUsd: null,
// Events surviving the fork-copy suppression above are unique to this
// rollout, so they need no global dedup.
dedupeKey: null,
// Archiving or copying a rollout must not charge the same event twice.
dedupeKey:
state.sessionId.length === 0
? null
: JSON.stringify([
"codex",
state.sessionId,
timestampMs,
state.model,
totals.uncachedInputTokens,
totals.cachedInputTokens,
totals.cacheCreationTokens,
totals.outputTokens,
totals.reasoningTokens,
]),
};
}

Expand Down
Loading