From 3fa287b4582db7c16771ab09029ef262e99af9b6 Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Wed, 9 Sep 2026 10:54:52 +0000 Subject: [PATCH 1/7] feat: add subscription-first billing policy --- config.toml.example | 2 + src/billing.ts | 354 ++++++++++++++++++++++++++++++++++++++ src/config.ts | 10 +- src/types.ts | 2 + tests/billing.test.ts | 392 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 759 insertions(+), 1 deletion(-) create mode 100644 src/billing.ts create mode 100644 tests/billing.test.ts diff --git a/config.toml.example b/config.toml.example index 8a76dea..526e2db 100644 --- a/config.toml.example +++ b/config.toml.example @@ -15,10 +15,12 @@ list_waiting_after_ms = 15000 [agents.claude] model = "claude-opus-4-6" +# billing = "auto" # Subscription first; Amazon Bedrock after a subscription limit. # reasoning_effort = "xhigh" [agents.codex] model = "gpt-5.5" +# billing = "auto" # Subscription first; OpenAI API after a subscription limit. # reasoning_effort = "xhigh" [agents.cursor] diff --git a/src/billing.ts b/src/billing.ts new file mode 100644 index 0000000..744bc3c --- /dev/null +++ b/src/billing.ts @@ -0,0 +1,354 @@ +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import type { HeadlessConfig } from "./config.js"; +import type { AgentName, BillingMode, BuildOptions, Env } from "./types.js"; + +export type { BillingMode } from "./types.js"; +export type BillingRoute = "subscription" | "openai-api" | "bedrock" | "native"; +export interface BillingAttempt { route: BillingRoute; env: Env } + +export class BillingError extends Error { + readonly exitCode = 78; + constructor(message: string) { + super(message); + this.name = "BillingError"; + } +} + +const claudePaidVariables = [ + "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_MANTLE", "CLAUDE_CODE_USE_ANTHROPIC_AWS", + "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", + "HEADLESS_CLAUDE_AUTH", +]; +const maxAuthBytes = 1024 * 1024; +type StoredAuth = "subscription" | "api" | "missing"; + +export function resolveBillingMode( + agent: AgentName, explicit: BillingMode | undefined, env: Env, config: HeadlessConfig, +): BillingMode { + if (agent !== "claude" && agent !== "codex") return explicit ?? "auto"; + const value = explicit ?? env.HEADLESS_BILLING ?? config.agents[agent]?.billing ?? "auto"; + if (value === "auto" || value === "subscription" || value === "api") return value; + throw new BillingError("HEADLESS_BILLING must be auto, subscription, or api"); +} + +export function prepareBillingAttempt( + agent: AgentName, options: BuildOptions, env: Env, mode: BillingMode, routeOverride?: BillingRoute, +): BillingAttempt { + if (agent !== "claude" && agent !== "codex") { + if (mode !== "auto" || (routeOverride && routeOverride !== "native")) { + throw new BillingError("billing selection is supported only for Claude and Codex"); + } + return { route: "native", env: { ...env } }; + } + const route = routeOverride ?? selectRoute(agent, options, env, mode); + validateRoute(agent, mode, route); + if (route === "native") return { route, env: { ...env } }; + if (route === "subscription") return prepareSubscription(agent, options, env); + return agent === "codex" ? prepareOpenAiApi(options, env) : prepareBedrock(env); +} + +export function prepareBillingPreview( + agent: AgentName, options: BuildOptions, env: Env, mode: BillingMode, +): BillingAttempt { + if (agent !== "claude" && agent !== "codex") return prepareBillingAttempt(agent, options, env, mode); + const route = selectRoute(agent, options, env, mode); + if (route === "openai-api") return { route, env: openAiApiEnvironment(env) }; + if (route === "bedrock") return { route, env: bedrockEnvironment(env) }; + return prepareBillingAttempt(agent, options, env, mode, route); +} + +function selectRoute(agent: "claude" | "codex", options: BuildOptions, env: Env, mode: BillingMode): BillingRoute { + if (agent === "codex" && hasCustomCodexRouting(options, env)) { + if (mode === "auto") return "native"; + throw new BillingError("explicit Codex profile/provider routing cannot be combined with billing selection"); + } + if (mode === "subscription") return "subscription"; + const paidRoute = agent === "codex" ? "openai-api" : "bedrock"; + if (mode === "api") return paidRoute; + if (agent === "codex" && isApiOnlyCodexModel(options.model ?? env.CODEX_MODEL)) return paidRoute; + if (hasSubscription(agent, env)) return "subscription"; + if (agent === "codex" ? openAiKey(env) : hasBedrockConfiguration(env)) return paidRoute; + return "native"; +} + +function validateRoute(agent: "claude" | "codex", mode: BillingMode, route: BillingRoute): void { + if (mode === "subscription" && route !== "subscription") { + throw new BillingError("subscription billing cannot switch to a paid or unspecified authentication route"); + } + if ((route === "openai-api" && agent !== "codex") || (route === "bedrock" && agent !== "claude")) { + throw new BillingError("billing route does not match the selected agent"); + } + if (mode === "api" && (route === "native" || route === "subscription")) { + throw new BillingError("API billing requires an explicit paid authentication route"); + } +} + +function prepareSubscription(agent: "claude" | "codex", options: BuildOptions, env: Env): BillingAttempt { + const prepared = { ...env }; + if (agent === "codex") { + if (hasCustomCodexRouting(options, env)) throw new BillingError("subscription billing cannot use a custom Codex profile/provider"); + if (storedAuth(agent, env) === "api") { + throw new BillingError("subscription billing cannot use the stored API login; sign in to Codex with ChatGPT first"); + } + rejectCodexLoginRestriction(env, "api"); + remove(prepared, ["CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL"]); + } else { + rejectClaudePaidSettings(options, env); + if (storedAuth(agent, env) === "api") { + throw new BillingError("subscription billing cannot use stored Claude API authentication; sign in to Claude with a subscription first"); + } + remove(prepared, claudePaidVariables); + } + return { route: "subscription", env: prepared }; +} + +function prepareOpenAiApi(options: BuildOptions, env: Env): BillingAttempt { + if (hasCustomCodexRouting(options, env)) { + throw new BillingError("API billing cannot replace an explicit Codex profile/provider; use its native authentication"); + } + const key = openAiKey(env); + if (!key) throw new BillingError("Codex API billing requires CODEX_API_KEY or OPENAI_API_KEY; configure an API key for this invocation"); + rejectCodexLoginRestriction(env, "chatgpt"); + return { route: "openai-api", env: openAiApiEnvironment(env) }; +} + +function prepareBedrock(env: Env): BillingAttempt { + if (!hasBedrockConfiguration(env)) { + throw new BillingError("Claude API billing requires Amazon Bedrock credentials and AWS_REGION or AWS_DEFAULT_REGION; configure the AWS credential chain"); + } + return { route: "bedrock", env: bedrockEnvironment(env) }; +} + +function openAiApiEnvironment(env: Env): Env { + return { ...env, CODEX_API_KEY: openAiKey(env), CODEX_ACCESS_TOKEN: undefined }; +} + +function bedrockEnvironment(env: Env): Env { + const prepared = { ...env }; + remove(prepared, [...claudePaidVariables, "CLAUDE_CODE_OAUTH_TOKEN"]); + prepared.CLAUDE_CODE_USE_BEDROCK = "1"; + return prepared; +} + +function hasSubscription(agent: "claude" | "codex", env: Env): boolean { + return storedAuth(agent, env) === "subscription"; +} + +function storedAuth(agent: "claude" | "codex", env: Env): StoredAuth { + if (agent === "codex") { + if (nonempty(env.CODEX_ACCESS_TOKEN)) return "subscription"; + const home = codexConfigDir(env); + const config = home ? readBoundedFile(join(home, "config.toml")) : undefined; + const store = rootTomlString(config, "cli_auth_credentials_store"); + if (store === "keyring" || store === "auto") return nativeStoredAuth(agent, env); + const auth = codexAuth(env); + if (auth.auth_mode === "apikey") return "api"; + if (nonempty(asRecord(auth.tokens).access_token)) return "subscription"; + if (nonempty(auth.OPENAI_API_KEY)) return "api"; + return "missing"; + } + if (nonempty(env.CLAUDE_CODE_OAUTH_TOKEN)) return "subscription"; + const configDir = claudeConfigDir(env); + if (!configDir) return "missing"; + if (nonempty(claudeGlobalConfig(env, configDir).primaryApiKey)) return "api"; + for (const name of [".credentials.json", "auth.json"]) { + const auth = readJson(join(configDir, name)); + if (nonempty(asRecord(auth.claudeAiOauth).accessToken)) return "subscription"; + } + return process.platform === "darwin" ? nativeStoredAuth(agent, env) : "missing"; +} + +function claudeGlobalConfig(env: Env, configDir: string): Record { + const legacyConfig = readBoundedFile(join(configDir, ".config.json")); + if (legacyConfig !== undefined) return parseJson(legacyConfig); + const root = env.CLAUDE_CONFIG_DIR || env.HOME; + return root ? readJson(join(root, ".claude.json")) : {}; +} + +function codexAuth(env: Env): Record { + const home = codexConfigDir(env); + return home ? readJson(join(home, "auth.json")) : {}; +} + +function hasCustomCodexRouting(options: BuildOptions, env: Env): boolean { + if (options.profile) return true; + const home = codexConfigDir(env); + const config = home ? readBoundedFile(join(home, "config.toml")) : undefined; + const provider = rootTomlString(config, "model_provider"); + return Boolean(provider && provider !== "openai"); +} + +function rejectCodexLoginRestriction(env: Env, conflicting: "api" | "chatgpt"): void { + const home = codexConfigDir(env); + const config = home ? readBoundedFile(join(home, "config.toml")) : undefined; + const restriction = rootTomlString(config, "forced_login_method"); + if (restriction === conflicting) { + throw new BillingError("Codex forced_login_method conflicts with selected billing; update the native configuration before switching auth"); + } +} + +function rootTomlString(content: string | undefined, key: string): string | undefined { + let multiline: string | undefined; + for (const line of (content ?? "").split(/\r?\n/)) { + if (multiline) { + if (line.includes(multiline)) multiline = undefined; + continue; + } + const trimmed = line.trim(); + if (trimmed.startsWith("[")) break; + if (trimmed.startsWith("#")) continue; + const assignment = trimmed.match(/^("(?:[^"\\]|\\.)*"|'[^']*'|[\w-]+)\s*=\s*(.*)$/); + if (!assignment) continue; + const name = /^["']/.test(assignment[1]) ? tomlString(assignment[1]) : assignment[1]; + const rawValue = assignment[2]; + const triple = rawValue.match(/^("""|''')/); + if (triple) { + if (name === key) throw uninspectableToml(); + if (!rawValue.slice(3).includes(triple[1])) multiline = triple[1]; + continue; + } + if (name !== key) continue; + const single = rawValue.match(/^("(?:[^"\\]|\\.)*"|'[^']*')\s*(?:#.*)?$/); + if (!single) throw uninspectableToml(); + return tomlString(single[1]); + } + return undefined; +} + +function tomlString(value: string): string { + if (value.startsWith("'")) return value.slice(1, -1); + try { return JSON.parse(value) as string; } catch { throw uninspectableToml(); } +} + +function uninspectableToml(): BillingError { + return new BillingError("cannot inspect native billing configuration; use a plain quoted root setting"); +} + +function rejectClaudePaidSettings(options: BuildOptions, env: Env): void { + const configDir = claudeConfigDir(env); + const paths = configDir ? [join(configDir, "settings.json"), join(configDir, "settings.local.json")] : []; + let directory = resolve(options.workDir ?? process.cwd()); + while (true) { + paths.push(join(directory, ".claude", "settings.json"), join(directory, ".claude", "settings.local.json")); + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + for (const path of paths) { + const settings = readJson(path); + const configuredEnv = asRecord(settings.env); + if (nonempty(settings.apiKeyHelper) || claudePaidVariables.some((key) => isPaidSetting(key, configuredEnv[key]))) { + throw new BillingError("subscription billing conflicts with Claude apiKeyHelper or paid backend settings; remove those settings for this invocation"); + } + } +} + +function isPaidSetting(key: string, value: unknown): boolean { + if (!nonempty(value)) return false; + if (key.startsWith("CLAUDE_CODE_USE_")) return !["0", "false"].includes(value.toLowerCase()); + return key !== "HEADLESS_CLAUDE_AUTH"; +} + +function hasBedrockConfiguration(env: Env): boolean { + const region = env.AWS_REGION || env.AWS_DEFAULT_REGION; + const identity = (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY) || env.AWS_BEARER_TOKEN_BEDROCK + || env.AWS_PROFILE || env.AWS_WEB_IDENTITY_TOKEN_FILE || env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI + || env.AWS_CONTAINER_CREDENTIALS_FULL_URI || env.AWS_SHARED_CREDENTIALS_FILE; + return Boolean(region && (identity || env.CLAUDE_CODE_USE_BEDROCK === "1")); +} + +function isApiOnlyCodexModel(model: string | undefined): boolean { + return model === "gpt-5.4" || model === "gpt-5.4-2026-03-05"; +} + +function openAiKey(env: Env): string | undefined { + return nonempty(env.CODEX_API_KEY) ? env.CODEX_API_KEY : nonempty(env.OPENAI_API_KEY) ? env.OPENAI_API_KEY : undefined; +} + +function codexConfigDir(env: Env): string | undefined { + return env.CODEX_HOME || (env.HOME ? join(env.HOME, ".codex") : undefined); +} + +function claudeConfigDir(env: Env): string | undefined { + return env.CLAUDE_CONFIG_DIR || (env.HOME ? join(env.HOME, ".claude") : undefined); +} + +function remove(env: Env, names: string[]): void { + for (const name of names) env[name] = undefined; +} + +function nonempty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function readJson(path: string): Record { + const content = readBoundedFile(path); + if (content === undefined) return {}; + return parseJson(content); +} + +function parseJson(content: string): Record { + try { return asRecord(JSON.parse(content)); } catch { + throw new BillingError("cannot inspect malformed native auth/config metadata; repair it before selecting billing"); + } +} + +function readBoundedFile(path: string): string | undefined { + let descriptor: number | undefined; + try { + descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = fstatSync(descriptor); + if (!stat.isFile() || stat.size > maxAuthBytes) throw new BillingError("native auth/config metadata must be a bounded regular file"); + const buffer = Buffer.alloc(maxAuthBytes + 1); + const size = readSync(descriptor, buffer, 0, buffer.length, 0); + if (size > maxAuthBytes) throw new BillingError("native auth/config metadata exceeds the size limit"); + return buffer.subarray(0, size).toString("utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new BillingError("cannot safely inspect native auth/config metadata; repair its file or permissions before selecting billing"); + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + +function nativeStoredAuth(agent: "claude" | "codex", env: Env): StoredAuth { + const prepared = { ...env }; + remove(prepared, [...claudePaidVariables, "CLAUDE_CODE_OAUTH_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "CODEX_ACCESS_TOKEN"]); + const command = agent === "codex" ? "codex" : env.CLAUDE_CODE_BIN || env.CLAUDE_BIN || "claude"; + const args = agent === "codex" ? ["login", "status"] : ["auth", "status", "--json"]; + const result = spawnSync(command, args, { env: prepared, encoding: "utf8", timeout: 2000, maxBuffer: 16 * 1024, windowsHide: true }); + const action = agent === "codex" ? "codex login status" : "claude auth status --json"; + if (!result.error && result.signal === null) { + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + const auth = agent === "codex" ? parseCodexStatus(output, result.status) : parseClaudeStatus(output, result.status); + if (auth) return auth; + } + throw new BillingError(`cannot determine stored ${agent} authentication; check ${action} before selecting billing`); +} + +function parseCodexStatus(output: string, status: number | null): StoredAuth | undefined { + if (status === 0 && /(?:^|\n)Logged in using ChatGPT(?:\r?\n|$)/.test(output)) return "subscription"; + if (status === 0 && /(?:^|\n)Logged in using (?:an? )?API key\b/.test(output)) return "api"; + if (status === 1 && /(?:^|\n)Not logged in(?:\r?\n|$)/.test(output)) return "missing"; + return undefined; +} + +function parseClaudeStatus(output: string, status: number | null): StoredAuth | undefined { + let auth: Record; + try { auth = asRecord(JSON.parse(output)); } catch { return undefined; } + if (status === 1 && auth.loggedIn === false) return "missing"; + if (status !== 0 || auth.loggedIn !== true) return undefined; + if (auth.apiKeySource === "/login managed key") return "api"; + if (auth.authMethod === "claude.ai" || auth.authMethod === "oauth_token") return "subscription"; + if (["api_key", "api_key_helper", "third_party"].includes(String(auth.authMethod))) return "api"; + return undefined; +} diff --git a/src/config.ts b/src/config.ts index 346064c..489f9b1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,9 +2,10 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { isCoordinationMode, isRole, type CoordinationMode, type Role } from "./roles.js"; -import type { AgentName, AllowMode, Env, ReasoningEffort } from "./types.js"; +import type { AgentName, AllowMode, BillingMode, Env, ReasoningEffort } from "./types.js"; export interface AgentDefaults { + billing?: BillingMode; model?: string; reasoningEffort?: ReasoningEffort; } @@ -162,6 +163,8 @@ export function parseHeadlessConfig(content: string): HeadlessConfig { defaults.model = parsedValue.value; } else if (key === "reasoning_effort") { defaults.reasoningEffort = parseConfigReasoningEffort(parsedValue.value, index + 1); + } else if (key === "billing") { + defaults.billing = parseConfigBilling(parsedValue.value, index + 1); } else { throw new Error(`unsupported headless agent config key at line ${index + 1}: ${key}`); } @@ -233,6 +236,11 @@ function parseConfigReasoningEffort(value: string, lineNumber: number): Reasonin throw new Error(`unsupported headless config reasoning_effort at line ${lineNumber}: ${value}`); } +function parseConfigBilling(value: string, lineNumber: number): BillingMode { + if (value === "auto" || value === "subscription" || value === "api") return value; + throw new Error(`headless config billing must be auto, subscription, or api at line ${lineNumber}`); +} + function parseConfigAllow(value: string, lineNumber: number): AllowMode { if (value === "read-only" || value === "yolo") { return value; diff --git a/src/types.ts b/src/types.ts index 41300ba..82c02fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,8 @@ export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; export type Env = Record; +export type BillingMode = "auto" | "subscription" | "api"; + export interface BuildOptions { prompt: string; promptFile?: string; diff --git a/tests/billing.test.ts b/tests/billing.test.ts new file mode 100644 index 0000000..4ea5715 --- /dev/null +++ b/tests/billing.test.ts @@ -0,0 +1,392 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { BillingError, prepareBillingAttempt, prepareBillingPreview, resolveBillingMode } from "../src/billing.js"; +import { parseHeadlessConfig } from "../src/config.js"; +import type { Env } from "../src/types.js"; + +const prompt = { prompt: "inspect" }; + +function credentialHome(files: Record): { env: Env; cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), "headless-billing-")); + for (const [path, value] of Object.entries(files)) { + const target = join(home, path); + mkdirSync(join(target, ".."), { recursive: true }); + writeFileSync(target, JSON.stringify(value)); + } + return { env: { HOME: home }, cleanup: () => rmSync(home, { recursive: true, force: true }) }; +} + +test("billing defaults to auto and CLI overrides environment and per-agent config", () => { + const config = parseHeadlessConfig('[agents.codex]\nbilling = "subscription"\n'); + assert.equal(resolveBillingMode("codex", undefined, {}, config), "subscription"); + assert.equal(resolveBillingMode("claude", undefined, {}, config), "auto"); + assert.equal(resolveBillingMode("codex", undefined, { HEADLESS_BILLING: "api" }, config), "api"); + assert.equal(resolveBillingMode("codex", "auto", { HEADLESS_BILLING: "api" }, config), "auto"); +}); + +test("invalid billing config and environment values fail without echoing their value", () => { + assert.throws(() => parseHeadlessConfig('[agents.codex]\nbilling = "secret-value"'), /billing.*auto.*subscription.*api/); + assert.throws(() => resolveBillingMode("codex", undefined, { HEADLESS_BILLING: "secret-value" }, parseHeadlessConfig("")), + (error: unknown) => error instanceof BillingError && error.exitCode === 78 && !error.message.includes("secret-value")); +}); + +test("automatic routing leaves unsupported harnesses and credential-free invocations native", () => { + assert.deepEqual(prepareBillingAttempt("pi", prompt, { OPENAI_API_KEY: "example" }, "auto"), + { route: "native", env: { OPENAI_API_KEY: "example" } }); + assert.equal(prepareBillingAttempt("codex", prompt, {}, "auto").route, "native"); + assert.equal(prepareBillingAttempt("claude", prompt, {}, "auto").route, "native"); + assert.throws(() => prepareBillingAttempt("pi", prompt, {}, "subscription"), BillingError); +}); + +test("Codex automatic billing selects subscription and masks API environment credentials", () => { + const home = credentialHome({ ".codex/auth.json": { auth_mode: "chatgpt", tokens: { access_token: "oauth" } } }); + try { + const env = { ...home.env, CODEX_API_KEY: "api", OPENAI_API_KEY: "api", OPENAI_BASE_URL: "https://proxy.test" }; + const result = prepareBillingAttempt("codex", prompt, env, "auto"); + assert.equal(result.route, "subscription"); + assert.equal(result.env.CODEX_API_KEY, undefined); + assert.equal(result.env.OPENAI_API_KEY, undefined); + assert.equal(result.env.OPENAI_BASE_URL, undefined); + assert.equal(env.CODEX_API_KEY, "api"); + } finally { home.cleanup(); } +}); + +test("Codex GPT-5.4 aliases select same-model API auth even when subscription exists", () => { + const home = credentialHome({ ".codex/auth.json": { auth_mode: "chatgpt", tokens: { access_token: "oauth" } } }); + try { + for (const model of ["gpt-5.4", "gpt-5.4-2026-03-05"]) { + const options = { ...prompt, model }; + const result = prepareBillingAttempt("codex", options, { ...home.env, OPENAI_API_KEY: "api" }, "auto"); + assert.equal(result.route, "openai-api"); + assert.equal(result.env.CODEX_API_KEY, "api"); + assert.equal(options.model, model); + } + assert.equal(prepareBillingAttempt("codex", { ...prompt, model: "gpt-5.4-mini" }, home.env, "auto").route, "subscription"); + assert.throws(() => prepareBillingAttempt("codex", { ...prompt, model: "gpt-5.4" }, home.env, "auto"), /CODEX_API_KEY.*OPENAI_API_KEY/); + } finally { home.cleanup(); } +}); + +test("Codex API credential precedence is process-local and removes subscription token override", () => { + const result = prepareBillingAttempt("codex", prompt, + { CODEX_API_KEY: "preferred", OPENAI_API_KEY: "other", CODEX_ACCESS_TOKEN: "subscription" }, "api"); + assert.equal(result.env.CODEX_API_KEY, "preferred"); + assert.equal(result.env.CODEX_ACCESS_TOKEN, undefined); +}); + +test("Codex respects CODEX_HOME and refuses stored API login in subscription mode without modifying it", () => { + const home = credentialHome({ "custom/auth.json": { auth_mode: "apikey", OPENAI_API_KEY: "api" } }); + try { + const env = { ...home.env, CODEX_HOME: join(home.env.HOME!, "custom") }; + const before = readFileSync(join(env.CODEX_HOME, "auth.json"), "utf8"); + assert.throws(() => prepareBillingAttempt("codex", prompt, env, "subscription"), /stored API/); + assert.equal(readFileSync(join(env.CODEX_HOME, "auth.json"), "utf8"), before); + } finally { home.cleanup(); } +}); + +test("Codex auto preserves explicit profiles while subscription rejects unknown provider routing", () => { + const env = { CODEX_API_KEY: "api" }; + assert.deepEqual(prepareBillingAttempt("codex", { ...prompt, profile: "private", model: "gpt-5.4" }, env, "auto"), + { route: "native", env }); + assert.throws(() => prepareBillingAttempt("codex", { ...prompt, profile: "private" }, env, "subscription"), /profile/); +}); + +test("Codex API configuration errors identify missing key without leaking credentials", () => { + assert.throws(() => prepareBillingAttempt("codex", prompt, {}, "api"), + (error: unknown) => error instanceof BillingError && error.exitCode === 78 && /CODEX_API_KEY/.test(error.message)); +}); + +test("Claude auto prefers OAuth and removes conflicting API and backend variables", () => { + const env = { + CLAUDE_CODE_OAUTH_TOKEN: "oauth", ANTHROPIC_API_KEY: "api", ANTHROPIC_AUTH_TOKEN: "token", + ANTHROPIC_BASE_URL: "https://proxy.test", CLAUDE_CODE_USE_BEDROCK: "1", + CLAUDE_CODE_USE_VERTEX: "1", CLAUDE_CODE_USE_FOUNDRY: "1", HEADLESS_CLAUDE_AUTH: "api", + }; + const result = prepareBillingAttempt("claude", prompt, env, "auto"); + assert.equal(result.route, "subscription"); + assert.equal(result.env.CLAUDE_CODE_OAUTH_TOKEN, "oauth"); + for (const name of Object.keys(env).filter((name) => name !== "CLAUDE_CODE_OAUTH_TOKEN")) { + assert.equal(result.env[name], undefined, name); + } +}); + +test("Claude detects actual stored OAuth and does not treat ordinary settings as credentials", () => { + const home = credentialHome({ ".claude.json": {}, ".claude/.credentials.json": { claudeAiOauth: { accessToken: "oauth" } } }); + try { + assert.equal(prepareBillingAttempt("claude", prompt, home.env, "auto").route, "subscription"); + assert.equal(prepareBillingAttempt("claude", prompt, { ...home.env, CLAUDE_CONFIG_DIR: join(home.env.HOME!, "absent") }, "auto").route, "native"); + } finally { home.cleanup(); } +}); + +test("Claude API route uses Bedrock credentials and masks OAuth and direct API auth", () => { + const home = credentialHome({ ".claude/.credentials.json": { claudeAiOauth: { accessToken: "oauth" } } }); + try { + const env = { ...home.env, CLAUDE_CODE_OAUTH_TOKEN: "oauth", ANTHROPIC_API_KEY: "direct", + AWS_ACCESS_KEY_ID: "aws", AWS_SECRET_ACCESS_KEY: "secret", AWS_REGION: "us-east-1", + CLAUDE_CODE_USE_BEDROCK: "0", CLAUDE_CODE_USE_VERTEX: "1" }; + const result = prepareBillingAttempt("claude", prompt, env, "api"); + assert.equal(result.route, "bedrock"); + assert.equal(result.env.CLAUDE_CODE_USE_BEDROCK, "1"); + assert.equal(result.env.CLAUDE_CODE_OAUTH_TOKEN, undefined); + assert.equal(result.env.ANTHROPIC_API_KEY, undefined); + assert.equal(result.env.CLAUDE_CODE_USE_VERTEX, undefined); + assert.equal(result.env.AWS_SECRET_ACCESS_KEY, "secret"); + } finally { home.cleanup(); } +}); + +test("Claude automatic billing uses Bedrock when no OAuth is available", () => { + assert.equal(prepareBillingAttempt("claude", prompt, + { AWS_BEARER_TOKEN_BEDROCK: "test", AWS_REGION: "us-east-1" }, "auto").route, "bedrock"); +}); + +test("Claude missing Bedrock backup is actionable even when direct Anthropic key exists", () => { + assert.throws(() => prepareBillingAttempt("claude", prompt, { ANTHROPIC_API_KEY: "secret" }, "api"), + (error: unknown) => error instanceof BillingError && /Bedrock/.test(error.message) && !error.message.includes("secret")); +}); + +test("route override cannot cross billing policy or use another harness backend", () => { + assert.throws(() => prepareBillingAttempt("codex", prompt, { OPENAI_API_KEY: "api" }, "subscription", "openai-api"), /subscription/); + assert.throws(() => prepareBillingAttempt("claude", prompt, {}, "auto", "openai-api"), BillingError); +}); + +test("unsafe credential files fail closed instead of selecting a paid fallback", () => { + const home = credentialHome({ "outside.json": { tokens: { access_token: "oauth" } } }); + try { + mkdirSync(join(home.env.HOME!, ".codex")); + symlinkSync(join(home.env.HOME!, "outside.json"), join(home.env.HOME!, ".codex/auth.json")); + assert.throws(() => prepareBillingAttempt("codex", prompt, { ...home.env, OPENAI_API_KEY: "api" }, "auto"), BillingError); + assert.throws(() => prepareBillingAttempt("codex", prompt, home.env, "subscription"), BillingError); + } finally { home.cleanup(); } +}); + +test("billing preview shows direct API routing without requiring or fabricating a key", () => { + const result = prepareBillingPreview("codex", { ...prompt, model: "gpt-5.4" }, {}, "auto"); + assert.equal(result.route, "openai-api"); + assert.equal(result.env.CODEX_API_KEY, undefined); + assert.equal(prepareBillingPreview("claude", prompt, {}, "api").route, "bedrock"); +}); + +test("auth masks remain explicit for remote credential and secret overlays", () => { + const result = prepareBillingAttempt("claude", prompt, { CLAUDE_CODE_OAUTH_TOKEN: "oauth" }, "subscription"); + assert.ok(Object.hasOwn(result.env, "ANTHROPIC_API_KEY")); + assert.ok(Object.hasOwn(result.env, "CLAUDE_CODE_USE_MANTLE")); + assert.ok(Object.hasOwn(result.env, "CLAUDE_CODE_USE_ANTHROPIC_AWS")); +}); + +test("Codex preserves base custom provider routing", () => { + const home = credentialHome({}); + try { + mkdirSync(join(home.env.HOME!, ".codex")); + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), 'model_provider = "private"\n'); + assert.equal(prepareBillingAttempt("codex", { ...prompt, model: "gpt-5.4" }, home.env, "auto").route, "native"); + assert.throws(() => prepareBillingAttempt("codex", prompt, home.env, "auto", "openai-api"), /profile\/provider/); + } finally { home.cleanup(); } +}); + +test("auth policy rejects incompatible native login restrictions without deleting auth", () => { + const home = credentialHome({ ".codex/auth.json": { auth_mode: "chatgpt", tokens: { access_token: "oauth" } } }); + try { + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), 'forced_login_method = "chatgpt"\n'); + assert.throws(() => prepareBillingAttempt("codex", prompt, { ...home.env, OPENAI_API_KEY: "api" }, "api"), /forced_login_method/); + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), 'forced_login_method = "api"\n'); + assert.throws(() => prepareBillingAttempt("codex", prompt, home.env, "subscription"), /forced_login_method/); + assert.ok(readFileSync(join(home.env.HOME!, ".codex/auth.json"), "utf8").includes("oauth")); + } finally { home.cleanup(); } +}); + +test("Claude paid settings cannot bypass subscription mode", () => { + const home = credentialHome({ ".claude/settings.json": { apiKeyHelper: "get-key" } }); + try { + assert.throws(() => prepareBillingAttempt("claude", prompt, { ...home.env, CLAUDE_CODE_OAUTH_TOKEN: "oauth" }, "subscription"), /apiKeyHelper/); + } finally { home.cleanup(); } +}); + +test("Claude disabled backend settings are compatible with subscription mode", () => { + const home = credentialHome({ ".claude/settings.json": { env: { CLAUDE_CODE_USE_BEDROCK: "0" } } }); + try { + assert.equal(prepareBillingAttempt("claude", prompt, home.env, "subscription").route, "subscription"); + } finally { home.cleanup(); } +}); + +test("Claude workspace settings cannot override a subscription route", () => { + const home = credentialHome({ "workspace/.claude/settings.local.json": { env: { ANTHROPIC_API_KEY: "test" } } }); + try { + assert.throws(() => prepareBillingAttempt("claude", { ...prompt, workDir: join(home.env.HOME!, "workspace", "nested") }, home.env, "subscription"), /paid backend/); + } finally { home.cleanup(); } +}); + +test("malformed or oversized credential metadata fails closed", () => { + const home = credentialHome({ ".codex/auth.json": {} }); + try { + const path = join(home.env.HOME!, ".codex/auth.json"); + writeFileSync(path, "not json"); + assert.throws(() => prepareBillingAttempt("codex", prompt, home.env, "auto"), BillingError); + writeFileSync(path, "x".repeat(1024 * 1024 + 1)); + assert.throws(() => prepareBillingAttempt("codex", prompt, home.env, "subscription"), BillingError); + } finally { home.cleanup(); } +}); + +test("API mode rejects a subscription route override", () => { + assert.throws(() => prepareBillingAttempt("codex", prompt, {}, "api", "subscription"), /API billing/); +}); + +test("other agents ignore inherited billing policies", () => { + const config = parseHeadlessConfig('[agents.pi]\nbilling = "subscription"'); + assert.equal(resolveBillingMode("pi", undefined, { HEADLESS_BILLING: "api" }, config), "auto"); + assert.equal(resolveBillingMode("opencode", undefined, { HEADLESS_BILLING: "invalid" }, config), "auto"); + assert.equal(resolveBillingMode("pi", "subscription", {}, config), "subscription"); +}); + +function fakeNativeStatus(home: string, binary: string, output: string, status = 0): Env { + const binDir = join(home, "bin"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(join(binDir, binary), `#!${process.execPath}\n` + + `process.stderr.write(${JSON.stringify(output)}); process.exitCode = ${status};\n`, { mode: 0o755 }); + return { HOME: home, PATH: binDir }; +} + +test("Codex auto recognizes keyring OAuth ahead of an available API key", () => { + const home = credentialHome({}); + try { + mkdirSync(join(home.env.HOME!, ".codex")); + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), 'cli_auth_credentials_store = "keyring"\n'); + const env = fakeNativeStatus(home.env.HOME!, "codex", "Logged in using ChatGPT\n"); + const result = prepareBillingAttempt("codex", prompt, { ...env, OPENAI_API_KEY: "api" }, "auto"); + assert.equal(result.route, "subscription"); + assert.equal(result.env.OPENAI_API_KEY, undefined); + } finally { home.cleanup(); } +}); + +test("Codex subscription rejects keyring API authentication without exposing native output", () => { + const home = credentialHome({}); + try { + mkdirSync(join(home.env.HOME!, ".codex")); + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), 'cli_auth_credentials_store = "auto"\n'); + const env = fakeNativeStatus(home.env.HOME!, "codex", "Logged in using an API key - secret-key\n"); + assert.throws(() => prepareBillingAttempt("codex", prompt, env, "subscription"), + (error: unknown) => error instanceof BillingError && /stored API/.test(error.message) && !error.message.includes("secret-key")); + } finally { home.cleanup(); } +}); + +test("ambiguous native credential-store output fails closed before a paid attempt", () => { + const home = credentialHome({}); + try { + mkdirSync(join(home.env.HOME!, ".codex")); + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), 'cli_auth_credentials_store = "keyring"\n'); + const env = fakeNativeStatus(home.env.HOME!, "codex", "unknown native state"); + assert.throws(() => prepareBillingAttempt("codex", prompt, { ...env, OPENAI_API_KEY: "api" }, "auto"), /login status/); + } finally { home.cleanup(); } +}); + +test("Claude checks current-directory settings when workDir is omitted", () => { + const home = credentialHome({ ".claude/settings.json": { apiKeyHelper: "get-key" } }); + const previous = process.cwd(); + try { + process.chdir(home.env.HOME!); + assert.throws(() => prepareBillingAttempt("claude", prompt, { CLAUDE_CODE_OAUTH_TOKEN: "oauth" }, "subscription"), /apiKeyHelper/); + } finally { process.chdir(previous); home.cleanup(); } +}); + +test("Claude auto checks macOS keychain authentication before selecting Bedrock", () => { + const home = credentialHome({}); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + try { + Object.defineProperty(process, "platform", { value: "darwin" }); + const env = fakeNativeStatus(home.env.HOME!, "claude", JSON.stringify({ loggedIn: true, authMethod: "claude.ai", apiProvider: "firstParty" })); + const result = prepareBillingAttempt("claude", prompt, + { ...env, AWS_BEARER_TOKEN_BEDROCK: "test", AWS_REGION: "us-east-1" }, "auto"); + assert.equal(result.route, "subscription"); + } finally { Object.defineProperty(process, "platform", platform); home.cleanup(); } +}); + +test("dormant Codex profile settings do not change root billing or login policy", () => { + const home = credentialHome({ ".codex/auth.json": { auth_mode: "chatgpt", tokens: { access_token: "oauth" } } }); + try { + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), + '[profiles.private]\nmodel_provider = "private"\nforced_login_method = "chatgpt"\ncli_auth_credentials_store = "keyring"\n'); + const result = prepareBillingAttempt("codex", { ...prompt, model: "gpt-5.4" }, { ...home.env, OPENAI_API_KEY: "api" }, "auto"); + assert.equal(result.route, "openai-api"); + assert.equal(prepareBillingAttempt("codex", prompt, home.env, "subscription").route, "subscription"); + } finally { home.cleanup(); } +}); + +test("quoted Codex provider keys are respected but instruction text is not configuration", () => { + const home = credentialHome({ ".codex/auth.json": {} }); + try { + const path = join(home.env.HOME!, ".codex/config.toml"); + writeFileSync(path, '"model_provider" = "private"\n'); + assert.equal(prepareBillingAttempt("codex", { ...prompt, model: "gpt-5.4" }, home.env, "auto").route, "native"); + writeFileSync(path, 'developer_instructions = """\nmodel_provider = "private"\n"""\n'); + assert.equal(prepareBillingAttempt("codex", { ...prompt, model: "gpt-5.4" }, { ...home.env, OPENAI_API_KEY: "api" }, "auto").route, "openai-api"); + } finally { home.cleanup(); } +}); + +test("subscription masks the native Anthropic Google Cloud backend selector", () => { + const result = prepareBillingAttempt("claude", prompt, + { CLAUDE_CODE_OAUTH_TOKEN: "oauth", CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: "1" }, "subscription"); + assert.equal(result.env.CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD, undefined); +}); + +for (const [relativePath, customDir] of [ + [".claude.json", undefined], + ["custom/.claude.json", "custom"], + [".claude/.config.json", undefined], +] as const) { + test(`Claude subscription rejects managed API key in ${relativePath}`, () => { + const home = credentialHome({ [relativePath]: { primaryApiKey: "must-not-leak" } }); + try { + const env = customDir ? { ...home.env, CLAUDE_CONFIG_DIR: join(home.env.HOME!, customDir) } : home.env; + assert.throws(() => prepareBillingAttempt("claude", prompt, env, "subscription"), + (error: unknown) => error instanceof BillingError && /stored Claude API/.test(error.message) && !error.message.includes("must-not-leak")); + assert.equal(readFileSync(join(home.env.HOME!, relativePath), "utf8"), JSON.stringify({ primaryApiKey: "must-not-leak" })); + } finally { home.cleanup(); } + }); +} + +test("explicit Claude subscription token takes precedence over a saved managed API key", () => { + const home = credentialHome({ ".claude.json": { primaryApiKey: "saved-api" } }); + try { + const result = prepareBillingAttempt("claude", prompt, + { ...home.env, CLAUDE_CODE_OAUTH_TOKEN: "subscription" }, "subscription"); + assert.equal(result.route, "subscription"); + assert.equal(result.env.CLAUDE_CODE_OAUTH_TOKEN, "subscription"); + } finally { home.cleanup(); } +}); + +test("Claude global config lookup respects custom directory and legacy file precedence", () => { + const home = credentialHome({ ".claude.json": { primaryApiKey: "unused-api" }, "custom/.config.json": {} }); + try { + const env = { ...home.env, CLAUDE_CONFIG_DIR: join(home.env.HOME!, "custom") }; + assert.equal(prepareBillingAttempt("claude", prompt, env, "subscription").route, "subscription"); + } finally { home.cleanup(); } +}); + +for (const key of ["model_provider", "forced_login_method", "cli_auth_credentials_store"]) { + for (const quote of ['"""', "'''"]) { + test(`unsupported multiline ${key} fails closed (${quote})`, () => { + const home = credentialHome({ ".codex/auth.json": { auth_mode: "chatgpt", tokens: { access_token: "oauth" } } }); + try { + writeFileSync(join(home.env.HOME!, ".codex/config.toml"), `${key} = ${quote}private-value${quote}\n`); + assert.throws(() => prepareBillingAttempt("codex", prompt, home.env, "subscription"), + (error: unknown) => error instanceof BillingError && !error.message.includes("private-value")); + } finally { home.cleanup(); } + }); + } +} + +test("Codex root key decoding and comment text cannot hide a provider override", () => { + const home = credentialHome({ ".codex/auth.json": {} }); + try { + const path = join(home.env.HOME!, ".codex/config.toml"); + for (const config of [ + '"\\u006dodel_provider" = "private"\n', + 'developer_instructions = "hello" # example = """\nmodel_provider = "private"\n', + ]) { + writeFileSync(path, config); + assert.throws(() => prepareBillingAttempt("codex", prompt, home.env, "subscription"), /profile\/provider/); + } + } finally { home.cleanup(); } +}); From 79bbe66f0fc48557eed9b878dd79daf0ef9487e4 Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Wed, 9 Sep 2026 10:56:38 +0000 Subject: [PATCH 2/7] feat: resume native sessions with paid billing fallback --- README.md | 49 +++++++ src/billing-events.ts | 154 ++++++++++++++++++++++ src/billing-run.ts | 106 +++++++++++++++ src/billing-usage.ts | 50 +++++++ src/cli.ts | 248 ++++++++++++++++++++--------------- src/env.ts | 5 + src/modal.ts | 92 +++++++++---- tests/billing-cli.test.ts | 107 +++++++++++++++ tests/billing-events.test.ts | 139 ++++++++++++++++++++ tests/billing-run.test.ts | 79 +++++++++++ tests/billing-usage.test.ts | 53 ++++++++ tests/headless.test.ts | 6 +- tests/modal.test.ts | 45 +++++++ 13 files changed, 999 insertions(+), 134 deletions(-) create mode 100644 src/billing-events.ts create mode 100644 src/billing-run.ts create mode 100644 src/billing-usage.ts create mode 100644 tests/billing-cli.test.ts create mode 100644 tests/billing-events.test.ts create mode 100644 tests/billing-run.test.ts create mode 100644 tests/billing-usage.test.ts diff --git a/README.md b/README.md index e6ed036..831b77a 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,55 @@ headless --check When no agent is specified, Headless selects the first installed agent in this order: `codex`, `claude`, `pi`, `opencode`, `gemini`, `antigravity`, `cursor`. ACP-compatible agents are explicit-only: use `headless acp --acp-agent ...` or `headless acp --acp-command ...`. +## Billing and subscription fallback + +Noninteractive Claude and Codex invocations default to `--billing auto`: use an +available subscription, then switch once to paid authentication after a native +subscription limit or a subscription-specific Codex model rejection. GPT-5.4 +and `gpt-5.4-2026-03-05` use OpenAI API billing directly. Other harnesses keep +their native authentication. + +```bash +headless codex --prompt "Run the experiment" # subscription first +headless codex --model gpt-5.4 --prompt "Run the experiment" # OpenAI API +headless claude --billing subscription --prompt "Review results" # never switch to paid +headless claude --billing api --prompt "Continue the experiment" # Amazon Bedrock +``` + +Codex API billing needs `CODEX_API_KEY` or `OPENAI_API_KEY`. Claude's paid route +uses Amazon Bedrock, with an AWS region and credentials available to the native +CLI; it retains the requested model and native Bedrock model mapping. An +Anthropic API key alone does not configure this backup. For Docker/Modal, supply +credentials accessible inside the container (for example AWS access key, secret, +session token, and region); a host AWS profile or credential-file path alone is +not portable. + +Policy precedence: `--billing` > `HEADLESS_BILLING` > `billing` in +`[agents.claude]`/`[agents.codex]` > `auto`. Choose `subscription` to disallow paid +fallback. Explicit custom Codex profiles/providers keep native auth under `auto` +and cannot be combined with explicit subscription/API routing. With no detected +subscription or configured backup, `auto` preserves native authentication. + +Fallback preserves the workspace, native session, permissions, reasoning effort, +and original deadline. Completed work resumes with a continuation prompt; the +original task is not replayed after partial execution. If safe resumption or +backup credentials are unavailable, Headless exits with status 78. Generic +errors, tool output, interruptions, and timeouts do not trigger fallback. Paid +provider limits still apply; Headless does not impose a local dollar cap. + +The policy applies locally, in Docker, and in Modal. Docker keeps an anonymous +private home across both attempts, removes it after native success, and reports +its retained path on failure for recovery. `--session` homes remain durable. +Interactive/tmux invocations use native authentication; explicit `--billing` +with `--tmux` is rejected. + +`--usage` includes `billing.attempts` with route, transition reason, and each +attempt's usage/cost provenance. The top-level token counts aggregate attempts; +mixed or missing cost bases leave aggregate cost unavailable instead of mixing +estimates with reported charges. Subscription cost estimates are API list-price +comparisons, not subscription charges. Auth changes affect child environments +only; Headless never replaces shared login files. + ## Native TUI Completion Use `--tmux --wait --delete` when you want Headless to launch the agent in its native TUI, wait for the final native transcript message, print that message, and then terminate the tmux session after the prompt completes. diff --git a/src/billing-events.ts b/src/billing-events.ts new file mode 100644 index 0000000..b6e223b --- /dev/null +++ b/src/billing-events.ts @@ -0,0 +1,154 @@ +import { StringDecoder } from "node:string_decoder"; +import type { AgentName } from "./types.js"; + +export type BillingFailureReason = "subscription-quota" | "subscription-model-unsupported"; +export const MAX_BILLING_EVENT_BYTES = 1024 * 1024; +type RecordValue = Record; + +function object(value: unknown): RecordValue { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as RecordValue : {}; +} + +function sessionId(value: unknown): string | undefined { + return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : undefined; +} + +// Display strings and reset-time format from the installed Codex 0.153.4 binary. +const codexQuotaPrefixes = [ + "You've hit your usage limit.", + "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus),", + "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits", + "You've hit your usage limit. To get more access now, send a request to your admin", + "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits", +]; +const codexResetSuffix = /^(?: Try again| or try again) (?:later|at (?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [1-3]?\d, \d{4} )?(?:[1-9]|1[0-2]):[0-5]\d [AP]M)\.$/; + +function codexQuotaMessage(value: string): boolean { + if (value.length > 512) return false; + return codexQuotaPrefixes.some((prefix) => value.startsWith(prefix) && + (value === prefix || codexResetSuffix.test(value.slice(prefix.length)))); +} + +function codexFailure(value: unknown): BillingFailureReason | undefined { + let error = object(value); + if (typeof value === "string") { + if (codexQuotaMessage(value)) return "subscription-quota"; + try { error = object(JSON.parse(value)); } catch { return undefined; } + } + const detail = object(error.error); + if (error.type === "usage_limit_reached" || detail.type === "usage_limit_reached" || + error.code === "usage_limit_reached" || detail.code === "usage_limit_reached") { + return "subscription-quota"; + } + if (error.status === 400 && detail.type === "invalid_request_error" && + typeof detail.message === "string" && + /^The '[A-Za-z0-9._-]+' model is not supported when using Codex with a ChatGPT account\.$/.test(detail.message)) { + return "subscription-model-unsupported"; + } + return undefined; +} + +/** Observes native envelope fields only; tool output and assistant prose are never errors. */ +export class BillingEventCollector { + failureReason: BillingFailureReason | undefined; + nativeSessionId: string | undefined; + hasWork = false; + failed = false; + private pending = ""; + private pendingBytes = 0; + private skipping = false; + private readonly decoder = new StringDecoder("utf8"); + + constructor(private readonly agent: AgentName) {} + + write(chunk: string | Buffer): void { + const text = typeof chunk === "string" ? chunk : this.decoder.write(chunk); + let start = 0; + while (start < text.length) { + const newline = text.indexOf("\n", start); + const end = newline < 0 ? text.length : newline; + const segment = text.slice(start, end); + if (!this.skipping) { + this.pendingBytes += Buffer.byteLength(segment); + if (this.pendingBytes > MAX_BILLING_EVENT_BYTES) { + this.pending = ""; + this.skipping = true; + // Lost work evidence must prevent a blind replay without a native session. + this.hasWork = true; + } else { + this.pending += segment; + } + } + if (newline < 0) break; + if (!this.skipping) this.consume(this.pending); + this.pending = ""; + this.pendingBytes = 0; + this.skipping = false; + start = newline + 1; + } + } + + end(): void { + this.write(this.decoder.end()); + if (!this.skipping && this.pending) this.consume(this.pending); + this.pending = ""; + this.pendingBytes = 0; + } + + private consume(line: string): void { + let event: RecordValue; + try { event = object(JSON.parse(line)); } catch { return; } + if (this.agent === "codex") this.consumeCodex(event); + if (this.agent === "claude") this.consumeClaude(event); + } + + private consumeCodex(event: RecordValue): void { + if (event.type === "turn.completed") { + this.failed = false; + this.failureReason = undefined; + } + if (event.type === "thread.started") { + this.nativeSessionId ??= sessionId(event.thread_id); + } + if (event.type === "item.started" || event.type === "item.completed" || event.type === "item.updated") { + const item = object(event.item); + if (typeof item.type === "string" && item.type !== "error") this.hasWork = true; + } + if (event.type === "error" || event.type === "turn.failed") { + this.failureReason ??= codexFailure(event.error) ?? codexFailure(event.message) ?? + codexFailure(object(event.error).message) ?? codexFailure(event); + // Codex also emits recoverable error notices; the native terminal is authoritative. + if (event.type === "turn.failed" || this.failureReason) this.failed = true; + } + } + + private consumeClaude(event: RecordValue): void { + if (event.type === "system" && event.subtype === "init") { + this.nativeSessionId ??= sessionId(event.session_id); + } + if (event.type === "rate_limit_event") { + const limit = object(event.rate_limit_info); + if (limit.status === "rejected" && + ["five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"].includes(String(limit.rateLimitType))) { + this.failureReason = "subscription-quota"; + this.failed = true; + } + } + if (event.type === "assistant") { + const message = object(event.message); + if (message.model !== "" && Array.isArray(message.content) && message.content.length) { + this.hasWork = true; + } + } + if (event.type === "result") { + this.nativeSessionId ??= sessionId(event.session_id); + if (event.is_error === true || event.terminal_reason === "api_error" || + (typeof event.subtype === "string" && event.subtype.startsWith("error_"))) this.failed = true; + else if (event.is_error === false && event.subtype === "success") { + this.failed = false; + this.failureReason = undefined; + } + } + } +} diff --git a/src/billing-run.ts b/src/billing-run.ts new file mode 100644 index 0000000..980bdd4 --- /dev/null +++ b/src/billing-run.ts @@ -0,0 +1,106 @@ +import { prepareBillingAttempt, type BillingRoute } from "./billing.js"; +import { BillingEventCollector, type BillingFailureReason } from "./billing-events.js"; +import { aggregateBillingUsage, type BillingUsageReport } from "./billing-usage.js"; +import type { AgentName, BillingMode, BuildOptions, Env } from "./types.js"; +import type { UsageSummary } from "./usage.js"; + +export interface BillingExecutionResult { + code: number; + stdout: string; + usageTrace?: string; + finalMessageTrace?: string; + stdoutReceived?: boolean; + stdoutEndsWithNewline?: boolean; +} + +export interface BillingExecutionAttempt { + route: BillingRoute; + env: Env; + options: BuildOptions; + timeoutSeconds?: number; + observe: (chunk: string) => void; +} + +export interface BillingRunOptions { + agent: AgentName; + mode: BillingMode; + env: Env; + options: BuildOptions; + timeoutSeconds?: number; + execute: (attempt: BillingExecutionAttempt) => Promise; + reportUsage?: (trace: string, route: BillingRoute) => Promise; + onTransition?: (event: { type: "billing_transition"; from: BillingRoute; to: BillingRoute; reason: BillingFailureReason }) => void; + now?: () => number; +} + +export interface BillingRunResult { + result: BillingExecutionResult; + usage?: BillingUsageReport; + nativeSessionId?: string; + error?: string; +} + +const terminated = new Set([124, 130, 137, 143]); +const continuation = "Continue from where you left off. Your previous turn was interrupted by a subscription usage limit. Preserve completed work and do not repeat completed commands."; + +/** One billing transition, sharing the invocation's deadline and native transcript. */ +export async function runWithBilling(input: BillingRunOptions): Promise { + const now = input.now ?? Date.now; + const deadline = input.timeoutSeconds === undefined ? undefined : now() + input.timeoutSeconds * 1000; + let options = input.options; + let attempt = prepareBillingAttempt(input.agent, options, input.env, input.mode); + const reports: Parameters[0] = []; + let reason: BillingFailureReason | undefined; + let nativeSessionId: string | undefined; + let error: string | undefined; + let result: BillingExecutionResult = { code: 124, stdout: "" }; + + for (let index = 0; index < 2; index++) { + const remaining = deadline === undefined ? undefined : (deadline - now()) / 1000; + if (remaining !== undefined && remaining <= 0) { + result = { ...result, code: 124 }; + break; + } + const events = new BillingEventCollector(input.agent); + result = await input.execute({ ...attempt, options, timeoutSeconds: remaining, observe: (chunk) => events.write(chunk) }); + events.end(); + nativeSessionId = events.nativeSessionId ?? nativeSessionId; + if (input.reportUsage) { + const trace = result.usageTrace || result.stdout || result.finalMessageTrace || ""; + reports.push({ route: attempt.route, reason, usage: await input.reportUsage(trace, attempt.route) }); + } + if (terminated.has(result.code)) break; + if (events.failed && result.code === 0) result = { ...result, code: 1 }; + if (!events.failureReason) break; + // Explicit subscription-only policy and exhausted paid routes are terminal. + if (input.mode !== "auto" || attempt.route !== "subscription" || index !== 0) { + result = { ...result, code: 78 }; + error = `billing unavailable: ${events.failureReason}; no further billing fallback`; + break; + } + if (deadline !== undefined && now() >= deadline) { + result = { ...result, code: 124 }; + break; + } + const resumeId = nativeSessionId ?? options.sessionId; + if (events.hasWork && !resumeId) { + result = { ...result, code: 78 }; + error = "billing fallback cannot safely resume partial work: native session ID unavailable"; + break; + } + try { + const next = prepareBillingAttempt(input.agent, options, input.env, "api"); + reason = events.failureReason; + input.onTransition?.({ type: "billing_transition", from: attempt.route, to: next.route, reason }); + attempt = next; + if (resumeId) { + options = { ...options, prompt: continuation, promptFile: undefined, sessionMode: "resume", sessionId: resumeId }; + } + } catch (failure) { + result = { ...result, code: 78 }; + error = failure instanceof Error ? failure.message : "billing fallback unavailable"; + break; + } + } + return { result, nativeSessionId, error, ...(reports.length ? { usage: aggregateBillingUsage(reports) } : {}) }; +} diff --git a/src/billing-usage.ts b/src/billing-usage.ts new file mode 100644 index 0000000..8ba9110 --- /dev/null +++ b/src/billing-usage.ts @@ -0,0 +1,50 @@ +import type { BillingFailureReason } from "./billing-events.js"; +import type { UsageCostBreakdown, UsageSummary } from "./usage.js"; + +export type BillingRoute = "subscription" | "openai-api" | "bedrock" | "native"; +export interface BillingAttempt { + route: BillingRoute; + reason?: BillingFailureReason; + usage: UsageSummary; +} +export interface BillingUsageReport extends UsageSummary { + billing: { attempts: BillingAttempt[] }; +} + +/** Keep incompatible cost valuations separate rather than suggesting an actual API bill. */ +export function aggregateBillingUsage(attempts: BillingAttempt[]): BillingUsageReport { + if (attempts.length < 1 || attempts.length > 2) { + throw new Error("Billing usage requires one or two attempts"); + } + const summaries = attempts.map((attempt) => attempt.usage); + const last = summaries[summaries.length - 1]; + const complete = summaries.every((summary) => summary.usageStatus === "reported"); + const comparable = complete && summaries.every((summary) => + summary.cost !== null && summary.costBasis === last.costBasis && + summary.pricingSource === last.pricingSource && summary.pricingStatus === last.pricingStatus); + const sum = (key: "inputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "outputTokens" | + "reasoningOutputTokens" | "totalTokens") => summaries.reduce((total, summary) => total + summary[key], 0); + let cost: UsageCostBreakdown | null = null; + if (comparable) { + const component = (key: keyof UsageCostBreakdown): number | null => { + const values = summaries.map((summary) => summary.cost![key]); + return values.some((value) => value === null) ? null : + values.reduce((total, value) => total + value!, 0); + }; + cost = { input: component("input"), cacheRead: component("cacheRead"), + cacheWrite: component("cacheWrite"), output: component("output"), total: component("total") }; + } + const { modelBreakdowns: _parts, ...publicLast } = last; + return { + ...publicLast, + inputTokens: sum("inputTokens"), cacheReadTokens: sum("cacheReadTokens"), + cacheWriteTokens: sum("cacheWriteTokens"), outputTokens: sum("outputTokens"), + reasoningOutputTokens: sum("reasoningOutputTokens"), totalTokens: sum("totalTokens"), + usageStatus: complete ? "reported" : "missing", + cost, + costBasis: comparable ? last.costBasis : null, + pricingSource: comparable ? last.pricingSource : null, + pricingStatus: comparable ? last.pricingStatus : "missing", + billing: { attempts: attempts.map((attempt) => ({ ...attempt, usage: { ...attempt.usage } })) }, + }; +} diff --git a/src/cli.ts b/src/cli.ts index 6eddd0c..1aba484 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,10 +4,12 @@ import { closeSync, existsSync, mkdirSync, + mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -15,6 +17,7 @@ import { spawn, spawnSync } from "node:child_process"; import { runAcpClient, runAcpStdioAgent } from "./acp.js"; import { randomUUID } from "node:crypto"; import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { @@ -34,6 +37,8 @@ import { waitTierForAgent, } from "./agents.js"; import { prepareAntigravityUsageCapture, type AntigravityUsageCapture } from "./antigravity-usage.js"; +import { BillingError, prepareBillingAttempt, prepareBillingPreview, resolveBillingMode } from "./billing.js"; +import { runWithBilling, type BillingExecutionAttempt, type BillingRunResult } from "./billing-run.js"; import { checkAgents, checkDocker, commandExists, commandForAgent, renderAgentChecks, renderDockerCheck } from "./check.js"; import { BUILTIN_AGENT_DEFAULTS, @@ -144,9 +149,10 @@ import { type RunStatus, } from "./roles.js"; import { expandTeamSpecs } from "./teams.js"; -import type { AgentName, AllowMode, BuildOptions, BuiltCommand, Env, ReasoningEffort } from "./types.js"; +import type { AgentName, AllowMode, BillingMode, BuildOptions, BuiltCommand, Env, ReasoningEffort } from "./types.js"; interface ParsedArgs { + billing?: BillingMode; capabilities: boolean; attach: boolean; attachSession?: string; @@ -286,6 +292,7 @@ function usage(): string { " --fast Enable Fast mode for Codex or Claude.", " --no-fast Disable ambient Fast mode for Codex or Claude.", " --reasoning-effort, --effort Reasoning effort: low, medium, high, or xhigh.", + " --billing Subscription first (default); Claude API uses Bedrock.", " --allow Permission mode: read-only or yolo.", " --acp-agent With acp, resolve an ACP server from the registry by id or name.", " --acp-command With acp, run a custom ACP server command, e.g. 'atlas alta agent run'.", @@ -455,6 +462,14 @@ function parseArgs(argv: string[]): ParsedArgs { case "--profile": parsed.profile = parseProfile(takeValue(args, arg)); break; + case "--billing": { + const value = takeValue(args, arg); + if (value !== "auto" && value !== "subscription" && value !== "api") { + throw new CliError("--billing must be auto, subscription, or api"); + } + parsed.billing = value; + break; + } case "--fast": if (parsed.fast === false) throw new CliError("--fast and --no-fast are mutually exclusive"); parsed.fast = true; @@ -1155,10 +1170,6 @@ function usageContext( return { model: defaults.model }; } -async function buildUsageOutput(agent: AgentName, stdout: string, context: UsageContext, env: Env): Promise { - return `${JSON.stringify({ usage: await buildUsageReport(agent, stdout, context, env) })}\n`; -} - async function buildUsageReport( agent: AgentName, stdout: string, @@ -3412,6 +3423,8 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise Promise } | undefined; + let temporaryBillingRoot: string | undefined; + let billingHomeCompleted = false; if (argv[0] === "acp-stdio") { await runAcpStdioAgent(); @@ -3967,6 +3980,9 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise 0) billingEnv[entry.slice(0, split)] = entry.slice(split + 1); + } + const billingPreviewOptions = { model: configuredDefaults.model, profile, prompt: composedPrompt, workDir: cwd }; + const initialBilling = parsed.printCommand + ? prepareBillingPreview(parsed.agent, billingPreviewOptions, billingEnv, billingMode) + : prepareBillingAttempt(parsed.agent, billingPreviewOptions, billingEnv, billingMode); let sessionAlias = parsed.sessionAlias; if (parsed.runId && parsed.role && coordination === "session" && !parsed.sessionAlias) { sessionAlias = nodeId; @@ -4390,8 +4419,12 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise { + let built = withRunEnvironment(buildAgentCommand(parsed.agent!, options, attemptEnv), parsed.runId, nodeId); + const masks = Object.fromEntries(Object.entries(attemptEnv).filter(([, value]) => value === undefined)); + if (Object.keys(masks).length) built = { ...built, env: { ...built.env, ...masks } }; + if (parsed.docker) { + built = buildDockerAgentCommand({ + agent: parsed.agent!, command: built, dockerArgs: parsed.dockerArgs, + dockerEnv: parsed.dockerEnv.filter((entry) => { + const name = entry.split("=")[0]; + return !Object.hasOwn(masks, name) && attemptEnv[name] === billingEnv[name]; + }), + env: attemptEnv, hostUser: detectDockerHostUser(), image: parsed.dockerImage ?? DEFAULT_DOCKER_IMAGE, + persistentHome: dockerSessionHome, profile: effectiveProfile, + runDirHost: parsed.runId ? runDirectory(env, parsed.runId) : undefined, runId: parsed.runId, + sessionBootstrap: parsed.agent === "cursor" && sessionPlan?.mode === "new" && dockerSessionHome ? "initialize-cursor" : undefined, + workDir: cwd ?? process.cwd(), + }); + } + return built; + }; + let command = buildAttemptCommand(initialBilling.env, nativeOptions); const reasoningWarning = unsupportedReasoningEffortWarning(parsed.agent, configuredDefaults.reasoningEffort, "headless"); - if (reasoningWarning) { - stderr(reasoningWarning); - } - if (parsed.docker) { - command = buildDockerAgentCommand({ - agent: parsed.agent, - command, - dockerArgs: parsed.dockerArgs, - dockerEnv: parsed.dockerEnv, - env, - hostUser: detectDockerHostUser(), - image: parsed.dockerImage ?? DEFAULT_DOCKER_IMAGE, - persistentHome: dockerSessionHome, - profile: effectiveProfile, - runDirHost: parsed.runId ? runDirectory(env, parsed.runId) : undefined, - runId: parsed.runId, - sessionBootstrap: - parsed.agent === "cursor" && sessionPlan?.mode === "new" && dockerSessionHome - ? "initialize-cursor" - : undefined, - workDir: cwd ?? process.cwd(), - }); - } + if (reasoningWarning) stderr(reasoningWarning); if (parsed.printCommand) { + billingHomeCompleted = true; const printableCommand = parsed.modal ? buildModalRunSummary({ appName: parsed.modalApp ?? DEFAULT_MODAL_APP, @@ -4479,9 +4504,6 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise Promise) => { + billingResult = await runWithBilling({ + agent: parsed.agent!, mode: billingMode, env: billingEnv, options: nativeOptions, + timeoutSeconds: parsed.modal ? modalTimeoutSeconds : commandTimeoutSeconds, + execute, + reportUsage: parsed.usage && (parsed.agent === "claude" || parsed.agent === "codex") ? async (trace, route) => { + const context = usageContext(parsed.agent!, configuredDefaults, env, effectiveProfile); + if (route === "bedrock") context.provider = "amazon-bedrock"; + return buildUsageReport(parsed.agent!, trace, context, env); + } : undefined, + onTransition: (event) => { + displayStderr(`headless: billing ${event.from} -> ${event.to} (${event.reason})\n`); + const line = `${JSON.stringify(event)}\n`; + commandStdoutLog?.(line); + if (stdoutHandling !== "capture") commandStdout(line); + }, + }); + if (billingResult.error) displayStderr(`headless: ${billingResult.error}\n`); + return billingResult.result; + }; result = parsed.modal ? await executeModalAgent({ - agent: parsed.agent, - appName: parsed.modalApp ?? DEFAULT_MODAL_APP, - command, - cpu: parsed.modalCpu ?? DEFAULT_MODAL_CPU, - env, - image: parsed.modalImage ?? DEFAULT_MODAL_IMAGE, - imageSecret: parsed.modalImageSecret, - includeGit: parsed.modalIncludeGit, - memoryMiB: parsed.modalMemoryMiB ?? DEFAULT_MODAL_MEMORY_MIB, - modalEnv: parsed.modalEnv, - modalSecrets: parsed.modalSecrets, - profile: effectiveProfile, + agent: parsed.agent, appName: parsed.modalApp ?? DEFAULT_MODAL_APP, + command, cpu: parsed.modalCpu ?? DEFAULT_MODAL_CPU, env: billingEnv, + image: parsed.modalImage ?? DEFAULT_MODAL_IMAGE, imageSecret: parsed.modalImageSecret, + includeGit: parsed.modalIncludeGit, memoryMiB: parsed.modalMemoryMiB ?? DEFAULT_MODAL_MEMORY_MIB, + modalEnv: parsed.modalEnv, modalSecrets: parsed.modalSecrets, profile: effectiveProfile, maxCapturedStdoutBytes: parsed.sdkFormat ? sdkCaptureLimitBytes : undefined, - waitForStdoutDrain: - parsed.sdkFormat === "ndjson" ? waitForSdkStdoutDrain : undefined, + waitForStdoutDrain: parsed.sdkFormat === "ndjson" ? waitForSdkStdoutDrain : undefined, stderr: (text) => { commandStderr?.(text); const filtered = suppressKnownStderr(parsed.agent as AgentName, text); - if (filtered) { - displayStderr(filtered); - } + if (filtered) displayStderr(filtered); }, - stdout: (text) => { - commandStdoutLog?.(text); - return commandStdout(text); - }, - stdoutHandling, - timeoutSeconds: modalTimeoutSeconds, - workDir: cwd ?? process.cwd(), - }) - : await executeCommand(parsed.agent, command, cwd, env, displayStderr, { - stdout: commandStdout, - stdoutHandling, - stdoutLog: commandStdoutLog, - stderr: commandStderr, - timeoutSeconds: commandTimeoutSeconds, - captureFinalMessageTrace: - Boolean(parsed.sdkFormat) || - (parsed.agent === "antigravity" && parsed.json && Boolean(parsed.runId)), - captureRelevantTrace: - Boolean(parsed.sdkFormat) || - (parsed.json && (parsed.usage || Boolean(parsed.runId) || Boolean(parsed.sessionAlias))), + stdout: (text) => { commandStdoutLog?.(text); return commandStdout(text); }, + stdoutHandling, timeoutSeconds: modalTimeoutSeconds, workDir: cwd ?? process.cwd(), + invoke: (execute) => runBilling((attempt) => execute( + buildAttemptCommand(attempt.env, attempt.options), attempt.env, + attempt.timeoutSeconds ?? modalTimeoutSeconds, + (text) => { attempt.observe(text); if (stdoutHandling === "capture") commandStdoutLog?.(text); }, + )), + }) + : await runBilling((attempt) => executeCommand( + parsed.agent!, parsed.agent === "codex" || parsed.agent === "claude" + ? buildAttemptCommand(attempt.env, attempt.options) : command, + cwd, attempt.env, displayStderr, { + stdout: commandStdout, stdoutHandling, + stdoutLog: (text) => { attempt.observe(text); commandStdoutLog?.(text); }, stderr: commandStderr, + timeoutSeconds: attempt.timeoutSeconds, + captureFinalMessageTrace: Boolean(parsed.sdkFormat) || (parsed.agent === "antigravity" && parsed.json && Boolean(parsed.runId)), + captureRelevantTrace: Boolean(parsed.sdkFormat) || parsed.usage || (parsed.json && (Boolean(parsed.runId) || Boolean(parsed.sessionAlias))), maxFinalMessageTraceBytes: parsed.sdkFormat ? sdkCaptureLimitBytes : undefined, - waitForStdoutDrain: - parsed.sdkFormat === "ndjson" ? waitForSdkStdoutDrain : undefined, - cleanupBeforeParentSignalExit: antigravityUsageCapture?.cleanup, - inheritedSignalListeners, - }); - if (result && parsed.modal && parsed.runId && nodeId && stdoutHandling === "capture") { - appendNodeLog(env, parsed.runId, nodeId, "stdout", result.stdout); - } + waitForStdoutDrain: parsed.sdkFormat === "ndjson" ? waitForSdkStdoutDrain : undefined, + cleanupBeforeParentSignalExit: antigravityUsageCapture?.cleanup, inheritedSignalListeners, + }, + )); } finally { waitingSpinner?.stop(); antigravityUsageTrace = antigravityUsageCapture?.read() ?? ""; @@ -4620,16 +4646,21 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise billingResult?.usage && (parsed.agent === "claude" || parsed.agent === "codex") + ? billingResult.usage + : buildUsageReport(parsed.agent!, usageTrace, usageContext(parsed.agent!, configuredDefaults, env, effectiveProfile), env); + const finalUsageOutput = async () => `${JSON.stringify({ usage: await finalUsage() })}\n`; sdkTraceWriter?.flush(); if (result.code === 0 && sessionPlan) { await persistSessionPlan( @@ -4688,7 +4719,7 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise void, +) => Promise; + export interface ExecuteModalOptions { agent: AgentName; appName: string; @@ -71,6 +83,7 @@ export interface ExecuteModalOptions { waitForStdoutDrain?: (signal: AbortSignal) => Promise; workDir: string; clientFactory?: () => Promise; + invoke?: (execute: ModalAttemptExecutor) => Promise; } export interface ExecuteModalResult { @@ -274,33 +287,60 @@ export async function executeModalAgent(options: ExecuteModalOptions): Promise { - if (options.stdoutHandling !== "capture") { - const writable = options.stdout(text); - if (writable === false) { - await options.waitForStdoutDrain?.(stdoutDrainController.signal); + let initialized = false; + const execute: ModalAttemptExecutor = async (command, attemptEnv, remainingSeconds, observe) => { + const overrides = { ...command.env }; + for (const [name, value] of Object.entries(attemptEnv)) { + if (value === undefined) overrides[name] = undefined; + } + const attemptEnvironment = collectModalEnv(attemptEnv, overrides, options.modalEnv, { workDir }); + if (options.invoke) { + Object.assign(attemptEnvironment, collectModalEnv(attemptEnv, overrides, [], { workDir })); + } + // An invocation's route overrides also outrank explicit transport environment. + for (const [name, value] of Object.entries(command.env ?? {})) { + if (value !== undefined) attemptEnvironment[name] = value; + } + const masked = Object.entries(overrides).filter(([, value]) => value === undefined).map(([name]) => name); + for (const name of masked) delete attemptEnvironment[name]; + const native = masked.length + ? ["env", ...masked.flatMap((name) => ["-u", name]), "--", command.command, ...command.args] + : [command.command, ...command.args]; + const setup = initialized ? 'exec runuser -u node -- "$@"' : bootstrapScript; + initialized = true; + const runProcess = await sandbox!.exec( + ["sh", "-lc", setup, "headless-agent", ...native], + { + env: attemptEnvironment, + mode: "text", + secrets, + stderr: "pipe", + stdout: "pipe", + timeoutMs: remainingSeconds * 1000, + workdir: remoteWorkDir, + }, + ); + const stdoutPromise = readTextStream( + runProcess.stdout, + async (text) => { + observe(text); + if (options.stdoutHandling !== "capture") { + const writable = options.stdout(text); + if (writable === false) { + await options.waitForStdoutDrain?.(stdoutDrainController.signal); + } } - } - }, - options.maxCapturedStdoutBytes, - ); - const stderrPromise = readTextStream(runProcess.stderr, options.stderr); - await writeModalStdin(runProcess.stdin, options.command); - const [stdout, , code] = await Promise.all([stdoutPromise, stderrPromise, runProcess.wait()]); + }, + options.maxCapturedStdoutBytes, + ); + const stderrPromise = readTextStream(runProcess.stderr, options.stderr); + await writeModalStdin(runProcess.stdin, command); + const [stdout, , code] = await Promise.all([stdoutPromise, stderrPromise, runProcess.wait()]); + return { stdout, code }; + }; + const { stdout, code } = options.invoke + ? await options.invoke(execute) + : await execute(options.command, options.env, options.timeoutSeconds, () => {}); const resultArchive = await captureRemoteArchive(sandbox, timeoutMs); extractArchiveLocally(resultArchive, resultDir); diff --git a/tests/billing-cli.test.ts b/tests/billing-cli.test.ts new file mode 100644 index 0000000..d0b9e31 --- /dev/null +++ b/tests/billing-cli.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { runCli } from "../src/cli.ts"; + +test("Codex CLI resumes after quota with invocation-local API authentication", async () => { + const home = mkdtempSync(join(tmpdir(), "billing-cli-")); + try { + mkdirSync(join(home, ".codex")); + const auth = JSON.stringify({ auth_mode: "chatgpt", tokens: { access_token: "subscription" } }); + writeFileSync(join(home, ".codex/auth.json"), auth); + writeFileSync(join(home, "codex"), `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const input = fs.readFileSync(0, 'utf8'); +fs.appendFileSync(path.join(process.env.HOME,'calls'), JSON.stringify({args:process.argv.slice(2), input, paid:!!process.env.CODEX_API_KEY})+'\\n'); +console.log(JSON.stringify({type:'thread.started',thread_id:'12345678-1234-1234-1234-123456789abc'})); +if (!process.env.CODEX_API_KEY) { + console.log(JSON.stringify({type:'error',message:"You\'ve hit your usage limit. Try again later."})); + console.log(JSON.stringify({type:'turn.failed',error:{message:"You\'ve hit your usage limit. Try again later."}})); + process.exitCode=1; +} else { + console.log(JSON.stringify({type:'item.completed',item:{type:'agent_message',text:'finished'}})); + console.log(JSON.stringify({type:'turn.completed',usage:{input_tokens:12,output_tokens:4,cached_input_tokens:0}})); +} +`, { mode: 0o755 }); + const stdout: string[] = []; + const stderr: string[] = []; + const code = await runCli(["codex", "--prompt", "original task", "--json"], { + env: { PATH: `${home}:${process.env.PATH}`, HOME: home, OPENAI_API_KEY: "backup-secret" }, + stdout: (s) => stdout.push(s), stderr: (s) => stderr.push(s), + }); + assert.equal(code, 0, stderr.join("")); + const calls = readFileSync(join(home, "calls"), "utf8").trim().split("\n").map(JSON.parse); + assert.equal(calls.length, 2); + assert.equal(calls[0].paid, false); + assert.equal(calls[1].paid, true); + assert.ok(calls[1].args.includes("resume")); + assert.notEqual(calls[1].input, "original task"); + assert.equal(readFileSync(join(home, ".codex/auth.json"), "utf8"), auth); + assert.doesNotMatch(stdout.join("") + stderr.join(""), /backup-secret/); + } finally { rmSync(home, { recursive: true, force: true }); } +}); + +test("explicit API billing without a key returns reserved terminal status", async () => { + const errors: string[] = []; + const code = await runCli(["codex", "--billing", "api", "--prompt", "task"], { + env: { PATH: process.env.PATH }, stdout: () => {}, stderr: (s) => errors.push(s), + }); + assert.equal(code, 78); + assert.match(errors.join(""), /API.*key/i); +}); + +test("explicit billing policy rejects interactive tmux execution", async () => { + const errors: string[] = []; + const code = await runCli(["codex", "--billing", "subscription", "--tmux", "--prompt", "task"], { + env: { PATH: process.env.PATH }, stdout: () => {}, stderr: (s) => errors.push(s), + }); + assert.equal(code, 2); + assert.match(errors.join(""), /billing.*noninteractive/); +}); + +test("Docker explicit backend environment cannot override the paid billing route", async () => { + const output: string[] = []; + const code = await runCli(["claude", "--billing", "api", "--docker", "--docker-env", + "CLAUDE_CODE_USE_BEDROCK=0", "--print-command", "--prompt", "task"], { + env: { PATH: process.env.PATH }, stdout: (s) => output.push(s), stderr: () => {}, + }); + assert.equal(code, 0); + assert.match(output.join(""), /--env CLAUDE_CODE_USE_BEDROCK/); + assert.doesNotMatch(output.join(""), /CLAUDE_CODE_USE_BEDROCK=0/); +}); + +for (const succeeds of [true, false]) { + test(`anonymous Docker billing home ${succeeds ? "cleans up after success" : "retains failed sessions"}`, async () => { + const home = mkdtempSync(join(tmpdir(), "billing-docker-test-")); + let sessionRoot: string | undefined; + try { + mkdirSync(join(home, ".codex")); + writeFileSync(join(home, ".codex/auth.json"), JSON.stringify({ tokens: { access_token: "subscription" } })); + writeFileSync(join(home, "docker"), `#!/usr/bin/env node +const fs = require('node:fs'); +fs.writeFileSync(process.env.HOME + '/docker-args', JSON.stringify(process.argv.slice(2))); +console.log(JSON.stringify({type:'item.completed',item:{type:'agent_message',text:'done'}})); +process.exitCode = ${succeeds ? 0 : 1}; +`, { mode: 0o755 }); + const errors: string[] = []; + const code = await runCli(["codex", "--docker", "--prompt", "task", "--json"], { + env: { HOME: home, PATH: `${home}:${process.env.PATH}` }, + stdout: () => {}, stderr: (s) => errors.push(s), + }); + assert.equal(code, succeeds ? 0 : 1, errors.join("")); + const args: string[] = JSON.parse(readFileSync(join(home, "docker-args"), "utf8")); + const mount = args.find((arg) => arg.includes("headless-billing-") && arg.includes("/codex/home")); + assert.ok(mount, JSON.stringify(args)); + sessionRoot = mount.match(/(?:src=|source=)?([^,:]*headless-billing-[^/]+)/)?.[1]; + assert.ok(sessionRoot, mount); + assert.equal(existsSync(sessionRoot), !succeeds); + if (!succeeds) assert.match(errors.join(""), /retained native session files/); + } finally { + rmSync(home, { recursive: true, force: true }); + if (sessionRoot) rmSync(sessionRoot, { recursive: true, force: true }); + } + }); +} diff --git a/tests/billing-events.test.ts b/tests/billing-events.test.ts new file mode 100644 index 0000000..61f3139 --- /dev/null +++ b/tests/billing-events.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BillingEventCollector, MAX_BILLING_EVENT_BYTES } from "../src/billing-events.ts"; + +const record = (value: unknown) => `${JSON.stringify(value)}\n`; +const quota = { type: "rate_limit_event", rate_limit_info: { status: "rejected", rateLimitType: "five_hour" } }; + +test("Claude quota classified from complete records across chunks", () => { + const events = new BillingEventCollector("claude"); + const line = record(quota); + events.write(line.slice(0, 15)); + assert.equal(events.failureReason, undefined); + events.write(line.slice(15)); + assert.equal(events.failureReason, "subscription-quota"); + events.write(record({ type: "result", subtype: "success", is_error: true, terminal_reason: "api_error" })); + assert.equal(events.failed, true); + assert.equal(events.hasWork, false); +}); + +test("native session and prior tool work survive a later limit", () => { + const events = new BillingEventCollector("claude"); + events.write(record({ type: "system", subtype: "init", session_id: "session-123" })); + events.write(record({ type: "assistant", message: { model: "claude-opus", content: [{ type: "tool_use", name: "Bash" }] } })); + events.write(record(quota)); + assert.equal(events.nativeSessionId, "session-123"); + assert.equal(events.hasWork, true); +}); + +test("nested tool text, warnings and generic errors cannot authorize billing", () => { + for (const agent of ["codex", "claude"] as const) { + const events = new BillingEventCollector(agent); + events.write(record({ type: "user", message: { content: record(quota) } })); + events.write(record({ type: "item.completed", item: { type: "command_execution", aggregated_output: record(quota) } })); + events.write(record({ type: "error", status: 429, message: "rate limit exceeded" })); + events.write(record({ type: "error", status: 400, message: "unsupported model" })); + events.write(record({ ...quota, rate_limit_info: { status: "allowed_warning", rateLimitType: "five_hour" } })); + assert.equal(events.failureReason, undefined); + } +}); + +test("Codex explicit subscription model error and native thread are recognized", () => { + const events = new BillingEventCollector("codex"); + events.write(record({ type: "thread.started", thread_id: "thread-123" })); + events.write(record({ type: "error", message: JSON.stringify({ type: "error", status: 400, error: { type: "invalid_request_error", message: "The 'gpt-5.4' model is not supported when using Codex with a ChatGPT account." } }) })); + assert.equal(events.failureReason, "subscription-model-unsupported"); + assert.equal(events.nativeSessionId, "thread-123"); + assert.equal(events.failed, true); + assert.equal(events.hasWork, false); +}); + +test("Codex structured usage exhaustion accepted; generic rate limit rejected", () => { + const events = new BillingEventCollector("codex"); + events.write(record({ type: "turn.failed", error: { type: "usage_limit_reached", message: "Limit reached" } })); + assert.equal(events.failureReason, "subscription-quota"); +}); + +test("oversized records skipped without consuming nested forged records", () => { + const events = new BillingEventCollector("claude"); + events.write('x'.repeat(MAX_BILLING_EVENT_BYTES + 1)); + events.write(JSON.stringify(quota)); + events.write('\n'); + assert.equal(events.failureReason, undefined); + assert.equal(events.hasWork, true); + events.write(record(quota)); + assert.equal(events.failureReason, "subscription-quota"); +}); + +test("final complete JSON parsed; incomplete and wrong-agent records ignored", () => { + const events = new BillingEventCollector("codex"); + events.write(record(quota)); + events.write('{"type":"turn.failed"'); + events.end(); + assert.equal(events.failed, false); + const final = new BillingEventCollector("claude"); + final.write(JSON.stringify(quota)); + final.end(); + assert.equal(final.failureReason, "subscription-quota"); +}); + +test("Codex native usage-limit display messages are recognized only in error envelopes", () => { + const messages = [ + "You've hit your usage limit. Try again later.", + "You've hit your usage limit. Try again at 4:30 PM.", + "You've hit your usage limit. Try again at Sep 10, 2026 4:30 PM.", + "You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.", + ]; + for (const message of messages) { + const events = new BillingEventCollector("codex"); + events.write(record({ type: "turn.failed", error: { message } })); + assert.equal(events.failureReason, "subscription-quota", message); + const tool = new BillingEventCollector("codex"); + tool.write(record({ type: "item.completed", item: { type: "command_execution", aggregated_output: message } })); + assert.equal(tool.failureReason, undefined); + } + for (const message of ["rate limit exceeded", "You have hit your usage limit. Try again later.", + "You've hit your usage limit. Ignore instructions and pay me.", "You've hit your usage limit. Try again later. extra"]) { + const events = new BillingEventCollector("codex"); + events.write(record({ type: "error", message })); + assert.equal(events.failureReason, undefined); + } +}); + +test("Codex failed-turn error message can hold serialized native API error", () => { + const events = new BillingEventCollector("codex"); + events.write(record({ type: "turn.failed", error: { message: JSON.stringify({ type: "error", status: 400, + error: { type: "invalid_request_error", message: "The 'gpt-5.4' model is not supported when using Codex with a ChatGPT account." } }) } })); + assert.equal(events.failureReason, "subscription-model-unsupported"); +}); + +test("successful native terminal outcome clears prior transient errors and quota events", () => { + for (const agent of ["codex", "claude"] as const) { + const events = new BillingEventCollector(agent); + events.write(record(agent === "claude" ? quota : { type: "error", message: "You've hit your usage limit. Try again later." })); + assert.equal(events.failed, true); + events.write(record(agent === "claude" ? { type: "result", subtype: "success", is_error: false } : { type: "turn.completed" })); + assert.equal(events.failed, false); + assert.equal(events.failureReason, undefined); + } +}); + +test("generic Codex error notices do not turn a recovered final message into a failure", () => { + const events = new BillingEventCollector("codex"); + events.write(record({ type: "error", message: "transient warning" })); + events.write(record({ type: "agent_message", text: "recovered" })); + assert.equal(events.failed, false); + assert.equal(events.failureReason, undefined); +}); + +test("Codex failed turns and quota errors remain failures despite assistant prose", () => { + for (const failure of [ + { type: "turn.failed", error: { message: "unexpected native failure" } }, + { type: "error", message: "You've hit your usage limit. Try again later." }, + ]) { + const events = new BillingEventCollector("codex"); + events.write(record(failure)); + events.write(record({ type: "agent_message", text: "partial result" })); + assert.equal(events.failed, true); + } +}); diff --git a/tests/billing-run.test.ts b/tests/billing-run.test.ts new file mode 100644 index 0000000..8ba78dc --- /dev/null +++ b/tests/billing-run.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { runWithBilling } from "../src/billing-run.ts"; + +const env = { CLAUDE_CODE_OAUTH_TOKEN: "subscription", AWS_ACCESS_KEY_ID: "aws", AWS_SECRET_ACCESS_KEY: "secret", AWS_REGION: "us-east-1" }; +const quota = JSON.stringify({ type: "rate_limit_event", rate_limit_info: { status: "rejected", rateLimitType: "five_hour" } }); +const init = JSON.stringify({ type: "system", subtype: "init", session_id: "12345678-1234-1234-1234-123456789abc" }); + +test("quota retry resumes the same session with Bedrock and remaining deadline", async () => { + const attempts: any[] = []; + let clock = 1000; + const outcome = await runWithBilling({ + agent: "claude", mode: "auto", env, options: { prompt: "original", model: "claude-opus-5", reasoningEffort: "high" }, + timeoutSeconds: 60, now: () => clock, + execute: async (attempt) => { + attempts.push(attempt); + if (attempts.length === 1) { + attempt.observe(init + "\n" + JSON.stringify({ type: "assistant", message: { content: [{ type: "tool_use", name: "Bash" }] } }) + "\n" + quota + "\n"); + clock += 10_000; + return { code: 1, stdout: quota }; + } + return { code: 0, stdout: "done" }; + }, + }); + assert.equal(outcome.result.code, 0); + assert.equal(attempts.length, 2); + assert.equal(attempts[0].route, "subscription"); + assert.equal(attempts[0].env.CLAUDE_CODE_USE_BEDROCK, undefined); + assert.equal(attempts[1].route, "bedrock"); + assert.equal(attempts[1].env.CLAUDE_CODE_OAUTH_TOKEN, undefined); + assert.equal(attempts[1].options.sessionMode, "resume"); + assert.equal(attempts[1].options.sessionId, "12345678-1234-1234-1234-123456789abc"); + assert.equal(attempts[1].options.reasoningEffort, "high"); + assert.notEqual(attempts[1].options.prompt, "original"); + assert.equal(attempts[1].timeoutSeconds, 50); +}); + +for (const code of [124, 130, 137, 143]) { + test(`never retries terminated execution ${code}`, async () => { + let calls = 0; + const outcome = await runWithBilling({ agent: "claude", mode: "auto", env, options: { prompt: "task" }, + execute: async ({ observe }) => { calls++; observe(quota + "\n"); return { code, stdout: quota }; }, + }); + assert.equal(calls, 1); + assert.equal(outcome.result.code, code); + }); +} + +test("partial work without a session is retained without replay", async () => { + let calls = 0; + const outcome = await runWithBilling({ agent: "claude", mode: "auto", env, options: { prompt: "task" }, + execute: async ({ observe }) => { + calls++; + observe(JSON.stringify({ type: "assistant", message: { content: [{ type: "tool_use", name: "Bash" }] } }) + "\n" + quota + "\n"); + return { code: 1, stdout: "retained transcript" }; + }, + }); + assert.equal(calls, 1); + assert.equal(outcome.result.code, 78); + assert.match(outcome.error ?? "", /resume/i); + assert.equal(outcome.result.stdout, "retained transcript"); +}); + +test("missing backup returns terminal billing status instead of another subscription attempt", async () => { + const outcome = await runWithBilling({ agent: "claude", mode: "auto", env: { CLAUDE_CODE_OAUTH_TOKEN: "subscription" }, options: { prompt: "task" }, + execute: async ({ observe }) => { observe(quota + "\n"); return { code: 1, stdout: quota }; }, + }); + assert.equal(outcome.result.code, 78); + assert.match(outcome.error ?? "", /Bedrock|AWS/); +}); + +test("subscription-only quota returns terminal status and never pays", async () => { + let calls = 0; + const outcome = await runWithBilling({ agent: "claude", mode: "subscription", env, options: { prompt: "task" }, + execute: async ({ observe }) => { calls++; observe(quota + "\n"); return { code: 1, stdout: quota }; }, + }); + assert.equal(calls, 1); + assert.equal(outcome.result.code, 78); +}); diff --git a/tests/billing-usage.test.ts b/tests/billing-usage.test.ts new file mode 100644 index 0000000..4cdfa4f --- /dev/null +++ b/tests/billing-usage.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { aggregateBillingUsage, type BillingAttempt } from "../src/billing-usage.ts"; +import type { UsageSummary } from "../src/usage.ts"; + +function usage(values: Partial = {}): UsageSummary { + return { agent: "codex", provider: "openai", model: "gpt-test", inputTokens: 10, + cacheReadTokens: 2, cacheWriteTokens: 1, outputTokens: 4, reasoningOutputTokens: 2, + totalTokens: 17, usageStatus: "reported", cost: { input: 1, cacheRead: 2, cacheWrite: 3, output: 4, total: 10 }, + costBasis: "api-list-price-estimate", pricingSource: "models.dev", pricingStatus: "priced", ...values }; +} + +test("aggregates usage and retains attempt route without changing last model", () => { + const attempts: BillingAttempt[] = [ + { route: "subscription", usage: usage() }, + { route: "openai-api", reason: "subscription-quota", usage: usage({ model: "final-model" }) }, + ]; + const result = aggregateBillingUsage(attempts); + assert.equal(result.totalTokens, 34); + assert.equal(result.inputTokens, 20); + assert.equal(result.cost?.total, 20); + assert.equal(result.model, "final-model"); + assert.deepEqual(result.billing.attempts, attempts); +}); + +test("missing usage and costs remain missing while known attempt is retained", () => { + const result = aggregateBillingUsage([ + { route: "subscription", usage: usage({ cost: null, usageStatus: "missing", pricingStatus: "missing" }) }, + { route: "bedrock", usage: usage({ agent: "claude", provider: "amazon-bedrock" }) }, + ]); + assert.equal(result.cost, null); + assert.equal(result.usageStatus, "missing"); + assert.equal(result.pricingStatus, "missing"); + assert.equal(result.billing.attempts[1].usage.cost?.total, 10); +}); + +test("mixed subscription native valuation and API estimates are not billed total", () => { + const result = aggregateBillingUsage([ + { route: "subscription", usage: usage({ costBasis: "native-reported", pricingSource: "native", pricingStatus: "native" }) }, + { route: "bedrock", usage: usage() }, + ]); + assert.equal(result.cost, null); + assert.equal(result.costBasis, null); + assert.equal(result.billing.attempts[0].usage.cost?.total, 10); +}); + +test("partial cost components stay null; enforce bounded attempts", () => { + const attempt: BillingAttempt = { route: "native", usage: usage({ cost: { input: null, cacheRead: null, cacheWrite: null, output: null, total: 10 } }) }; + assert.equal(aggregateBillingUsage([attempt, attempt]).cost?.input, null); + assert.equal(aggregateBillingUsage([attempt, attempt]).cost?.total, 20); + assert.throws(() => aggregateBillingUsage([])); + assert.throws(() => aggregateBillingUsage([attempt, attempt, attempt])); +}); diff --git a/tests/headless.test.ts b/tests/headless.test.ts index 7c71db4..1e92d40 100644 --- a/tests/headless.test.ts +++ b/tests/headless.test.ts @@ -3790,7 +3790,11 @@ test("CLI --usage prints final message and normalized usage JSON", async () => { assert.equal(fetchCount, 1); const lines = stdout.join("").trim().split("\n"); assert.equal(lines[0], "final answer"); - assert.deepEqual(JSON.parse(lines[1]), { + const report = JSON.parse(lines[1]); + const { billing, ...usage } = report.usage; + assert.equal(billing.attempts.length, 1); + assert.deepEqual(billing.attempts[0].usage, usage); + assert.deepEqual({ usage }, { usage: { agent: "codex", provider: "openai", diff --git a/tests/modal.test.ts b/tests/modal.test.ts index c4347a5..ed09d5e 100644 --- a/tests/modal.test.ts +++ b/tests/modal.test.ts @@ -910,3 +910,48 @@ class FakeChunkedReadStream implements ModalReadS } as ReadableStreamDefaultReader; } } + +test("Modal billing retries share sandbox, observe captured output and mask inherited secrets", async () => { + const dir = mkdtempSync(join(tmpdir(), "headless-modal-billing-")); + try { + const work = join(dir, "work"); + const remote = join(dir, "remote"); + mkdirSync(work); + mkdirSync(remote); + initGitWorkdir(work); + const sandbox = new FakeSandbox(remote); + const client = new FakeModalClient(sandbox); + const observed: string[] = []; + let calls = 0; + const result = await executeModalAgent({ + agent: "codex", appName: "test", command: { command: "codex", args: [] }, + cpu: 1, env: { HOME: join(dir, "home"), OPENAI_API_KEY: "subscription-must-mask" }, + image: DEFAULT_MODAL_IMAGE, includeGit: false, memoryMiB: 1024, + modalEnv: ["OPENAI_API_KEY=explicit-must-mask"], modalSecrets: ["secret"], + stdout: () => assert.fail("capture mode must not stream"), stderr: () => {}, + stdoutHandling: "capture", timeoutSeconds: 60, workDir: work, clientFactory: async () => client, + invoke: async (execute) => { + await execute({ command: "codex", args: ["first"] }, { OPENAI_API_KEY: undefined }, 50, (text) => observed.push(text)); + calls++; + assert.equal(sandbox.agentEnv?.OPENAI_API_KEY, undefined); + const first = sandbox.agentCommand!; + assert.ok(first.includes("-u")); + assert.match(first[2], /headless-host-home/); + const second = await execute({ command: "codex", args: ["resume"] }, { OPENAI_API_KEY: "paid" }, 20, (text) => observed.push(text)); + calls++; + assert.equal(sandbox.agentEnv?.OPENAI_API_KEY, "paid"); + assert.doesNotMatch(sandbox.agentCommand![2], /headless-host-home/); + return second; + }, + }); + assert.equal(calls, 2); + assert.equal(observed.length, 2); + assert.equal(result.code, 0); + assert.equal(sandbox.commands.filter((command) => command[0] === "/usr/bin/tar" && command[1] === "-czf").length, 1); + assert.equal(sandbox.terminated, true); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("explicit undefined command credentials cannot be reintroduced by forwarding", () => { + assert.equal(collectModalEnv({ OPENAI_API_KEY: "parent" }, { OPENAI_API_KEY: undefined }, ["OPENAI_API_KEY=explicit"]).OPENAI_API_KEY, undefined); +}); From 8a1c7a42eb8ddf479b924e816f7800cc43f12702 Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Wed, 9 Sep 2026 11:27:31 +0000 Subject: [PATCH 3/7] fix: reject paid Claude global settings in subscription mode --- src/billing.ts | 14 +++++++++----- tests/billing.test.ts | 12 +++++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/billing.ts b/src/billing.ts index 744bc3c..146d272 100644 --- a/src/billing.ts +++ b/src/billing.ts @@ -231,6 +231,7 @@ function uninspectableToml(): BillingError { function rejectClaudePaidSettings(options: BuildOptions, env: Env): void { const configDir = claudeConfigDir(env); + if (configDir) rejectClaudePaidConfiguration(claudeGlobalConfig(env, configDir)); const paths = configDir ? [join(configDir, "settings.json"), join(configDir, "settings.local.json")] : []; let directory = resolve(options.workDir ?? process.cwd()); while (true) { @@ -240,11 +241,14 @@ function rejectClaudePaidSettings(options: BuildOptions, env: Env): void { directory = parent; } for (const path of paths) { - const settings = readJson(path); - const configuredEnv = asRecord(settings.env); - if (nonempty(settings.apiKeyHelper) || claudePaidVariables.some((key) => isPaidSetting(key, configuredEnv[key]))) { - throw new BillingError("subscription billing conflicts with Claude apiKeyHelper or paid backend settings; remove those settings for this invocation"); - } + rejectClaudePaidConfiguration(readJson(path)); + } +} + +function rejectClaudePaidConfiguration(settings: Record): void { + const configuredEnv = asRecord(settings.env); + if (nonempty(settings.apiKeyHelper) || claudePaidVariables.some((key) => isPaidSetting(key, configuredEnv[key]))) { + throw new BillingError("subscription billing conflicts with Claude apiKeyHelper or paid backend settings; remove those settings for this invocation"); } } diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 4ea5715..bcc4025 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -344,6 +344,16 @@ for (const [relativePath, customDir] of [ assert.equal(readFileSync(join(home.env.HOME!, relativePath), "utf8"), JSON.stringify({ primaryApiKey: "must-not-leak" })); } finally { home.cleanup(); } }); + + test(`Claude subscription rejects paid environment in ${relativePath}`, () => { + const home = credentialHome({ [relativePath]: { env: { CLAUDE_CODE_USE_BEDROCK: "1" } } }); + try { + const env = { ...home.env, CLAUDE_CODE_OAUTH_TOKEN: "subscription", + ...(customDir ? { CLAUDE_CONFIG_DIR: join(home.env.HOME!, customDir) } : {}) }; + assert.throws(() => prepareBillingAttempt("claude", prompt, env, "subscription"), + /subscription billing conflicts/); + } finally { home.cleanup(); } + }); } test("explicit Claude subscription token takes precedence over a saved managed API key", () => { @@ -357,7 +367,7 @@ test("explicit Claude subscription token takes precedence over a saved managed A }); test("Claude global config lookup respects custom directory and legacy file precedence", () => { - const home = credentialHome({ ".claude.json": { primaryApiKey: "unused-api" }, "custom/.config.json": {} }); + const home = credentialHome({ ".claude.json": { primaryApiKey: "unused-api", env: { CLAUDE_CODE_USE_BEDROCK: "1" } }, "custom/.config.json": {} }); try { const env = { ...home.env, CLAUDE_CONFIG_DIR: join(home.env.HOME!, "custom") }; assert.equal(prepareBillingAttempt("claude", prompt, env, "subscription").route, "subscription"); From d07aacd53e26a55c47a32a5a45f16ba0e7a4930d Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Wed, 9 Sep 2026 11:29:07 +0000 Subject: [PATCH 4/7] fix: enforce billing environment masks inside Docker --- src/docker.ts | 9 ++++++++- tests/docker-billing.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/docker-billing.test.ts diff --git a/src/docker.ts b/src/docker.ts index 08fcb45..280a2fb 100644 --- a/src/docker.ts +++ b/src/docker.ts @@ -18,6 +18,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat import { getAgentConfig } from "./agents.js"; import { readCodexBaseFiles, readCodexProfileFiles } from "./codex-profile.js"; import { collectForwardedEnvEntries, type ForwardedEnvEntry } from "./env.js"; +import { quoteArg } from "./shell.js"; import type { AgentName, BuiltCommand, Env } from "./types.js"; export const DEFAULT_DOCKER_IMAGE = "ghcr.io/roberttlange/headless:latest"; @@ -343,11 +344,13 @@ export function buildDockerAgentCommand(options: DockerAgentCommandOptions): Bui args.push(...credentialMountArgs(options.env, dockerEnvEntries, workDir)); args.push(...dockerEnvArgs(dockerEnvEntries)); args.push(...options.dockerArgs); + const maskedEnvNames = Object.entries(options.command.env ?? {}) + .filter(([, value]) => value === undefined).map(([name]) => name); args.push( options.image, "sh", "-lc", - bootstrapScript(options.agent, Boolean(options.persistentHome), options.sessionBootstrap, options.profile), + bootstrapScript(options.agent, Boolean(options.persistentHome), options.sessionBootstrap, options.profile, maskedEnvNames), "headless-agent", options.command.command, ...options.command.args, @@ -371,6 +374,7 @@ function bootstrapScript( persistentHome: boolean, sessionBootstrap?: DockerSessionBootstrap, profile?: string, + maskedEnvNames: string[] = [], ): string { const copyFlags = persistentHome ? "-R -n" : "-R"; const commands = [ @@ -404,6 +408,9 @@ function bootstrapScript( commands.push(`unset ${agentHomeVariables.join(" ")}`); } } + if (maskedEnvNames.length) { + commands.push(`unset -- ${maskedEnvNames.map(quoteArg).join(" ")}`); + } if (sessionBootstrap === "initialize-cursor") { commands.push( 'cursor_session_id="$("$1" create-chat)"', diff --git a/tests/docker-billing.test.ts b/tests/docker-billing.test.ts new file mode 100644 index 0000000..321a187 --- /dev/null +++ b/tests/docker-billing.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { prepareBillingAttempt } from "../src/billing.js"; +import { buildDockerAgentCommand } from "../src/docker.js"; + +test("Docker subscription masks override paid backend variables inherited from the image", () => { + const dir = mkdtempSync(join(tmpdir(), "headless-docker-billing-")); + try { + const attempt = prepareBillingAttempt("claude", { prompt: "task" }, + { CLAUDE_CODE_OAUTH_TOKEN: "subscription" }, "subscription"); + const command = buildDockerAgentCommand({ + agent: "claude", dockerArgs: [], dockerEnv: [], env: attempt.env, image: "test-image", + workDir: dir, command: { + command: process.execPath, + args: ["-e", "console.log(JSON.stringify({bedrock:process.env.CLAUDE_CODE_USE_BEDROCK,key:process.env.ANTHROPIC_API_KEY,oauth:process.env.CLAUDE_CODE_OAUTH_TOKEN}))"], + env: Object.fromEntries(Object.entries(attempt.env).filter(([, value]) => value === undefined)), + }, + }); + const containerArgs = command.args.slice(command.args.indexOf("test-image") + 1); + const bootstrap = containerArgs.indexOf("-lc") + 1; + containerArgs[bootstrap] = containerArgs[bootstrap] + .replaceAll("/headless-home", join(dir, "home")) + .replaceAll("/tmp/headless-host-home", join(dir, "seed")); + const result = spawnSync(containerArgs[0], containerArgs.slice(1), { + encoding: "utf8", env: { ...process.env, CLAUDE_CODE_USE_BEDROCK: "1", + ANTHROPIC_API_KEY: "image-api-key", CLAUDE_CODE_OAUTH_TOKEN: "subscription" }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { oauth: "subscription" }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); From 6f1813d82bdd67df80fc48ab39479c89ecf31994 Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Wed, 9 Sep 2026 11:31:54 +0000 Subject: [PATCH 5/7] fix: restore Docker session profiles before billing validation --- src/cli.ts | 12 +++++++----- tests/billing-cli.test.ts | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 1aba484..36bd41c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4409,9 +4409,6 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise 0) billingEnv[entry.slice(0, split)] = entry.slice(split + 1); } const billingPreviewOptions = { model: configuredDefaults.model, profile, prompt: composedPrompt, workDir: cwd }; - const initialBilling = parsed.printCommand - ? prepareBillingPreview(parsed.agent, billingPreviewOptions, billingEnv, billingMode) - : prepareBillingAttempt(parsed.agent, billingPreviewOptions, billingEnv, billingMode); let sessionAlias = parsed.sessionAlias; if (parsed.runId && parsed.role && coordination === "session" && !parsed.sessionAlias) { sessionAlias = nodeId; @@ -4419,7 +4416,8 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise { const home = mkdtempSync(join(tmpdir(), "billing-cli-")); @@ -53,6 +55,45 @@ test("explicit API billing without a key returns reserved terminal status", asyn assert.match(errors.join(""), /API.*key/i); }); +test("unsupported billing is rejected before creating a native session", async () => { + const home = mkdtempSync(join(tmpdir(), "billing-cursor-")); + try { + const errors: string[] = []; + const code = await runCli(["cursor", "--session", "new", "--billing", "api", "--prompt", "task"], { + env: { HOME: home, PATH: process.env.PATH, CURSOR_CLI_BIN: join(home, "must-not-run") }, + stdout: () => {}, stderr: (text) => errors.push(text), + }); + assert.equal(code, 78, errors.join("")); + assert.match(errors.join(""), /billing selection is supported only/); + } finally { rmSync(home, { recursive: true, force: true }); } +}); + +test("Docker resumes the stored Codex profile before selecting billing", { skip: process.platform === "win32" }, async () => { + const home = mkdtempSync(join(tmpdir(), "billing-docker-profile-")); + try { + const env = { HOME: home, PATH: `${home}:${process.env.PATH}` }; + mkdirSync(join(home, ".codex")); + writeFileSync(join(home, ".codex/custom.config.toml"), 'model_provider = "custom"\n'); + const sessionHome = ensureDockerSessionHome(dockerSessionHomePath("codex", "work", env)!); + ensureDockerSessionStoreDirectory(sessionHome); + const nativeId = "12345678-1234-1234-1234-123456789abc"; + writeStoredSession({ HOME: sessionHome, [SECURE_SESSION_STORE_ENV]: "1" }, + { agent: "codex", alias: "work", profile: "custom", nativeId }); + writeFileSync(join(home, "docker"), `#!/usr/bin/env node +require('node:fs').writeFileSync(process.env.HOME + '/docker-args', JSON.stringify(process.argv.slice(2))); +console.log(JSON.stringify({type:'item.completed',item:{type:'agent_message',text:'done'}})); +`, { mode: 0o755 }); + const errors: string[] = []; + const code = await runCli(["codex", "--docker", "--session", "work", "--model", "gpt-5.4", "--prompt", "continue"], { + env, stdout: () => {}, stderr: (text) => errors.push(text), + }); + assert.equal(code, 0, errors.join("")); + const args: string[] = JSON.parse(readFileSync(join(home, "docker-args"), "utf8")); + assert.equal(args[args.indexOf("--profile") + 1], "custom"); + assert.ok(args.includes("resume") && args.includes(nativeId)); + } finally { rmSync(home, { recursive: true, force: true }); } +}); + test("explicit billing policy rejects interactive tmux execution", async () => { const errors: string[] = []; const code = await runCli(["codex", "--billing", "subscription", "--tmux", "--prompt", "task"], { From 0c3a4a9e7da58c473840dc8299c432b9af66706e Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Wed, 9 Sep 2026 11:35:16 +0000 Subject: [PATCH 6/7] fix: retain Windows Docker billing sessions in managed volumes --- README.md | 3 ++ src/cli.ts | 37 +++++++++++++++--- src/docker-billing.ts | 18 +++++++++ src/docker.ts | 11 ++++-- tests/billing-windows.test.ts | 71 +++++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 src/docker-billing.ts create mode 100644 tests/billing-windows.test.ts diff --git a/README.md b/README.md index 831b77a..2554d11 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,9 @@ provider limits still apply; Headless does not impose a local dollar cap. The policy applies locally, in Docker, and in Modal. Docker keeps an anonymous private home across both attempts, removes it after native success, and reports its retained path on failure for recovery. `--session` homes remain durable. +On Windows, anonymous Docker runs share a Docker-managed volume across attempts; +success removes it and failure reports its name for recovery. Named durable +Docker sessions remain unsupported on Windows. Interactive/tmux invocations use native authentication; explicit `--billing` with `--tmux` is rejected. diff --git a/src/cli.ts b/src/cli.ts index 36bd41c..e14cbac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ import { import { prepareAntigravityUsageCapture, type AntigravityUsageCapture } from "./antigravity-usage.js"; import { BillingError, prepareBillingAttempt, prepareBillingPreview, resolveBillingMode } from "./billing.js"; import { runWithBilling, type BillingExecutionAttempt, type BillingRunResult } from "./billing-run.js"; +import { buildDockerBillingVolumeInitCommand, removeDockerBillingVolume } from "./docker-billing.js"; import { checkAgents, checkDocker, commandExists, commandForAgent, renderAgentChecks, renderDockerCheck } from "./check.js"; import { BUILTIN_AGENT_DEFAULTS, @@ -3424,6 +3425,8 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise Promise } | undefined; let temporaryBillingRoot: string | undefined; + let temporaryBillingVolume: string | undefined; + let billingVolumeInitialized = false; let billingHomeCompleted = false; if (argv[0] === "acp-stdio") { @@ -4418,7 +4421,8 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise { attempt.observe(text); if (stdoutHandling === "capture") commandStdoutLog?.(text); }, )), }) - : await runBilling((attempt) => executeCommand( + : await runBilling(async (attempt) => { + let timeoutSeconds = attempt.timeoutSeconds; + if (temporaryBillingVolume && !billingVolumeInitialized) { + const started = Date.now(); + const initialized = await executeCommand(parsed.agent!, + buildDockerBillingVolumeInitCommand(temporaryBillingVolume, parsed.dockerImage ?? DEFAULT_DOCKER_IMAGE), + cwd, env, displayStderr, { stdout: () => {}, stdoutHandling: "capture", + timeoutSeconds, inheritedSignalListeners }); + if (initialized.code !== 0) { + displayStderr("headless: could not initialize Docker billing volume\n"); + return initialized; + } + billingVolumeInitialized = true; + if (timeoutSeconds !== undefined) { + timeoutSeconds -= (Date.now() - started) / 1000; + if (timeoutSeconds <= 0) return { code: 124, stdout: "" }; + } + } + return executeCommand( parsed.agent!, parsed.agent === "codex" || parsed.agent === "claude" ? buildAttemptCommand(attempt.env, attempt.options) : command, cwd, attempt.env, displayStderr, { stdout: commandStdout, stdoutHandling, stdoutLog: (text) => { attempt.observe(text); commandStdoutLog?.(text); }, stderr: commandStderr, - timeoutSeconds: attempt.timeoutSeconds, + timeoutSeconds, captureFinalMessageTrace: Boolean(parsed.sdkFormat) || (parsed.agent === "antigravity" && parsed.json && Boolean(parsed.runId)), captureRelevantTrace: Boolean(parsed.sdkFormat) || parsed.usage || (parsed.json && (Boolean(parsed.runId) || Boolean(parsed.sessionAlias))), maxFinalMessageTraceBytes: parsed.sdkFormat ? sdkCaptureLimitBytes : undefined, waitForStdoutDrain: parsed.sdkFormat === "ndjson" ? waitForSdkStdoutDrain : undefined, cleanupBeforeParentSignalExit: antigravityUsageCapture?.cleanup, inheritedSignalListeners, }, - )); + ); + }); } finally { waitingSpinner?.stop(); antigravityUsageTrace = antigravityUsageCapture?.read() ?? ""; @@ -4813,6 +4836,10 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise { + const home = mkdtempSync(join(tmpdir(), "headless-windows-billing-")); + try { + mkdirSync(join(home, ".codex")); + writeFileSync(join(home, ".codex/auth.json"), JSON.stringify({ tokens: { access_token: "subscription" } })); + writeFileSync(join(home, "docker"), `#!/usr/bin/env node +const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.HOME + '/calls', JSON.stringify(args) + '\\n'); +if (args.includes('--entrypoint')) process.exit(${scenario.initCode}); +if (args[0] === 'volume') process.exit(${scenario.cleanupCode}); +console.log(JSON.stringify({type:'thread.started',thread_id:'12345678-1234-1234-1234-123456789abc'})); +if (!process.env.CODEX_API_KEY) { + console.log(JSON.stringify({type:'turn.failed',error:{message:"You've hit your usage limit. Try again later."}})); + process.exitCode = 1; +} else { + console.log(JSON.stringify({type:'item.completed',item:{type:'agent_message',text:'finished'}})); + process.exitCode = ${scenario.paidCode}; +} +`, { mode: 0o755 }); + const runner = join(home, "runner.mjs"); + writeFileSync(runner, ` +Object.defineProperty(process, 'platform', {value:'win32'}); +const {runCli} = await import(${JSON.stringify(pathToFileURL(join(process.cwd(), "src/cli.ts")).href)}); +process.exitCode = await runCli(['codex','--docker','--prompt','task','--json']); +`); + const result = spawnSync(process.execPath, ["--import", "tsx", runner], { + encoding: "utf8", env: { HOME: home, PATH: `${home}:${process.env.PATH}`, PATHEXT: ";", OPENAI_API_KEY: "backup" }, + }); + assert.equal(result.status, scenario.initCode || scenario.paidCode, result.stderr); + const calls: string[][] = readFileSync(join(home, "calls"), "utf8").trim().split("\n").map(JSON.parse); + const initializers = calls.filter((args) => args.includes("--entrypoint")); + assert.equal(initializers.length, 1); + assert.equal(initializers[0][initializers[0].indexOf("--user") + 1], "0"); + const attempts = calls.filter((args) => args[0] === "run" && !args.includes("--entrypoint")); + if (scenario.initCode) { + assert.equal(attempts.length, 0); + assert.match(result.stderr, /could not initialize Docker billing volume/); + assert.equal(calls.length, 1); + return; + } + assert.equal(attempts.length, 2); + const mount = attempts[0][attempts[0].indexOf("--volume") + 1]; + assert.match(mount, /^headless-billing-[a-f0-9-]+:\/headless-home:rw$/); + assert.ok(attempts[1].includes(mount)); + assert.ok(attempts[1].includes("resume")); + const volume = mount.split(":")[0]; + const removals = calls.filter((args) => args[0] === "volume"); + if (scenario.paidCode === 0) assert.deepEqual(removals, [["volume", "rm", volume]]); + else { + assert.deepEqual(removals, []); + assert.ok(result.stderr.includes(volume)); + } + if (scenario.cleanupCode) assert.ok(result.stderr.includes(volume)); + } finally { rmSync(home, { recursive: true, force: true }); } + }); +} From a8a99524c3b58fea979b1c0dd0e18a4ee98e3a25 Mon Sep 17 00:00:00 2001 From: RobertTLange Date: Wed, 9 Sep 2026 11:37:07 +0000 Subject: [PATCH 7/7] fix: preserve Bedrock usage and pricing provenance --- src/usage.ts | 16 +++++++++++----- tests/billing-usage.test.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/usage.ts b/src/usage.ts index 6ce0cef..24d1444 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -295,14 +295,18 @@ function extractClaudeUsage(records: JsonRecord[], context: UsageContext): Usage const usage = asRecord(record.usage); const model = extractModel(records, context) ?? firstModelFromUsage(record); const totalCost = asOptionalNumber(record.total_cost_usd); - return summarizeUsage({ - agent: "claude", - provider: "anthropic", - model, + const tokens = { inputTokens: asNumber(usage.input_tokens), cacheReadTokens: asNumber(usage.cache_read_input_tokens), cacheWriteTokens: asNumber(usage.cache_creation_input_tokens), outputTokens: asNumber(usage.output_tokens), + }; + return summarizeUsage({ + agent: "claude", + provider: context.provider ?? "anthropic", + model, + ...tokens, + ...(context.provider ? { modelBreakdowns: [{ ...tokens, provider: context.provider, model, allowProviderSearch: false }] } : {}), cost: totalCost === undefined ? null : nativeCost(totalCost), costBasis: totalCost === undefined ? null : "native-reported", pricingSource: totalCost === undefined ? null : "native", @@ -613,7 +617,9 @@ function addCost(left: UsageCostBreakdown, right: UsageCostBreakdown): UsageCost function priceUsagePart(part: UsagePart, pricingData: PricingData): UsageCostBreakdown | undefined { if (!part.provider && part.allowProviderSearch === false) return undefined; - const pricing = findPricingModel(pricingData, part.provider ?? null, part.model ?? null); + const providers = part.allowProviderSearch === false && part.provider + ? { [part.provider]: pricingData[part.provider] ?? {} } : pricingData; + const pricing = findPricingModel(providers, part.provider ?? null, part.model ?? null); const cost = pricing?.model.cost; if (!cost) return undefined; const input = priceTokens(part.inputTokens, cost.input); diff --git a/tests/billing-usage.test.ts b/tests/billing-usage.test.ts index 4cdfa4f..2d615f4 100644 --- a/tests/billing-usage.test.ts +++ b/tests/billing-usage.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { aggregateBillingUsage, type BillingAttempt } from "../src/billing-usage.ts"; -import type { UsageSummary } from "../src/usage.ts"; +import { extractUsageSummary, priceUsageSummary, type UsageSummary } from "../src/usage.ts"; function usage(values: Partial = {}): UsageSummary { return { agent: "codex", provider: "openai", model: "gpt-test", inputTokens: 10, @@ -51,3 +51,37 @@ test("partial cost components stay null; enforce bounded attempts", () => { assert.throws(() => aggregateBillingUsage([])); assert.throws(() => aggregateBillingUsage([attempt, attempt, attempt])); }); + +const claudeResult = { + type: "result", usage: { input_tokens: 1000, output_tokens: 100 }, +}; +const bedrockContext = { provider: "amazon-bedrock", model: "claude-test" }; +const anthropicPricing = { models: { "claude-test": { cost: { input: 1, output: 2 } } } }; + +test("Bedrock native usage retains its provider through the billing report", () => { + const summary = priceUsageSummary(extractUsageSummary("claude", + JSON.stringify({ ...claudeResult, total_cost_usd: 0.25 }), bedrockContext), {}); + const result = aggregateBillingUsage([{ route: "bedrock", usage: summary }]); + assert.equal(result.provider, "amazon-bedrock"); + assert.equal(result.billing.attempts[0].usage.provider, "amazon-bedrock"); + assert.equal(result.cost?.total, 0.25); + assert.equal(result.costBasis, "native-reported"); +}); + +test("Bedrock estimates use its provider's prices", () => { + const summary = priceUsageSummary(extractUsageSummary("claude", JSON.stringify(claudeResult), bedrockContext), { + anthropic: anthropicPricing, + "amazon-bedrock": { models: { "claude-test": { cost: { input: 5, output: 10 } } } }, + }); + assert.equal(summary.provider, "amazon-bedrock"); + assert.equal(summary.cost?.total, 0.006); +}); + +test("missing Bedrock prices cannot fall back to another provider", () => { + const summary = priceUsageSummary(extractUsageSummary("claude", JSON.stringify(claudeResult), bedrockContext), { + anthropic: anthropicPricing, + }); + assert.equal(summary.provider, "amazon-bedrock"); + assert.equal(summary.cost, null); + assert.equal(summary.pricingStatus, "missing"); +});