Skip to content

Commit d94a866

Browse files
snowzlmbotRomneyDa
authored andcommitted
fix(usage): preserve provider-billed zero totals
1 parent c7295e4 commit d94a866

7 files changed

Lines changed: 155 additions & 11 deletions

File tree

extensions/openrouter/index.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,51 @@ describe("openrouter provider hooks", () => {
846846

847847
expect(fetchMock).toHaveBeenCalledOnce();
848848
expect(message.usage.cost.total).toBe(0.0042);
849+
expect(message.usage.cost.totalOrigin).toBe("provider-billed");
850+
} finally {
851+
vi.unstubAllGlobals();
852+
}
853+
});
854+
855+
it("marks OpenRouter zero generation metadata cost as provider-billed", async () => {
856+
const provider = await registerSingleProviderPlugin(openrouterPlugin);
857+
const fetchMock = vi.fn(async (url: string) => {
858+
expect(url).toBe("https://openrouter.ai/api/v1/generation?id=gen-free-1");
859+
return new Response(JSON.stringify({ data: { total_cost: 0 } }), {
860+
headers: { "Content-Type": "application/json" },
861+
status: 200,
862+
});
863+
});
864+
vi.stubGlobal("fetch", fetchMock);
865+
const baseStreamFn = vi.fn(() =>
866+
createOpenRouterDoneStream({ responseId: "gen-free-1", totalCost: 0.001 }),
867+
);
868+
869+
try {
870+
const wrapped = provider.wrapStreamFn?.({
871+
provider: "openrouter",
872+
modelId: "openrouter/auto",
873+
streamFn: baseStreamFn,
874+
} as never);
875+
if (!wrapped) {
876+
throw new Error("expected OpenRouter wrapper");
877+
}
878+
const stream = await wrapped(
879+
{
880+
provider: "openrouter",
881+
api: "openai-completions",
882+
id: "openrouter/auto",
883+
baseUrl: "https://openrouter.ai/api/v1",
884+
compat: {},
885+
} as never,
886+
{ messages: [] } as never,
887+
{ apiKey: "or-test-key" } as never,
888+
);
889+
const message = await stream.result();
890+
891+
expect(fetchMock).toHaveBeenCalledOnce();
892+
expect(message.usage.cost.total).toBe(0);
893+
expect(message.usage.cost.totalOrigin).toBe("provider-billed");
849894
} finally {
850895
vi.unstubAllGlobals();
851896
}
@@ -897,6 +942,7 @@ describe("openrouter provider hooks", () => {
897942

898943
expect(fetchMock).toHaveBeenCalledOnce();
899944
expect(message.usage.cost.total).toBe(0.001);
945+
expect(message.usage.cost.totalOrigin).toBeUndefined();
900946
} finally {
901947
vi.unstubAllGlobals();
902948
}

extensions/openrouter/stream.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ async function applyOpenRouterBilledCost(params: {
149149
});
150150
if (totalCost !== undefined) {
151151
params.message.usage.cost.total = totalCost;
152+
params.message.usage.cost.totalOrigin = "provider-billed";
152153
}
153154
} catch (error) {
154155
log.debug?.(

packages/llm-core/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ export interface Usage {
274274
cacheRead: number;
275275
cacheWrite: number;
276276
total: number;
277+
/** Provenance for the recorded total cost; provider-billed totals are authoritative. */
278+
totalOrigin?: "provider-billed" | "estimated";
277279
};
278280
}
279281

src/agents/usage.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type ContextUsage =
99
| { state: "available"; promptTokens: number; totalTokens: number }
1010
| { state: "unavailable" };
1111

12+
export type UsageCostTotalOrigin = "provider-billed" | "estimated";
13+
1214
/** Provider/SDK usage payload variants accepted by usage normalization. */
1315
export type UsageLike = {
1416
input?: number;
@@ -50,6 +52,16 @@ export type UsageLike = {
5052
prompt_n?: number;
5153
predicted_n?: number;
5254
};
55+
// Optional cost metadata carried through transcripts for downstream cost accounting.
56+
cost?: {
57+
input?: number;
58+
output?: number;
59+
cacheRead?: number;
60+
cacheWrite?: number;
61+
total?: number;
62+
/** Provenance for the recorded total cost; provider-billed totals are authoritative. */
63+
totalOrigin?: UsageCostTotalOrigin;
64+
};
5365
};
5466

5567
/** Normalized token counts used by runtime accounting. */
@@ -86,6 +98,8 @@ export type AssistantUsageSnapshot = {
8698
cacheRead: number;
8799
cacheWrite: number;
88100
total: number;
101+
/** Provenance for the recorded total cost; provider-billed totals are authoritative. */
102+
totalOrigin?: UsageCostTotalOrigin;
89103
};
90104
};
91105

src/infra/session-cost-usage.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,80 @@ describe("session cost usage", () => {
449449
}
450450
});
451451

452+
it("preserves a provider-billed zero total when total provenance is explicit", async () => {
453+
const root = await makeSessionCostRoot("cost-provider-billed-zero-origin");
454+
const sessionsDir = path.join(root, "agents", "main", "sessions");
455+
await fs.mkdir(sessionsDir, { recursive: true });
456+
const sessionFile = path.join(sessionsDir, "sess-openrouter-billed-zero.jsonl");
457+
const entry = {
458+
type: "message",
459+
timestamp: "2026-02-05T12:00:00.000Z",
460+
message: {
461+
role: "assistant",
462+
provider: "openrouter",
463+
model: "openai/gpt-5.5",
464+
content: "provider-billed free route",
465+
usage: {
466+
input: 10_000,
467+
output: 5_000,
468+
cacheRead: 0,
469+
cacheWrite: 0,
470+
totalTokens: 15_000,
471+
cost: {
472+
input: 0,
473+
output: 0,
474+
cacheRead: 0,
475+
cacheWrite: 0,
476+
total: 0,
477+
totalOrigin: "provider-billed",
478+
},
479+
},
480+
},
481+
};
482+
await fs.writeFile(sessionFile, transcriptText("sess-openrouter-billed-zero", entry), "utf-8");
483+
484+
const config = {
485+
models: {
486+
providers: {
487+
openrouter: {
488+
models: [
489+
{
490+
id: "openai/gpt-5.5",
491+
cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 },
492+
},
493+
],
494+
},
495+
},
496+
},
497+
} as unknown as OpenClawConfig;
498+
499+
clearGatewayModelPricingCacheState();
500+
await withStateDir(root, async () => {
501+
const summary = await loadCostUsageSummary({
502+
startMs: Date.UTC(2026, 1, 5),
503+
endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999),
504+
config,
505+
});
506+
expect(summary.totals.totalTokens).toBe(15_000);
507+
expect(summary.totals.totalCost).toBe(0);
508+
expect(summary.totals.missingCostEntries).toBe(0);
509+
510+
await refreshCostUsageCache({ config, sessionFiles: [sessionFile] });
511+
const cached = await loadCostUsageSummaryFromCache({
512+
startMs: Date.UTC(2026, 1, 5),
513+
endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999),
514+
config,
515+
requestRefresh: false,
516+
});
517+
expect(cached.totals.totalCost).toBe(0);
518+
expect(cached.totals.missingCostEntries).toBe(0);
519+
520+
const logs = await loadSessionLogs({ sessionFile, config });
521+
expect(logs?.[0]?.tokens).toBe(15_000);
522+
expect(logs?.[0]?.cost).toBe(0);
523+
});
524+
});
525+
452526
it("uses top-level transcript provider and model when recomputing session-log cost", async () => {
453527
const root = await makeSessionCostRoot("cost-known-pricing-top-level-metadata");
454528
const sessionsDir = path.join(root, "agents", "main", "sessions");
@@ -529,7 +603,7 @@ describe("session cost usage", () => {
529603
await refreshCostUsageCache({ sessionFiles: [sessionFile] });
530604
const cachePath = path.join(sessionsDir, ".usage-cost-cache.json");
531605
const cache = JSON.parse(await fs.readFile(cachePath, "utf-8")) as { version: number };
532-
cache.version = 5;
606+
cache.version = 6;
533607
await fs.writeFile(cachePath, `${JSON.stringify(cache)}\n`, "utf-8");
534608

535609
// The pre-upgrade cache must be treated as stale (not served), forcing a rebuild

src/infra/session-cost-usage.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export type {
7676

7777
// Bump when the durable cache schema or the meaning of cached totals changes, so
7878
// older builds are rebuilt instead of served stale.
79-
const USAGE_COST_CACHE_VERSION = 6;
79+
const USAGE_COST_CACHE_VERSION = 7;
8080
const USAGE_COST_CACHE_FILE = ".usage-cost-cache.json";
8181
const USAGE_COST_CACHE_LOCK_WRITE_GRACE_MS = 10_000;
8282
const USAGE_COST_CACHE_TEMP_FILE_GRACE_MS = USAGE_COST_CACHE_LOCK_WRITE_GRACE_MS;
@@ -835,6 +835,9 @@ function buildSessionCostSummaryFromCacheEntry(params: {
835835
};
836836
}
837837

838+
const normalizeUsageCostTotalOrigin = (value: unknown): CostBreakdown["totalOrigin"] =>
839+
value === "provider-billed" || value === "estimated" ? value : undefined;
840+
838841
const extractCostBreakdown = (usageRaw?: UsageLike | null): CostBreakdown | undefined => {
839842
if (!usageRaw || typeof usageRaw !== "object") {
840843
return undefined;
@@ -856,6 +859,7 @@ const extractCostBreakdown = (usageRaw?: UsageLike | null): CostBreakdown | unde
856859
output: asFiniteNumber(cost.output),
857860
cacheRead: asFiniteNumber(cost.cacheRead),
858861
cacheWrite: asFiniteNumber(cost.cacheWrite),
862+
totalOrigin: normalizeUsageCostTotalOrigin(cost.totalOrigin),
859863
};
860864
};
861865

@@ -1141,12 +1145,13 @@ const isModelPricingKnown = (cost: ReturnType<typeof resolveModelCostConfig>): b
11411145

11421146
const shouldPreserveRecordedZeroCost = (costBreakdown: CostBreakdown | undefined): boolean =>
11431147
costBreakdown?.total === 0 &&
1144-
[
1145-
costBreakdown.input,
1146-
costBreakdown.output,
1147-
costBreakdown.cacheRead,
1148-
costBreakdown.cacheWrite,
1149-
].some((value) => value !== undefined && value !== 0);
1148+
(costBreakdown.totalOrigin === "provider-billed" ||
1149+
[
1150+
costBreakdown.input,
1151+
costBreakdown.output,
1152+
costBreakdown.cacheRead,
1153+
costBreakdown.cacheWrite,
1154+
].some((value) => value !== undefined && value !== 0));
11501155

11511156
const shouldRecomputeRecordedZeroCost = (params: {
11521157
cost: ReturnType<typeof resolveModelCostConfig>;
@@ -1292,8 +1297,8 @@ async function scanTranscriptFile(params: {
12921297
})
12931298
) {
12941299
// Fill in missing estimates and override fabricated API-provided zeros
1295-
// for known-priced models such as DeepSeek V4. Providers that reconcile
1296-
// only the total keep their authoritative zero when components are nonzero.
1300+
// for known-priced models such as DeepSeek V4. Providers that mark
1301+
// the total as provider-billed keep their authoritative zero total.
12971302
entry.costTotal = estimateUsageCost({ usage: entry.usage, cost });
12981303
entry.costBreakdown = undefined;
12991304
}

src/infra/session-cost-usage.types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Shared session cost and usage accounting type contracts.
2-
import type { NormalizedUsage } from "../agents/usage.js";
2+
import type { NormalizedUsage, UsageCostTotalOrigin } from "../agents/usage.js";
33
import type {
44
SessionUsageTimePoint as SharedSessionUsageTimePoint,
55
SessionUsageTimeSeries as SharedSessionUsageTimeSeries,
@@ -11,6 +11,8 @@ export type CostBreakdown = {
1111
output?: number;
1212
cacheRead?: number;
1313
cacheWrite?: number;
14+
/** Provenance for the recorded total cost; provider-billed totals are authoritative. */
15+
totalOrigin?: UsageCostTotalOrigin;
1416
};
1517

1618
export type ParsedUsageEntry = {

0 commit comments

Comments
 (0)