diff --git a/packages/shared/src/types/cost.ts b/packages/shared/src/types/cost.ts index bdf1f818dd0..20cbc1b6cc6 100644 --- a/packages/shared/src/types/cost.ts +++ b/packages/shared/src/types/cost.ts @@ -119,6 +119,8 @@ export interface CostByAgent { /** Ledger events in this group and selected date range, not distinct runs. */ eventCount: number; estimatedEventCount: number; + /** Events with usage but no known price; their cost is missing from costCents, not zero. */ + unpricedEventCount: number; costCents: number; costCentsExact?: string; inputTokens: number; @@ -183,6 +185,8 @@ export interface CostByAgentModel { /** Ledger events in this group and selected date range, not distinct runs. */ eventCount: number; estimatedEventCount: number; + /** Events with usage but no known price; their cost is missing from costCents, not zero. */ + unpricedEventCount: number; costCents: number; costCentsExact?: string; inputTokens: number; @@ -212,6 +216,8 @@ export interface CostByProject { /** Ledger events in this project and selected date range, not distinct runs. */ eventCount: number; estimatedEventCount: number; + /** Events with usage but no known price; their cost is missing from costCents, not zero. */ + unpricedEventCount: number; costCents: number; costCentsExact?: string; inputTokens: number; diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 06a393eadf1..57291eaad50 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -483,6 +483,35 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { cachedInputTokens: 2_632_998, outputTokens: 32_644 }); }); + it("marks metered usage with no known price as unpriced in the agent, model and project reports", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = 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: "Morgan", role: "engineer", status: "active", + adapterType: "opencode_local", adapterConfig: {}, runtimeConfig: {}, permissions: {}, + }); + await db.insert(projects).values({ id: projectId, companyId, name: "Launch" }); + const base = { agentId, projectId, provider: "deepseek", biller: "openrouter", billingType: "metered_api" as const, + inputTokens: 100, cachedInputTokens: 0, outputTokens: 10, occurredAt: new Date() }; + await costs.createEvent(companyId, { ...base, model: "priced-model", costStatus: "reported", costCents: 137 }); + await costs.createEvent(companyId, { ...base, model: "unpriced-model", costStatus: "unpriced", costCents: 0 }); + + const [byAgentRow] = await costs.byAgent(companyId); + expect(byAgentRow).toMatchObject({ agentId, costCents: 137, eventCount: 2, unpricedEventCount: 1 }); + const modelRows = await costs.byAgentModel(companyId); + expect(modelRows.find(row => row.model === "unpriced-model")).toMatchObject({ costCents: 0, unpricedEventCount: 1 }); + expect(modelRows.find(row => row.model === "priced-model")).toMatchObject({ costCents: 137, unpricedEventCount: 0 }); + const [byProjectRow] = await costs.byProject(companyId); + expect(byProjectRow).toMatchObject({ projectId, costCents: 137, eventCount: 2, unpricedEventCount: 1 }); + }); + 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__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 862587653c3..70f02e50c45 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -334,6 +334,18 @@ function loadSpecRoutes() { } describe("openapi routes", () => { + it("tells agents that unpriced usage in cost reports is unknown cost, not zero", () => { + const paths = buildOpenApiSpec().paths; + for (const segment of ["summary", "by-agent", "by-agent-model", "by-project"]) { + const description = paths[`/api/companies/{companyId}/costs/${segment}`].get.description as string; + expect(description).toContain("unpricedEventCount is above 0"); + expect(description).toContain("never as $0.00"); + expect(description).toContain("GET /api/companies/{companyId}/heartbeat-runs?agentId="); + expect(description).not.toContain("costs/data-quality"); + } + expect(paths["/api/companies/{companyId}/costs/window-spend"].get.description).toBeUndefined(); + }); + it("tells agents managed-agent profiles are created with the CLI, not a web page", () => { const route = buildOpenApiSpec().paths["/api/companies/{companyId}/managed-agent-profiles"]; for (const operation of [route.get, route.post]) { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index eda397c295c..261434dd520 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6099,12 +6099,17 @@ const costSummaryPaths = [ "data-quality", ] as const; +/** Reports whose rows count usage that has no known price. */ +const UNPRICED_COUNT_COST_REPORTS = new Set(["summary", "by-agent", "by-user", "by-agent-model", "by-provider", "by-biller", "by-project"]); +export const UNPRICED_COST_REPORT_DESCRIPTION = "costCents counts priced usage only. A row whose unpricedEventCount is above 0 includes usage with no known price, so its real cost is unknown, not zero: report that usage as unpriced and its total as incomplete, never as $0.00. To find that usage, list the agent's runs with GET /api/companies/{companyId}/heartbeat-runs?agentId=: a run whose usageJson.costStatus is \"unpriced\" is one, and its usageJson.ledgerScope.issueId names its task. Link each run you cite at //agents//runs/, or its task."; + for (const segment of costSummaryPaths) { registry.registerPath({ method: "get", path: `/api/companies/{companyId}/costs/${segment}`, tags: ["costs"], summary: `Cost report: ${segment}`, + ...(UNPRICED_COUNT_COST_REPORTS.has(segment) ? { description: UNPRICED_COST_REPORT_DESCRIPTION } : {}), request: { params: z.object({ companyId: z.string() }), ...(!["window-spend", "quota-windows"].includes(segment) ? { diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 8a47c53efa8..f3f3da55cb1 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -428,6 +428,7 @@ export function costService(db: Db, _budgetHooks: BudgetServiceHooks = {}) { agentStatus: agents.status, eventCount: sql`count(*)::int`, estimatedEventCount: sql`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`, + unpricedEventCount: sql`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`, costCents: sumAsNumber(costEvents.costCents), costCentsExact: sql`coalesce(sum(${costEvents.costCents}), 0)::text`, inputTokens: sumAsNumber(ordinaryInputTokens), @@ -603,6 +604,7 @@ export function costService(db: Db, _budgetHooks: BudgetServiceHooks = {}) { model: costEvents.model, eventCount: sql`count(*)::int`, estimatedEventCount: sql`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`, + unpricedEventCount: sql`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`, costCents: sumAsNumber(costEvents.costCents), costCentsExact: sql`coalesce(sum(${costEvents.costCents}), 0)::text`, inputTokens: sumAsNumber(ordinaryInputTokens), @@ -669,6 +671,7 @@ export function costService(db: Db, _budgetHooks: BudgetServiceHooks = {}) { projectName: projects.name, eventCount: sql`count(*)::int`, estimatedEventCount: sql`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`, + unpricedEventCount: sql`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`, costCents: costCentsExpr, costCentsExact: sql`coalesce(sum(${costEvents.costCents}), 0)::text`, inputTokens: sumAsNumber(ordinaryInputTokens), diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index 6aead32b936..28b9b17be8c 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -379,7 +379,7 @@ "packages/shared/src/types/company-skill.ts": "259ee322e01e0c97532a6d5ff2e9753e3c1de460", "packages/shared/src/types/company.ts": "40b5858ebabe3df694af955bd68d2222069a3993", "packages/shared/src/types/connection-intent.ts": "883a9ea5d509780422a668906be058acf8c0eaeb", - "packages/shared/src/types/cost.ts": "bdf1f818dd06b7fd029f3f004b023be3e1755a56", + "packages/shared/src/types/cost.ts": "20cbc1b6cc6c1a45643d3b25e5d66944c8ec2aba", "packages/shared/src/types/dashboard.ts": "cdeaebdb7774b3b039dc5199a5ed8bc6cf32ce40", "packages/shared/src/types/decision-queue.ts": "029b8c252913700287892b277d5353f00d2ef484", "packages/shared/src/types/decision-training.ts": "23a6485b82e1cd1125ec2002285e0a29cd73d485", diff --git a/ui/src/pages/AgentAuditTab.test.ts b/ui/src/pages/AgentAuditTab.test.ts index 55f354211aa..55d2d3b2494 100644 --- a/ui/src/pages/AgentAuditTab.test.ts +++ b/ui/src/pages/AgentAuditTab.test.ts @@ -19,6 +19,7 @@ function costRow(agentId: string | null, costCents: number): CostByAgent { agentStatus: "active", eventCount: 1, estimatedEventCount: 0, + unpricedEventCount: 0, costCents, inputTokens: 10, cachedInputTokens: 0, @@ -41,6 +42,7 @@ function modelRow(agentId: string, model: string, costCents: number): CostByAgen model, eventCount: 1, estimatedEventCount: 0, + unpricedEventCount: 0, costCents, inputTokens: 1, cachedInputTokens: 0,