diff --git a/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx b/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx
index a329596ef6..2b54498922 100644
--- a/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx
+++ b/apps/web/src/app/(app)/code-reviews/[reviewId]/CodeReviewDetailClient.tsx
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageContainer } from '@/components/layouts/PageContainer';
import { CodeReviewStreamView } from '@/components/code-reviews/CodeReviewStreamView';
+import { formatTokenCount } from '@/lib/code-reviews/summary/usage-footer';
import {
ExternalLink,
GitPullRequest,
@@ -55,6 +56,10 @@ type CodeReviewDetailClientProps = {
reviewId: string;
};
+function formatAvailableTokenCount(count: number | null): string {
+ return count == null ? '—' : formatTokenCount(count);
+}
+
export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
@@ -268,12 +273,15 @@ export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps
${(review.total_cost_musd / 1_000_000).toFixed(4)}
)}
- {(review.total_tokens_in != null || review.total_tokens_out != null) && (
+ {(data.tokenUsage.input != null ||
+ data.tokenUsage.output != null ||
+ data.tokenUsage.cached != null) && (
Tokens
-
- {review.total_tokens_in?.toLocaleString() ?? '—'} in /{' '}
- {review.total_tokens_out?.toLocaleString() ?? '—'} out
+
+ Input {formatAvailableTokenCount(data.tokenUsage.input)} / Output{' '}
+ {formatAvailableTokenCount(data.tokenUsage.output)} / Cached{' '}
+ {formatAvailableTokenCount(data.tokenUsage.cached)}
)}
diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts
index 09d5cab951..a55147405b 100644
--- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts
+++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.test.ts
@@ -1317,6 +1317,8 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
model: 'anthropic/claude-sonnet-4.6',
totalTokensIn: 100_001,
totalTokensOut: 0,
+ totalCachedTokens: 0,
+ totalUncachedTokens: 100_001,
totalCostMusd: 200_000,
},
},
@@ -1326,6 +1328,8 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
model: 'anthropic/claude-sonnet-4.6',
totalTokensIn: 60_000,
totalTokensOut: 40_000,
+ totalCachedTokens: 0,
+ totalUncachedTokens: 60_000,
totalCostMusd: 200_000,
},
},
@@ -1335,6 +1339,8 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
model: 'anthropic/claude-sonnet-4.6',
totalTokensIn: 100_001,
totalTokensOut: 0,
+ totalCachedTokens: 0,
+ totalUncachedTokens: 100_001,
totalCostMusd: 199_999,
},
},
@@ -1344,6 +1350,8 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
model: 'anthropic/claude-sonnet-4.6',
totalTokensIn: 99_999,
totalTokensOut: 0,
+ totalCachedTokens: 0,
+ totalUncachedTokens: 99_999,
totalCostMusd: 200_000,
},
},
@@ -1575,6 +1583,8 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
model: 'anthropic/claude-sonnet-4.6',
totalTokensIn: 99_999,
totalTokensOut: 0,
+ totalCachedTokens: 0,
+ totalUncachedTokens: 99_999,
totalCostMusd: 200_000,
});
@@ -1614,6 +1624,8 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
model: 'anthropic/claude-sonnet-4.6',
totalTokensIn: 99_999,
totalTokensOut: 0,
+ totalCachedTokens: 0,
+ totalUncachedTokens: 99_999,
totalCostMusd: 199_999,
},
},
@@ -2762,11 +2774,21 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
repository_review_instructions_used: true,
repository_review_instructions_ref: 'main',
repository_review_instructions_truncated: false,
+ cli_session_id: 'ses_review_with_cache',
model: 'anthropic/claude-sonnet-4.6',
total_tokens_in: 1000,
total_tokens_out: 200,
+ completed_at: '2025-01-01T00:10:00Z',
});
mockGetCodeReviewById.mockResolvedValue(review);
+ mockGetSessionUsageFromBilling.mockResolvedValue({
+ model: 'openai/gpt-4o',
+ totalTokensIn: 1000,
+ totalTokensOut: 200,
+ totalCachedTokens: 800,
+ totalUncachedTokens: 200,
+ totalCostMusd: 100,
+ });
await POST(makeRequest({ status: 'completed' }), makeParams(REVIEW_ID));
@@ -2783,8 +2805,23 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
null,
{ maxBodyCharacters: 65_536, reservedCharacters: 8 }
);
+ expect(mockGetSessionUsageFromBilling).toHaveBeenCalledWith(
+ 'ses_review_with_cache',
+ '2025-01-01T00:00:00Z',
+ '2025-01-01T00:10:00Z'
+ );
+ expect(mockUpdateCodeReviewUsage).toHaveBeenCalledWith(REVIEW_ID, {
+ totalTokensIn: 1000,
+ totalTokensOut: 200,
+ totalCostMusd: 100,
+ });
expect(mockAppendReviewSummaryFooter).toHaveBeenCalledWith('existing body', {
- usage: { model: 'anthropic/claude-sonnet-4.6', tokensIn: 1000, tokensOut: 200 },
+ usage: {
+ model: 'anthropic/claude-sonnet-4.6',
+ tokensIn: 200,
+ tokensOut: 200,
+ cachedTokens: 800,
+ },
reviewGuidance: { used: true, ref: 'main', truncated: false },
});
expect(mockUpdateKiloReviewComment).toHaveBeenCalledWith(
@@ -2820,7 +2857,12 @@ describe('POST /api/internal/code-review-status/[reviewId]', () => {
'https://gitlab.com'
);
expect(mockAppendReviewSummaryFooter).toHaveBeenCalledWith('existing note body', {
- usage: { model: 'anthropic/claude-sonnet-4.6', tokensIn: 1000, tokensOut: 200 },
+ usage: {
+ model: 'anthropic/claude-sonnet-4.6',
+ tokensIn: 1000,
+ tokensOut: 200,
+ cachedTokens: null,
+ },
reviewGuidance: { used: true, ref: 'main', truncated: true },
});
expect(mockUpdateKiloReviewNote).toHaveBeenCalledWith(
diff --git a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts
index 2f7ffc0a7e..655a2798c9 100644
--- a/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts
+++ b/apps/web/src/app/api/internal/code-review-status/[reviewId]/route.ts
@@ -573,8 +573,9 @@ const BILLING_NOTICE_BODY = `${BILLING_NOTICE_MARKER}
* For v2 (cloud-agent-next) the orchestrator never writes usage — we
* skip the poll and go straight to the billing tables.
*
- * When the billing fallback is used we also back-fill the code_reviews
- * record so later reads (e.g. admin panel) don't repeat the aggregation.
+ * Billing usage is preferred when a CLI session is available because the
+ * persisted review columns do not include cache details. Raw cache-inclusive
+ * input, output, and cost totals are still back-filled for later reads.
*/
async function getReviewUsageData(reviewId: string) {
let review = await getCodeReviewById(reviewId);
@@ -591,21 +592,15 @@ async function getReviewUsageData(reviewId: string) {
}
}
- if (review?.model) {
- return {
- model: review.model,
- tokensIn: review.total_tokens_in ?? null,
- tokensOut: review.total_tokens_out ?? null,
- };
- }
-
- // Fallback: aggregate from billing tables (covers v2 / cloud-agent-next reviews)
if (review?.cli_session_id && review.created_at) {
- const billing = await getSessionUsageFromBilling(review.cli_session_id, review.created_at);
+ const billing = await getSessionUsageFromBilling(
+ review.cli_session_id,
+ review.created_at,
+ review.completed_at ?? undefined
+ );
if (billing) {
- // Back-fill the code_reviews record so we don't repeat this aggregation
updateCodeReviewUsage(reviewId, {
- model: billing.model,
+ ...(review.model == null ? { model: billing.model } : {}),
totalTokensIn: billing.totalTokensIn,
totalTokensOut: billing.totalTokensOut,
totalCostMusd: billing.totalCostMusd,
@@ -614,14 +609,24 @@ async function getReviewUsageData(reviewId: string) {
});
return {
- model: billing.model,
- tokensIn: billing.totalTokensIn,
+ model: review.model ?? billing.model,
+ tokensIn: billing.totalUncachedTokens,
tokensOut: billing.totalTokensOut,
+ cachedTokens: billing.totalCachedTokens,
};
}
}
- return { model: null, tokensIn: null, tokensOut: null };
+ if (review?.model) {
+ return {
+ model: review.model,
+ tokensIn: review.total_tokens_in ?? null,
+ tokensOut: review.total_tokens_out ?? null,
+ cachedTokens: null,
+ };
+ }
+
+ return { model: null, tokensIn: null, tokensOut: null, cachedTokens: null };
}
function getReviewGuidanceFooterData(review: CloudAgentCodeReview) {
@@ -1455,10 +1460,11 @@ export async function POST(
// Summary history and footer (completed only)
if (status === 'completed') {
- const { model, tokensIn, tokensOut } = await getReviewUsageData(reviewId);
+ const { model, tokensIn, tokensOut, cachedTokens } =
+ await getReviewUsageData(reviewId);
const usage =
model && tokensIn != null && tokensOut != null
- ? { model, tokensIn, tokensOut }
+ ? { model, tokensIn, tokensOut, cachedTokens }
: undefined;
const reviewGuidance = getReviewGuidanceFooterData(review);
const summaryFooter = { usage, reviewGuidance };
@@ -1548,10 +1554,11 @@ export async function POST(
// Summary history and footer (completed only)
if (status === 'completed') {
- const { model, tokensIn, tokensOut } = await getReviewUsageData(reviewId);
+ const { model, tokensIn, tokensOut, cachedTokens } =
+ await getReviewUsageData(reviewId);
const usage =
model && tokensIn != null && tokensOut != null
- ? { model, tokensIn, tokensOut }
+ ? { model, tokensIn, tokensOut, cachedTokens }
: undefined;
const reviewGuidance = getReviewGuidanceFooterData(review);
diff --git a/apps/web/src/lib/code-reviews/db/code-reviews.test.ts b/apps/web/src/lib/code-reviews/db/code-reviews.test.ts
index bd4e255de5..e762c2c33f 100644
--- a/apps/web/src/lib/code-reviews/db/code-reviews.test.ts
+++ b/apps/web/src/lib/code-reviews/db/code-reviews.test.ts
@@ -3,9 +3,11 @@ import {
cloud_agent_code_review_attempts,
cloud_agent_code_reviews,
kilocode_users,
+ microdollar_usage,
+ microdollar_usage_metadata,
platform_integrations,
} from '@kilocode/db/schema';
-import { eq } from 'drizzle-orm';
+import { eq, inArray } from 'drizzle-orm';
import { insertTestUser } from '@/tests/helpers/user.helper';
import type { User } from '@kilocode/db/schema';
import {
@@ -14,6 +16,7 @@ import {
createCodeReviewAttempt,
createInfraRetryAttemptIfMissing,
getCodeReviewAttemptForReview,
+ getSessionUsageFromBilling,
listCodeReviewAttempts,
updateCodeReviewAttemptForCallback,
findPreviousCompletedReview,
@@ -618,3 +621,74 @@ describe('findPreviousCompletedReview', () => {
).rejects.toThrow('not found');
});
});
+
+describe('getSessionUsageFromBilling', () => {
+ const usageIds: string[] = [];
+
+ afterEach(async () => {
+ if (usageIds.length === 0) return;
+
+ await db
+ .delete(microdollar_usage_metadata)
+ .where(inArray(microdollar_usage_metadata.id, usageIds));
+ await db.delete(microdollar_usage).where(inArray(microdollar_usage.id, usageIds));
+ usageIds.length = 0;
+ });
+
+ it('excludes later usage when a completed review session is reused', async () => {
+ const sessionId = `ses_usage_window_${crypto.randomUUID()}`;
+ const firstUsageId = crypto.randomUUID();
+ const laterUsageId = crypto.randomUUID();
+ usageIds.push(firstUsageId, laterUsageId);
+
+ await db.insert(microdollar_usage).values([
+ {
+ id: firstUsageId,
+ kilo_user_id: 'code-review-usage-test',
+ cost: 100,
+ input_tokens: 1000,
+ output_tokens: 100,
+ cache_write_tokens: 100,
+ cache_hit_tokens: 600,
+ created_at: '2026-06-18T10:00:00.000Z',
+ model: 'anthropic/claude-sonnet-4.6',
+ },
+ {
+ id: laterUsageId,
+ kilo_user_id: 'code-review-usage-test',
+ cost: 200,
+ input_tokens: 2000,
+ output_tokens: 200,
+ cache_write_tokens: 200,
+ cache_hit_tokens: 1200,
+ created_at: '2026-06-18T12:00:00.000Z',
+ model: 'openai/gpt-4o',
+ },
+ ]);
+ await db.insert(microdollar_usage_metadata).values([
+ {
+ id: firstUsageId,
+ message_id: `msg_${firstUsageId}`,
+ session_id: sessionId,
+ created_at: '2026-06-18T10:00:00.000Z',
+ },
+ {
+ id: laterUsageId,
+ message_id: `msg_${laterUsageId}`,
+ session_id: sessionId,
+ created_at: '2026-06-18T12:00:00.000Z',
+ },
+ ]);
+
+ await expect(
+ getSessionUsageFromBilling(sessionId, '2026-06-18T09:00:00.000Z', '2026-06-18T11:00:00.000Z')
+ ).resolves.toEqual({
+ model: 'anthropic/claude-sonnet-4.6',
+ totalTokensIn: 1000,
+ totalTokensOut: 100,
+ totalCachedTokens: 700,
+ totalUncachedTokens: 300,
+ totalCostMusd: 100,
+ });
+ });
+});
diff --git a/apps/web/src/lib/code-reviews/db/code-reviews.ts b/apps/web/src/lib/code-reviews/db/code-reviews.ts
index ccc8af43b0..fc3a399e61 100644
--- a/apps/web/src/lib/code-reviews/db/code-reviews.ts
+++ b/apps/web/src/lib/code-reviews/db/code-reviews.ts
@@ -13,7 +13,7 @@ import {
microdollar_usage,
microdollar_usage_metadata,
} from '@kilocode/db/schema';
-import { eq, and, asc, desc, count, ne, inArray, sql, sum, gte, isNull } from 'drizzle-orm';
+import { eq, and, asc, desc, count, ne, inArray, sql, sum, gte, lte, isNull } from 'drizzle-orm';
import { captureException } from '@sentry/nextjs';
import type { CreateReviewParams, CodeReviewStatus, ListReviewsParams, Owner } from '../core';
import type { CloudAgentCodeReview, CloudAgentCodeReviewAttempt } from '@kilocode/db/schema';
@@ -1631,6 +1631,8 @@ export type SessionUsageSummary = {
model: string;
totalTokensIn: number;
totalTokensOut: number;
+ totalCachedTokens: number;
+ totalUncachedTokens: number;
totalCostMusd: number;
};
@@ -1642,19 +1644,22 @@ export type SessionUsageSummary = {
* system (processUsage → microdollar_usage) already records per-request
* usage keyed by session_id, so we aggregate here.
*
- * The `reviewCreatedAt` lower bound lets Postgres use the existing
+ * The review time bounds let Postgres use the existing
* `idx_microdollar_usage_metadata_created_at` index instead of seq-scanning
- * the full table (~469 M rows). Billing rows cannot exist before the review.
+ * the full table (~469 M rows). The upper bound prevents later reviews that
+ * continue the same session from changing a completed review's totals.
*/
export async function getSessionUsageFromBilling(
cliSessionId: string,
- reviewCreatedAt: string
+ reviewCreatedAt: string,
+ reviewCompletedAt?: string
): Promise {
try {
const joinCondition = eq(microdollar_usage.id, microdollar_usage_metadata.id);
const sessionFilter = and(
eq(microdollar_usage_metadata.session_id, cliSessionId),
- gte(microdollar_usage_metadata.created_at, reviewCreatedAt)
+ gte(microdollar_usage_metadata.created_at, reviewCreatedAt),
+ reviewCompletedAt ? lte(microdollar_usage_metadata.created_at, reviewCompletedAt) : undefined
);
// 1. Session-wide totals (all models combined)
@@ -1662,6 +1667,8 @@ export async function getSessionUsageFromBilling(
.select({
totalTokensIn: sum(microdollar_usage.input_tokens).mapWith(Number),
totalTokensOut: sum(microdollar_usage.output_tokens).mapWith(Number),
+ totalCacheHitTokens: sum(microdollar_usage.cache_hit_tokens).mapWith(Number),
+ totalCacheWriteTokens: sum(microdollar_usage.cache_write_tokens).mapWith(Number),
totalCostMusd: sum(microdollar_usage.cost).mapWith(Number),
})
.from(microdollar_usage)
@@ -1684,10 +1691,15 @@ export async function getSessionUsageFromBilling(
if (!topModel?.model) return null;
+ const totalCachedTokens =
+ (totals.totalCacheHitTokens ?? 0) + (totals.totalCacheWriteTokens ?? 0);
+
return {
model: topModel.model,
totalTokensIn: totals.totalTokensIn,
totalTokensOut: totals.totalTokensOut ?? 0,
+ totalCachedTokens,
+ totalUncachedTokens: Math.max(0, totals.totalTokensIn - totalCachedTokens),
totalCostMusd: totals.totalCostMusd ?? 0,
};
} catch (error) {
diff --git a/apps/web/src/lib/code-reviews/summary/usage-footer.test.ts b/apps/web/src/lib/code-reviews/summary/usage-footer.test.ts
index d9323981c9..55a5f5583a 100644
--- a/apps/web/src/lib/code-reviews/summary/usage-footer.test.ts
+++ b/apps/web/src/lib/code-reviews/summary/usage-footer.test.ts
@@ -4,29 +4,43 @@ import {
buildReviewGuidanceFooter,
buildReviewSummaryFooter,
buildUsageFooter,
+ formatTokenCount,
stripReviewSummaryFooter,
} from './usage-footer';
import { REVIEW_SUMMARY_HISTORY_END, REVIEW_SUMMARY_HISTORY_START } from './history';
+describe('formatTokenCount', () => {
+ it.each([
+ [999, '999'],
+ [1100, '1.1K'],
+ [1_000_000, '1M'],
+ ])('formats %i as %s', (count, expected) => {
+ expect(formatTokenCount(count)).toBe(expected);
+ });
+});
+
describe('buildUsageFooter', () => {
it('strips provider prefix from model slug', () => {
- const footer = buildUsageFooter('anthropic/claude-sonnet-4.6', 1000, 200);
+ const footer = buildUsageFooter('anthropic/claude-sonnet-4.6', 1000, 200, 300);
expect(footer).toContain('claude-sonnet-4.6');
expect(footer).not.toContain('anthropic/');
});
it('keeps model name as-is when no provider prefix', () => {
- const footer = buildUsageFooter('gpt-4o', 500, 100);
+ const footer = buildUsageFooter('gpt-4o', 500, 100, null);
expect(footer).toContain('gpt-4o');
});
- it('sums input and output tokens', () => {
- const footer = buildUsageFooter('model', 10000, 2345);
- expect(footer).toContain('12,345 tokens');
+ it('renders the exact split token usage', () => {
+ const footer = buildUsageFooter('provider/minimax-m3', 64_730, 5_400, 919_981);
+
+ expect(footer).toBe(
+ '\nReviewed by minimax-m3 · Input: 64.7K · Output: 5.4K · Cached: 920K'
+ );
});
it('includes usage marker comment', () => {
- const footer = buildUsageFooter('model', 1, 2);
+ const footer = buildUsageFooter('model', 1, 2, 3);
expect(footer).toContain('');
});
});
@@ -61,7 +75,12 @@ describe('buildReviewGuidanceFooter', () => {
describe('buildReviewSummaryFooter', () => {
it('returns the exact suffix appended to the summary body', () => {
const footerData = {
- usage: { model: 'anthropic/claude-sonnet-4.6', tokensIn: 5000, tokensOut: 1000 },
+ usage: {
+ model: 'anthropic/claude-sonnet-4.6',
+ tokensIn: 5000,
+ tokensOut: 1000,
+ cachedTokens: 2000,
+ },
reviewGuidance: { used: true, ref: 'main', truncated: false },
};
const footer = buildReviewSummaryFooter(footerData);
@@ -79,12 +98,17 @@ describe('appendReviewSummaryFooter', () => {
it('appends usage and guidance in one footer block', () => {
const body = '## Code Review Summary\n\nLooks good!';
const result = appendReviewSummaryFooter(body, {
- usage: { model: 'anthropic/claude-sonnet-4.6', tokensIn: 5000, tokensOut: 1000 },
+ usage: {
+ model: 'anthropic/claude-sonnet-4.6',
+ tokensIn: 5000,
+ tokensOut: 1000,
+ cachedTokens: 2000,
+ },
reviewGuidance: { used: true, ref: 'main', truncated: false },
});
expect(result).toMatch(/^## Code Review Summary\n\nLooks good!\n\n---\n/);
- expect(result).toContain('6,000 tokens');
+ expect(result).toContain('Input: 5K · Output: 1K · Cached: 2K');
expect(result).toContain('');
expect(result).toContain('Review guidance: REVIEW.md from base branch `main`');
expect(result.match(/^---$/gm)?.length).toBe(1);
@@ -103,12 +127,12 @@ describe('appendReviewSummaryFooter', () => {
'Review guidance: REVIEW.md from base branch `develop`',
].join('\n');
const result = appendReviewSummaryFooter(body, {
- usage: { model: 'new/new-model', tokensIn: 2000, tokensOut: 500 },
+ usage: { model: 'new/new-model', tokensIn: 2000, tokensOut: 500, cachedTokens: 1500 },
reviewGuidance: { used: true, ref: 'main', truncated: true },
});
expect(result).toContain('new-model');
- expect(result).toContain('2,500 tokens');
+ expect(result).toContain('Input: 2K · Output: 500 · Cached: 1.5K');
expect(result).toContain('`main` (truncated)');
expect(result).not.toContain('old-model');
expect(result).not.toContain('develop');
@@ -129,7 +153,7 @@ describe('appendReviewSummaryFooter', () => {
it('preserves unrelated horizontal rules in the body', () => {
const body = '## Summary\n\n---\n\nSome section\n\nMore content';
const result = appendReviewSummaryFooter(body, {
- usage: { model: 'x/m', tokensIn: 1, tokensOut: 1 },
+ usage: { model: 'x/m', tokensIn: 1, tokensOut: 1, cachedTokens: null },
});
expect(result).toContain('## Summary\n\n---\n\nSome section\n\nMore content');
@@ -187,7 +211,7 @@ describe('appendReviewSummaryFooter', () => {
REVIEW_SUMMARY_HISTORY_END,
].join('\n');
const result = appendReviewSummaryFooter(body, {
- usage: { model: 'x/m', tokensIn: 1, tokensOut: 2 },
+ usage: { model: 'x/m', tokensIn: 1, tokensOut: 2, cachedTokens: 3 },
});
expect(result).toContain(REVIEW_SUMMARY_HISTORY_START);
@@ -204,7 +228,7 @@ describe('appendUsageFooter', () => {
const result = appendUsageFooter('body', 'provider/org/model-name', 100, 200);
expect(result).toContain('org/model-name');
- expect(result).toContain('300 tokens');
+ expect(result).toContain('Input: 100 · Output: 200 · Cached: —');
expect(result).toContain('');
});
});
diff --git a/apps/web/src/lib/code-reviews/summary/usage-footer.ts b/apps/web/src/lib/code-reviews/summary/usage-footer.ts
index f4b7eb68ca..e17cfdb262 100644
--- a/apps/web/src/lib/code-reviews/summary/usage-footer.ts
+++ b/apps/web/src/lib/code-reviews/summary/usage-footer.ts
@@ -10,6 +10,7 @@ type UsageFooterData = {
model: string;
tokensIn: number;
tokensOut: number;
+ cachedTokens: number | null;
};
type ReviewGuidanceFooterData = {
@@ -27,21 +28,24 @@ function formatModelName(modelSlug: string): string {
return parts.length > 1 ? parts.slice(1).join('/') : modelSlug;
}
-/**
- * Format a token count with thousands separators
- */
-function formatTokenCount(count: number): string {
- return count.toLocaleString('en-US');
+const tokenCountFormatter = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+});
+
+export function formatTokenCount(count: number): string {
+ return tokenCountFormatter.format(count);
}
-/**
- * Build the usage footer line
- * e.g., "Model: claude-sonnet-4.6 · Tokens: 12,345 in, 1,234 out"
- */
-export function buildUsageFooter(model: string, tokensIn: number, tokensOut: number): string {
+export function buildUsageFooter(
+ model: string,
+ tokensIn: number,
+ tokensOut: number,
+ cachedTokens: number | null
+): string {
const displayModel = formatModelName(model);
- const totalTokens = formatTokenCount(tokensIn + tokensOut);
- return `${USAGE_FOOTER_MARKER}\nReviewed by ${displayModel} · ${totalTokens} tokens`;
+ const cached = cachedTokens == null ? '—' : formatTokenCount(cachedTokens);
+ return `${USAGE_FOOTER_MARKER}\nReviewed by ${displayModel} · Input: ${formatTokenCount(tokensIn)} · Output: ${formatTokenCount(tokensOut)} · Cached: ${cached}`;
}
export function buildReviewGuidanceFooter(guidance: ReviewGuidanceFooterData): string {
@@ -59,7 +63,12 @@ export function buildReviewSummaryFooter(footer: {
if (footer.usage) {
footerLines.push(
- buildUsageFooter(footer.usage.model, footer.usage.tokensIn, footer.usage.tokensOut)
+ buildUsageFooter(
+ footer.usage.model,
+ footer.usage.tokensIn,
+ footer.usage.tokensOut,
+ footer.usage.cachedTokens
+ )
);
}
@@ -106,7 +115,9 @@ export function appendUsageFooter(
tokensIn: number,
tokensOut: number
): string {
- return appendReviewSummaryFooter(existingBody, { usage: { model, tokensIn, tokensOut } });
+ return appendReviewSummaryFooter(existingBody, {
+ usage: { model, tokensIn, tokensOut, cachedTokens: null },
+ });
}
function findBackendFooterStart(body: string, markerIdx: number): number | null {
diff --git a/apps/web/src/routers/code-reviews-router.test.ts b/apps/web/src/routers/code-reviews-router.test.ts
index f7b13cbbe4..117b071823 100644
--- a/apps/web/src/routers/code-reviews-router.test.ts
+++ b/apps/web/src/routers/code-reviews-router.test.ts
@@ -552,14 +552,19 @@ describe('codeReviewRouter attempts', () => {
status: 'failed',
error_message: 'Container shutdown: SIGTERM',
terminal_reason: 'sandbox_error',
+ total_tokens_in: 1200,
+ total_tokens_out: 300,
})
)
.returning({ id: cloud_agent_code_reviews.id });
const caller = await createCallerForUser(testUser.id);
const before = await caller.codeReviews.get({ reviewId: review.id });
- expect(before.success).toBe(true);
- expect(before.success ? before.attempts : []).toEqual([]);
+ if (!before.success) {
+ throw new Error('Expected successful code review get');
+ }
+ expect(before.attempts).toEqual([]);
+ expect(before.tokenUsage).toEqual({ input: 1200, output: 300, cached: null });
await caller.codeReviews.retrigger({ reviewId: review.id });
diff --git a/apps/web/src/routers/code-reviews/code-reviews-router.ts b/apps/web/src/routers/code-reviews/code-reviews-router.ts
index fccfa5448b..d95dbec2d9 100644
--- a/apps/web/src/routers/code-reviews/code-reviews-router.ts
+++ b/apps/web/src/routers/code-reviews/code-reviews-router.ts
@@ -25,6 +25,7 @@ import {
ensureCurrentCodeReviewAttemptFromReview,
createCodeReviewAttempt,
getLatestCodeReviewAttempt,
+ getSessionUsageFromBilling,
} from '@/lib/code-reviews/db/code-reviews';
import { getIntegrationById } from '@/lib/integrations/db/platform-integrations';
import { createCheckRun, updateCheckRun } from '@/lib/integrations/platforms/github/adapter';
@@ -329,9 +330,33 @@ export const codeReviewRouter = createTRPCRouter({
});
}
- const attempts = await listCodeReviewAttempts(input.reviewId);
+ const cliSessionId = review.cli_session_id;
+ const shouldLoadBillingUsage =
+ ['completed', 'failed', 'cancelled', 'interrupted'].includes(review.status) &&
+ cliSessionId !== null;
+ const [attempts, billingUsage] = await Promise.all([
+ listCodeReviewAttempts(input.reviewId),
+ shouldLoadBillingUsage
+ ? getSessionUsageFromBilling(
+ cliSessionId,
+ review.created_at,
+ review.completed_at ?? undefined
+ )
+ : Promise.resolve(null),
+ ]);
+ const tokenUsage = billingUsage
+ ? {
+ input: billingUsage.totalUncachedTokens,
+ output: billingUsage.totalTokensOut,
+ cached: billingUsage.totalCachedTokens,
+ }
+ : {
+ input: review.total_tokens_in ?? null,
+ output: review.total_tokens_out ?? null,
+ cached: null,
+ };
- return successResult({ review, attempts });
+ return successResult({ review, attempts, tokenUsage });
} catch (error) {
if (error instanceof TRPCError) {
throw error;