diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 295195409f..13793545ad 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -292,6 +292,7 @@ Invariants: - `billing_code` text null - `provider` text not null - `model` text not null +- `cost_status` text not null default `reported`; `unpriced` when usage exists but no price was reported - `input_tokens` int not null default 0 - `output_tokens` int not null default 0 - `cost_cents` int not null diff --git a/doc/spec/agent-runs.md b/doc/spec/agent-runs.md index 56e2eae134..ab98996c32 100644 --- a/doc/spec/agent-runs.md +++ b/doc/spec/agent-runs.md @@ -313,7 +313,7 @@ Codex emits JSONL events. Parse line-by-line and extract: - `cached_input_tokens` - `output_tokens` -Codex JSONL currently may not include cost; store token usage and leave cost null/unknown unless available. +Codex JSONL currently may not include cost; store token usage as per-run totals and mark the ledger row `unpriced` unless a cost is available. ## 7.3 Common local adapter process handling diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index ea4e2759eb..961e0c9eb2 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -1067,7 +1067,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { outputTokens: 1_800, cachedInputTokens: 20, }); - expect(parsed.usageBasis).toBeNull(); + expect(parsed.usageBasis).toBe("per_run"); }); }); diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index 852a7f1f01..2707ed2c9a 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -111,7 +111,7 @@ export function parseClaudeStreamJson(stdout: string) { usage, // modelUsage covers exactly this CLI invocation, so mark it per-run to // keep the server from applying its session-cumulative delta heuristic. - usageBasis: modelUsageTotals ? ("per_run" as const) : null, + usageBasis: "per_run" as const, summary, resultJson: finalResult, }; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 976b63cf98..cd9b9f1806 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -976,6 +976,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { cachedInputTokens: 2, outputTokens: 4, }, + usageBasis: "per_run", errorMessage: "resume failed", }); }); @@ -63,6 +64,7 @@ describe("parseCodexJsonl", () => { cachedInputTokens: 2, outputTokens: 4, }, + usageBasis: "per_run", errorMessage: null, }); }); diff --git a/packages/adapters/codex-local/src/server/parse.ts b/packages/adapters/codex-local/src/server/parse.ts index 535d872f28..0c4e0a7312 100644 --- a/packages/adapters/codex-local/src/server/parse.ts +++ b/packages/adapters/codex-local/src/server/parse.ts @@ -70,6 +70,7 @@ export function parseCodexJsonl(stdout: string) { sessionId, summary: finalMessage?.trim() ?? "", usage, + usageBasis: "per_run" as const, errorMessage, }; } diff --git a/packages/db/src/migrations/0147_cost_event_status.sql b/packages/db/src/migrations/0147_cost_event_status.sql new file mode 100644 index 0000000000..ed9879237a --- /dev/null +++ b/packages/db/src/migrations/0147_cost_event_status.sql @@ -0,0 +1 @@ +ALTER TABLE "cost_events" ADD COLUMN IF NOT EXISTS "cost_status" text DEFAULT 'reported' NOT NULL; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index fd11d6dbb9..a28906ec31 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1016,6 +1016,13 @@ "when": 1783822632557, "tag": "0146_routine_activity_gate", "breakpoints": true + }, + { + "idx": 147, + "version": "7", + "when": 1783953514660, + "tag": "0147_cost_event_status", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/cost_events.ts b/packages/db/src/schema/cost_events.ts index f8a36847e6..7652290fbd 100644 --- a/packages/db/src/schema/cost_events.ts +++ b/packages/db/src/schema/cost_events.ts @@ -20,6 +20,7 @@ export const costEvents = pgTable( provider: text("provider").notNull(), biller: text("biller").notNull().default("unknown"), billingType: text("billing_type").notNull().default("unknown"), + costStatus: text("cost_status").notNull().default("reported"), model: text("model").notNull(), inputTokens: integer("input_tokens").notNull().default(0), cachedInputTokens: integer("cached_input_tokens").notNull().default(0), diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 801019a47a..b9db9c76b9 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -687,6 +687,9 @@ export const BILLING_TYPES = [ ] as const; export type BillingType = (typeof BILLING_TYPES)[number]; +export const COST_STATUSES = ["reported", "unpriced"] as const; +export type CostStatus = (typeof COST_STATUSES)[number]; + export const FINANCE_EVENT_KINDS = [ "inference_charge", "platform_fee", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b90f1bb8a..59ba736c6e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -373,6 +373,7 @@ export { type SecretScope, type StorageProvider, type BillingType, + type CostStatus, type FinanceEventKind, type FinanceDirection, type FinanceUnit, diff --git a/packages/shared/src/types/cost.ts b/packages/shared/src/types/cost.ts index 11d9a7652c..88e2af361d 100644 --- a/packages/shared/src/types/cost.ts +++ b/packages/shared/src/types/cost.ts @@ -1,4 +1,4 @@ -import type { BillingType } from "../constants.js"; +import type { BillingType, CostStatus } from "../constants.js"; export interface CostEvent { id: string; @@ -12,6 +12,7 @@ export interface CostEvent { provider: string; biller: string; billingType: BillingType; + costStatus: CostStatus; model: string; inputTokens: number; cachedInputTokens: number; diff --git a/packages/shared/src/validators/cost.ts b/packages/shared/src/validators/cost.ts index ed22d7ebc2..d38ec43681 100644 --- a/packages/shared/src/validators/cost.ts +++ b/packages/shared/src/validators/cost.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { BILLING_TYPES } from "../constants.js"; +import { BILLING_TYPES, COST_STATUSES } from "../constants.js"; export const createCostEventSchema = z.object({ agentId: z.string().uuid(), @@ -11,6 +11,7 @@ export const createCostEventSchema = z.object({ provider: z.string().min(1), biller: z.string().min(1).optional(), billingType: z.enum(BILLING_TYPES).optional().default("unknown"), + costStatus: z.enum(COST_STATUSES).optional().default("reported"), model: z.string().min(1), inputTokens: z.number().int().nonnegative().optional().default(0), cachedInputTokens: z.number().int().nonnegative().optional().default(0), diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 7fe01345a7..af8312a883 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -720,6 +720,9 @@ describe("claude execute", () => { expect(result.exitCode).toBe(0); expect(result.errorMessage).toBeNull(); + expect(result.usage).toEqual({ inputTokens: 1, cachedInputTokens: 0, outputTokens: 1 }); + expect(result.usageBasis).toBe("per_run"); + expect(result.costUsd).toBeNull(); expect(loggedCommand).toBe(commandPath); expect(loggedEnv.HOME).toBe(root); expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index cdadb2f45c..b7d719fce1 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -178,6 +178,9 @@ describe("codex execute", () => { expect(result.exitCode).toBe(0); expect(result.errorMessage).toBeNull(); + expect(result.usage).toEqual({ inputTokens: 1, cachedInputTokens: 0, outputTokens: 1 }); + expect(result.usageBasis).toBe("per_run"); + expect(result.costUsd).toBeNull(); const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; expect(capture.codexHome).toBe(managedCodexHome); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 1effd44490..3ce190c093 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -3,6 +3,7 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll } from "vitest"; import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import { createDb, companies, @@ -432,6 +433,48 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { await tempDb?.cleanup(); }); + it("persists unpriced token usage without inflating monthly spend", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CLI Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const event = await costs.createEvent(companyId, { + agentId, + provider: "openai", + biller: "chatgpt", + billingType: "subscription_included", + costStatus: "unpriced", + model: "gpt-5.6-terra", + inputTokens: 2_732_577, + cachedInputTokens: 2_632_998, + outputTokens: 32_644, + costCents: 0, + occurredAt: new Date("2026-07-13T14:22:54.000Z"), + }); + + expect(event.costStatus).toBe("unpriced"); + expect(event.inputTokens).toBe(2_732_577); + const [agent] = await db.select().from(agents).where(eq(agents.id, agentId)); + expect(agent?.spentMonthlyCents).toBe(0); + }); + it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/heartbeat-cost-accounting.test.ts b/server/src/__tests__/heartbeat-cost-accounting.test.ts new file mode 100644 index 0000000000..9f55385d69 --- /dev/null +++ b/server/src/__tests__/heartbeat-cost-accounting.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { resolveLedgerCostStatus } from "../services/heartbeat.js"; + +describe("heartbeat cost accounting", () => { + it("marks token-bearing CLI usage without a reported cost as unpriced", () => { + expect(resolveLedgerCostStatus({ + costUsd: null, + inputTokens: 2_732_577, + cachedInputTokens: 2_632_998, + outputTokens: 32_644, + })).toBe("unpriced"); + }); + + it("marks reported CLI cost as priced", () => { + expect(resolveLedgerCostStatus({ + costUsd: 1.25, + inputTokens: 2_090, + cachedInputTokens: 300_000, + outputTokens: 77_000, + })).toBe("reported"); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2c792bef19..a9418cd063 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -12,6 +12,7 @@ import { envBindingSchema, isEnvironmentDriverSupportedForAdapter, type BillingType, + type CostStatus, type EnvironmentLeaseStatus, type ExecutionWorkspace, type ExecutionWorkspaceConfig, @@ -2367,6 +2368,16 @@ function normalizeBilledCostCents(costUsd: number | null | undefined, billingTyp return Math.max(0, Math.round(costUsd * 100)); } +export function resolveLedgerCostStatus(input: { + costUsd: number | null | undefined; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +}): CostStatus { + const hasTokenUsage = input.inputTokens > 0 || input.cachedInputTokens > 0 || input.outputTokens > 0; + return input.costUsd == null && hasTokenUsage ? "unpriced" : "reported"; +} + async function resolveLedgerScopeForRun( db: Db, companyId: string, @@ -10867,6 +10878,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const billingType = normalizeLedgerBillingType(result.billingType); const additionalCostCents = normalizeBilledCostCents(result.costUsd, billingType); const hasTokenUsage = inputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0; + const costStatus = resolveLedgerCostStatus({ + costUsd: result.costUsd, + inputTokens, + cachedInputTokens, + outputTokens, + }); const provider = result.provider ?? "unknown"; const biller = resolveLedgerBiller(result); const ledgerScope = await resolveLedgerScopeForRun(db, agent.companyId, run); @@ -10897,6 +10914,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) provider, biller, billingType, + costStatus, model: result.model ?? "unknown", inputTokens, cachedInputTokens, @@ -12967,6 +12985,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) biller: resolveLedgerBiller(adapterResult), model: readNonEmptyString(adapterResult.model) ?? "unknown", ...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}), + costStatus: resolveLedgerCostStatus({ + costUsd: adapterResult.costUsd, + inputTokens: normalizedUsage?.inputTokens ?? 0, + cachedInputTokens: normalizedUsage?.cachedInputTokens ?? 0, + outputTokens: normalizedUsage?.outputTokens ?? 0, + }), billingType: normalizeLedgerBillingType(adapterResult.billingType), } as Record) : null;