Skip to content
Closed
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
4 changes: 3 additions & 1 deletion sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,9 @@ including cached input and cache writes; fees and surcharges are not included.

Use `--max-cost USD` to stop a scan, including its delegated workers, when its
running cost exceeds the limit. Partial results are preserved. Requests
already in progress can finish above the limit.
already in progress can finish above the limit. Cost tracking accepts Codex
session events up to 1 MiB; an oversized event stops the scan because its
running cost can no longer be verified safely.

Run `npx @openai/codex-security scan --help` or `npx @openai/codex-security bulk-scan --help`
for the complete CLI references.
Expand Down
65 changes: 49 additions & 16 deletions sdk/typescript/src/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ interface ScanTokenUsage {

interface SessionUsage {
offset: number;
remainder: Buffer;
pendingLine: Buffer[];
pendingLineBytes: number;
unreadable: boolean;
threadId: string | null;
parentThreadId: string | null;
usage: ScanTokenUsage | null;
Expand Down Expand Up @@ -56,6 +58,7 @@ const MODEL_PRICING_NANODOLLARS: Readonly<Record<string, ModelPricing>> = {

const COST_POLL_INTERVAL_MS = 100;
const SESSION_READ_SIZE = 64 * 1_024;
const MAX_SESSION_EVENT_BYTES = 1 * 1_024 * 1_024;

export class ScanCostTracker {
readonly #options: ScanCostTrackerOptions;
Expand Down Expand Up @@ -115,7 +118,9 @@ export class ScanCostTracker {
if (session === undefined) {
session = {
offset: 0,
remainder: Buffer.alloc(0),
pendingLine: [],
pendingLineBytes: 0,
unreadable: false,
threadId: null,
parentThreadId: null,
usage: null,
Expand Down Expand Up @@ -187,6 +192,7 @@ async function readSessionUsage(
path: string,
session: SessionUsage,
): Promise<void> {
if (session.unreadable) return;
let file;
try {
file = await open(path, "r");
Expand All @@ -205,27 +211,54 @@ async function readSessionUsage(
);
if (bytesRead === 0) return;
session.offset += bytesRead;
const contents = Buffer.concat([
session.remainder,
buffer.subarray(0, bytesRead),
]);
let lineStart = 0;
while (true) {
const lineEnd = contents.indexOf(0x0a, lineStart);
if (lineEnd === -1) break;
readSessionEvent(
contents.subarray(lineStart, lineEnd).toString("utf8"),
session,
);
lineStart = lineEnd + 1;
try {
readSessionChunk(buffer.subarray(0, bytesRead), session);
} catch (error) {
session.unreadable = true;
session.pendingLine = [];
session.pendingLineBytes = 0;
throw error;
}
session.remainder = Buffer.from(contents.subarray(lineStart));
}
} finally {
await file.close();
}
}

function readSessionChunk(contents: Buffer, session: SessionUsage): void {
let lineStart = 0;
while (lineStart < contents.length) {
const newline = contents.indexOf(0x0a, lineStart);
const lineEnd = newline === -1 ? contents.length : newline;
const fragment = contents.subarray(lineStart, lineEnd);
const lineBytes = session.pendingLineBytes + fragment.length;
if (lineBytes > MAX_SESSION_EVENT_BYTES) {
throw new Error("Codex session event exceeds the 1 MiB safety limit.");
}

if (newline === -1) {
if (fragment.length > 0) {
session.pendingLine.push(Buffer.from(fragment));
session.pendingLineBytes = lineBytes;
}
return;
}

if (session.pendingLineBytes === 0) {
readSessionEvent(fragment.toString("utf8"), session);
} else {
if (fragment.length > 0) session.pendingLine.push(Buffer.from(fragment));
readSessionEvent(
Buffer.concat(session.pendingLine, lineBytes).toString("utf8"),
session,
);
session.pendingLine = [];
session.pendingLineBytes = 0;
}
lineStart = newline + 1;
}
}

function readSessionEvent(line: string, session: SessionUsage): void {
if (line.length === 0) return;
let event: unknown;
Expand Down
56 changes: 56 additions & 0 deletions sdk/typescript/tests-ts/cost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,62 @@ describe("live scan cost tracking", () => {
});
});

test("retains a bounded partial event across incremental reads", async () => {
const home = await codexHome();
const path = await writeSession(home, "scan-thread", {
input_tokens: 100,
output_tokens: 10,
});
const tracker = new ScanCostTracker({
codexHome: home,
model: "gpt-5.6-terra",
});
tracker.start("scan-thread");
await tracker.refresh();

const event = JSON.stringify({
type: "event_msg",
payload: {
type: "token_count",
info: {
total_token_usage: { input_tokens: 250, output_tokens: 20 },
},
},
});
const padding = " ".repeat(128 * 1_024);
await appendFile(path, `${padding}${event.slice(0, 40)}`);
expect((await tracker.refresh()).cost?.inputTokens).toBe(100);

await appendFile(path, `${event.slice(40)}\n`);
expect((await tracker.stop()).cost?.inputTokens).toBe(250);
});

test("rejects and quarantines a session event larger than 1 MiB", async () => {
const home = await codexHome();
const path = await writeSession(home, "scan-thread", {
input_tokens: 100,
output_tokens: 10,
});
await appendFile(path, "x".repeat(1 * 1_024 * 1_024 + 1));
const tracker = new ScanCostTracker({
codexHome: home,
model: "gpt-5.6-terra",
});
tracker.start("scan-thread");

await expect(tracker.refresh()).rejects.toThrow(
"Codex session event exceeds the 1 MiB safety limit.",
);
expect((await tracker.refresh()).cost).toEqual({
model: "gpt-5.6-terra",
inputTokens: 100,
cachedInputTokens: 0,
cacheWriteInputTokens: 0,
outputTokens: 10,
estimatedUsd: 0.0004,
});
});

test("reports a changed running cost only once", async () => {
const home = await codexHome();
await writeSession(home, "scan-thread", {
Expand Down