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
28 changes: 25 additions & 3 deletions sdk/typescript/src/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ interface ScanCostSnapshot {
cost: ScanCost | null;
}

// Access refusals that the process cannot clear by retrying, such as a rollout
// left behind by another account. Any other open failure may be transient, so
// it keeps being retried instead of retiring a session that could still be read.
const PERMANENT_ACCESS_ERROR_CODES: ReadonlySet<string> = new Set([
"EACCES",
"EPERM",
]);

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

Expand Down Expand Up @@ -403,6 +411,7 @@ async function readSessionUsage(
file = await open(path, "r");
} catch (error) {
if (isMissingFile(error)) return;
if (isPermanentAccessError(error)) quarantineSession(session);
throw error;
}
try {
Expand All @@ -419,9 +428,7 @@ async function readSessionUsage(
try {
readSessionChunk(buffer.subarray(0, bytesRead), session, repository);
} catch (error) {
session.unreadable = true;
session.pendingLine = [];
session.pendingLineBytes = 0;
quarantineSession(session);
throw error;
}
}
Expand Down Expand Up @@ -843,3 +850,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isMissingFile(error: unknown): boolean {
return isRecord(error) && error["code"] === "ENOENT";
}

function isPermanentAccessError(error: unknown): boolean {
if (!isRecord(error)) return false;
const code = error["code"];
return typeof code === "string" && PERMANENT_ACCESS_ERROR_CODES.has(code);
}

// A retired session is reported once and then skipped, which is how the reader
// already treats a log it cannot parse. Retiring it is what stops one bad file
// from failing every later poll of the same scan.
function quarantineSession(session: SessionUsage): void {
session.unreadable = true;
session.pendingLine = [];
session.pendingLineBytes = 0;
}
74 changes: 74 additions & 0 deletions sdk/typescript/tests-ts/cost.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import {
appendFile,
chmod,
mkdir,
mkdtemp,
realpath,
Expand Down Expand Up @@ -582,6 +583,79 @@ describe("live scan cost tracking", () => {
expect((await tracker.stop()).cost?.inputTokens).toBe(100);
});

test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports an inaccessible prior session log once instead of on every poll",
async () => {
const home = await codexHome();
await writeSession(home, "scan-thread", {
input_tokens: 100,
output_tokens: 10,
});
const inaccessible = await writeSession(home, "prior-thread", {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
});
await chmod(inaccessible, 0o000);
const errors: string[] = [];
const tracker = new ScanCostTracker({
codexHome: home,
model: "gpt-5.6-sol",
maxCostUsd: 1,
onError: (error) => {
if (error instanceof Error) errors.push(error.message);
},
});
tracker.start("scan-thread");

try {
await waitFor(() => errors.length === 1);
// Three more poll intervals: a retired file is not opened again.
await new Promise<void>((resolve) => setTimeout(resolve, 300));
expect(errors).toHaveLength(1);
expect(errors[0]).toContain("EACCES");
await tracker.stop();
} finally {
await chmod(inaccessible, 0o600);
}
},
);

test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"still reports the scan's own usage when a prior session log is inaccessible",
async () => {
const home = await codexHome();
await writeSession(home, "scan-thread", {
input_tokens: 100,
output_tokens: 10,
});
const inaccessible = await writeSession(home, "prior-thread", {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
});
await chmod(inaccessible, 0o000);
let reportFirstError!: () => void;
const firstError = new Promise<void>((resolve) => {
reportFirstError = resolve;
});
const tracker = new ScanCostTracker({
codexHome: home,
model: "gpt-5.6-sol",
maxCostUsd: 1,
onError: () => reportFirstError(),
});
tracker.start("scan-thread");

try {
await firstError;
const stopped = await tracker.stop();
expect(stopped.cost?.inputTokens).toBe(100);
expect(stopped.usage).toMatchObject({ input_tokens: 100 });
} finally {
await chmod(inaccessible, 0o600);
}
},
);

test("reports live token use and cost without a spending limit", async () => {
const home = await codexHome();
await writeSession(home, "scan-thread", {
Expand Down
Loading