fix(costs): mark unpriced usage in the agent, model and project cost reports (#20889)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Cost reports show what agents and projects spent
> - A run can have usage with no known price; Paperclip saves it with
costStatus "unpriced" and costCents 0
> - The summary and the provider, biller and user reports count those
events, but the agent, agent-model and project reports did not
> - An agent that read the by-agent-model report saw a native run at
costCents 0 and told the person it cost $0.00
> - This pull request adds unpricedEventCount to those three reports and
tells agents what it means
> - The benefit is that missing charges are reported as unpriced, not as
free

## Linked Issues or Issue Description

**What happened**
In the chat eval budgets-costs.spend-breakdown, the person asked "How
much did Launch cost this week, broken down by agent?". The week
included a real native run whose usage had no known price (data-quality
reason missing_price). The agent read /costs/by-agent-model and
/costs/by-project. Both returned costCents 0 for that usage and no
unpriced count. The reply showed "1 native run ($0.00)" and gave the
total as complete.

**Expected behavior**
The reports say which rows include unpriced usage. The agent says that
usage is unpriced, marks the total incomplete, and links the record.

**Steps to reproduce**
1. Record one priced and one unpriced metered cost event for an agent in
a project.
2. Call GET /api/companies/{companyId}/costs/by-agent, /by-agent-model
and /by-project.
3. See costCents counts only the priced event, and no field shows the
unpriced event.

## What Changed

- `costService.byAgent`, `byAgentModel` and `byProject` return
`unpricedEventCount` (same filter as the summary: unpriced and not
subscription_included).
- Shared types `CostByAgent`, `CostByAgentModel` and `CostByProject` add
`unpricedEventCount`.
- The OpenAPI catalog entries for the cost reports that count unpriced
usage now say that costCents counts priced usage only, that unpriced
usage must be reported as unpriced (never $0.00), and that
/costs/data-quality lists those runs.
- Tests: embedded-Postgres service test (red on master, green here) and
an OpenAPI description test. A UI test fixture adds the new field.

## Verification

- `npx vitest run src/__tests__/costs-service.test.ts` (19 passed). The
new test fails with the master service.
- `npx vitest run src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api.test.ts` (2146 passed).
- `tsc --noEmit` for packages/shared and server.

## Risks

- Low risk. One added aggregate column per report and an added response
field. The UI does not read it yet.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-11 13:36:03 -06:00
1 parent 77757a3a93
commit 6dc36700b1
7 files changed
+58 -1

No files matched your search

+6
View File
@@ -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;
@@ -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();
@@ -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=<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]) {
+5
View File
@@ -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<string>(["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=<agentId>: a run whose usageJson.costStatus is \"unpriced\" is one, and its usageJson.ledgerScope.issueId names its task. Link each run you cite at /<prefix>/agents/<agentId>/runs/<runId>, 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) ? {
+3
View File
@@ -428,6 +428,7 @@ export function costService(db: Db, _budgetHooks: BudgetServiceHooks = {}) {
agentStatus: agents.status,
eventCount: sql<number>`count(*)::int`,
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
unpricedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`,
costCents: sumAsNumber(costEvents.costCents),
costCentsExact: sql<string>`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<number>`count(*)::int`,
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
unpricedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`,
costCents: sumAsNumber(costEvents.costCents),
costCentsExact: sql<string>`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<number>`count(*)::int`,
estimatedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'estimated')::int`,
unpricedEventCount: sql<number>`count(*) filter (where ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included')::int`,
costCents: costCentsExpr,
costCentsExact: sql<string>`coalesce(sum(${costEvents.costCents}), 0)::text`,
inputTokens: sumAsNumber(ordinaryInputTokens),
@@ -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",
+2
View File
@@ -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,