Skip to content

Commit c45124a

Browse files
committed
fix(infra): preserve top-level pricing metadata in session logs
1 parent c5260a3 commit c45124a

2 files changed

Lines changed: 155 additions & 5 deletions

File tree

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,132 @@ describe("session cost usage", () => {
372372
});
373373
});
374374

375+
it("preserves a provider-reconciled zero total with nonzero cost components", async () => {
376+
for (const pricingState of ["known", "unknown"] as const) {
377+
const root = await makeSessionCostRoot(`cost-provider-reconciled-zero-${pricingState}`);
378+
const sessionsDir = path.join(root, "agents", "main", "sessions");
379+
await fs.mkdir(sessionsDir, { recursive: true });
380+
const sessionFile = path.join(sessionsDir, `sess-openrouter-zero-${pricingState}.jsonl`);
381+
const model = pricingState === "known" ? "openai/gpt-5.5" : "retired/model";
382+
const entry = {
383+
type: "message",
384+
timestamp: "2026-02-05T12:00:00.000Z",
385+
message: {
386+
role: "assistant",
387+
provider: "openrouter",
388+
model,
389+
content: "ok",
390+
usage: {
391+
input: 1_000,
392+
output: 500,
393+
cacheRead: 0,
394+
cacheWrite: 0,
395+
totalTokens: 1_500,
396+
cost: { input: 0.001, output: 0.001, cacheRead: 0, cacheWrite: 0, total: 0 },
397+
},
398+
},
399+
};
400+
await fs.writeFile(
401+
sessionFile,
402+
transcriptText(`sess-openrouter-zero-${pricingState}`, entry),
403+
"utf-8",
404+
);
405+
406+
const config =
407+
pricingState === "known"
408+
? ({
409+
models: {
410+
providers: {
411+
openrouter: {
412+
models: [
413+
{
414+
id: model,
415+
cost: { input: 1, output: 2, cacheRead: 0.5, cacheWrite: 0 },
416+
},
417+
],
418+
},
419+
},
420+
},
421+
} as unknown as OpenClawConfig)
422+
: undefined;
423+
424+
clearGatewayModelPricingCacheState();
425+
await withStateDir(root, async () => {
426+
const summary = await loadCostUsageSummary({
427+
startMs: Date.UTC(2026, 1, 5),
428+
endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999),
429+
config,
430+
});
431+
expect(summary.totals.totalCost).toBe(0);
432+
expect(summary.totals.inputCost).toBe(0.001);
433+
expect(summary.totals.outputCost).toBe(0.001);
434+
expect(summary.totals.missingCostEntries).toBe(0);
435+
436+
await refreshCostUsageCache({ config, sessionFiles: [sessionFile] });
437+
const cached = await loadCostUsageSummaryFromCache({
438+
startMs: Date.UTC(2026, 1, 5),
439+
endMs: Date.UTC(2026, 1, 5, 23, 59, 59, 999),
440+
config,
441+
requestRefresh: false,
442+
});
443+
expect(cached.totals.totalCost).toBe(0);
444+
expect(cached.totals.missingCostEntries).toBe(0);
445+
446+
const logs = await loadSessionLogs({ sessionFile, config });
447+
expect(logs?.[0]?.cost).toBe(0);
448+
});
449+
}
450+
});
451+
452+
it("uses top-level transcript provider and model when recomputing session-log cost", async () => {
453+
const root = await makeSessionCostRoot("cost-known-pricing-top-level-metadata");
454+
const sessionsDir = path.join(root, "agents", "main", "sessions");
455+
await fs.mkdir(sessionsDir, { recursive: true });
456+
const sessionFile = path.join(sessionsDir, "sess-top-level-provider.jsonl");
457+
const timestamp = "2026-02-05T12:00:00.000Z";
458+
const entry = {
459+
type: "message",
460+
timestamp,
461+
provider: "deepseek",
462+
model: "deepseek-v4-flash",
463+
message: {
464+
role: "assistant",
465+
content: "ok",
466+
usage: {
467+
input: 10_000,
468+
output: 5_000,
469+
cacheRead: 0,
470+
cacheWrite: 0,
471+
totalTokens: 15_000,
472+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
473+
},
474+
},
475+
};
476+
await fs.writeFile(sessionFile, transcriptText("sess-top-level-provider", entry), "utf-8");
477+
478+
const config = {
479+
models: {
480+
providers: {
481+
deepseek: {
482+
models: [
483+
{
484+
id: "deepseek-v4-flash",
485+
cost: { input: 0.14, output: 0.28, cacheRead: 0.028, cacheWrite: 0 },
486+
},
487+
],
488+
},
489+
},
490+
},
491+
} as unknown as OpenClawConfig;
492+
const expectedCost = 0.0028;
493+
494+
await withStateDir(root, async () => {
495+
const logs = await loadSessionLogs({ sessionId: "sess-top-level-provider", config });
496+
expect(logs?.[0]?.tokens).toBe(15_000);
497+
expect(logs?.[0]?.cost).toBeCloseTo(expectedCost, 8);
498+
});
499+
});
500+
375501
it("treats a pre-upgrade (older-version) durable cache as stale so unpriced usage is rebuilt", async () => {
376502
const root = await makeSessionCostRoot("cost-cache-upgrade");
377503
const sessionsDir = path.join(root, "agents", "main", "sessions");

src/infra/session-cost-usage.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,12 +1153,23 @@ const isModelPricingKnown = (cost: ReturnType<typeof resolveModelCostConfig>): b
11531153
return cost.input > 0 || cost.output > 0 || cost.cacheRead > 0 || cost.cacheWrite > 0;
11541154
};
11551155

1156+
const shouldPreserveRecordedZeroCost = (costBreakdown: CostBreakdown | undefined): boolean =>
1157+
costBreakdown?.total === 0 &&
1158+
[
1159+
costBreakdown.input,
1160+
costBreakdown.output,
1161+
costBreakdown.cacheRead,
1162+
costBreakdown.cacheWrite,
1163+
].some((value) => value !== undefined && value !== 0);
1164+
11561165
const shouldRecomputeRecordedZeroCost = (params: {
11571166
cost: ReturnType<typeof resolveModelCostConfig>;
1167+
costBreakdown: CostBreakdown | undefined;
11581168
costTotal: number | undefined;
11591169
usage: NormalizedUsage;
11601170
}): boolean =>
11611171
params.costTotal === 0 &&
1172+
!shouldPreserveRecordedZeroCost(params.costBreakdown) &&
11621173
isModelPricingKnown(params.cost) &&
11631174
computeUsageTokenTotals(params.usage).totalTokens > 0;
11641175

@@ -1262,7 +1273,8 @@ async function scanTranscriptFile(params: {
12621273
});
12631274
const usageTotals = computeUsageTokenTotals(entry.usage);
12641275
const pricingKnown = isModelPricingKnown(cost);
1265-
if (cost?.tieredPricing && cost.tieredPricing.length > 0) {
1276+
const preserveRecordedZeroCost = shouldPreserveRecordedZeroCost(entry.costBreakdown);
1277+
if (cost?.tieredPricing && cost.tieredPricing.length > 0 && !preserveRecordedZeroCost) {
12661278
// When tiered pricing is configured, always recompute to override
12671279
// the flat-rate cost that the transport layer wrote into the transcript.
12681280
// Clear costBreakdown so downstream aggregation uses the recomputed total
@@ -1271,6 +1283,7 @@ async function scanTranscriptFile(params: {
12711283
entry.costBreakdown = undefined;
12721284
} else if (
12731285
!pricingKnown &&
1286+
!preserveRecordedZeroCost &&
12741287
(entry.costTotal === undefined || entry.costTotal === 0) &&
12751288
usageTotals.totalTokens > 0
12761289
) {
@@ -1285,10 +1298,16 @@ async function scanTranscriptFile(params: {
12851298
entry.costBreakdown = undefined;
12861299
} else if (
12871300
entry.costTotal === undefined ||
1288-
(pricingKnown && entry.costTotal === 0 && usageTotals.totalTokens > 0)
1301+
shouldRecomputeRecordedZeroCost({
1302+
usage: entry.usage,
1303+
cost,
1304+
costBreakdown: entry.costBreakdown,
1305+
costTotal: entry.costTotal,
1306+
})
12891307
) {
12901308
// Fill in missing estimates and override fabricated API-provided zeros
1291-
// for known-priced models such as DeepSeek V4.
1309+
// for known-priced models such as DeepSeek V4. Providers that reconcile
1310+
// only the total keep their authoritative zero when components are nonzero.
12921311
entry.costTotal = estimateUsageCost({ usage: entry.usage, cost });
12931312
entry.costBreakdown = undefined;
12941313
}
@@ -2765,14 +2784,19 @@ export async function loadSessionLogs(params: {
27652784
(usage.cacheWrite ?? 0);
27662785
const breakdown = extractCostBreakdown(usageRaw);
27672786
const costConfig = resolveCost({
2768-
provider: message.provider as string | undefined,
2769-
model: message.model as string | undefined,
2787+
provider:
2788+
(typeof message.provider === "string" ? message.provider : undefined) ??
2789+
(typeof parsed.provider === "string" ? parsed.provider : undefined),
2790+
model:
2791+
(typeof message.model === "string" ? message.model : undefined) ??
2792+
(typeof parsed.model === "string" ? parsed.model : undefined),
27702793
});
27712794
if (
27722795
breakdown?.total !== undefined &&
27732796
!shouldRecomputeRecordedZeroCost({
27742797
usage,
27752798
cost: costConfig,
2799+
costBreakdown: breakdown,
27762800
costTotal: breakdown.total,
27772801
})
27782802
) {

0 commit comments

Comments
 (0)