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;