diff --git a/src/__tests__/utils/receipt.test.ts b/src/__tests__/utils/receipt.test.ts index 4eb212e411..5e4615ada1 100644 --- a/src/__tests__/utils/receipt.test.ts +++ b/src/__tests__/utils/receipt.test.ts @@ -139,3 +139,107 @@ it('retains a valid zero alongside invalid costs', () => { separatelyBilledCredits: 0, }); }); + +const enrichmentResponse = ( + cost: unknown, + complete: unknown = true, + overrides = {} +) => ({ + data: { + creditsCost: 0, + alexandria: [ + { + provider: 'firecrawl', + capability: 'enrich', + data: { + status: 'matched', + providerCredits: cost, + billingComplete: complete, + steps: [{ creditsCost: cost }], + }, + ...overrides, + }, + ], + }, +}); +it('shows enrichment provider costs once while retaining the zero outer charge', () => { + const receipt = receiptFor(enrichmentResponse(30), 'scrape'); + expect(receipt).toEqual({ + creditsUsed: 0, + separatelyBilledCredits: 30, + providerCostsComplete: true, + }); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + printReceipt(receipt); + expect(log).toHaveBeenCalledWith( + 'Credits: 30 (0 outer request + 30 separately billed provider calls)' + ); + expect(log).toHaveBeenCalledTimes(1); + } finally { + log.mockRestore(); + } +}); +it('labels partial enrichment costs without claiming they are the final total', () => { + const receipt = receiptFor(enrichmentResponse(5, false), 'scrape'); + expect(receipt).toMatchObject({ + separatelyBilledCredits: 5, + providerCostsComplete: false, + }); + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + printReceipt(receipt); + expect(log).toHaveBeenCalledWith( + 'Known credits: 5 (0 outer request + 5 separately billed provider calls)' + ); + expect(log).toHaveBeenCalledWith( + 'Provider costs are incomplete; additional credits may have been incurred.' + ); + } finally { + log.mockRestore(); + } +}); +it('preserves confirmed zero-cost enrichment', () => { + expect(receiptFor(enrichmentResponse(0), 'scrape')).toEqual({ + creditsUsed: 0, + separatelyBilledCredits: 0, + providerCostsComplete: true, + }); +}); +it.each([undefined, -1, NaN, Infinity, '30'])( + 'does not present malformed enrichment costs as complete: %s', + (cost) => { + expect(receiptFor(enrichmentResponse(cost), 'scrape')).toEqual({ + creditsUsed: 0, + providerCostsComplete: false, + }); + } +); +it.each([ + { provider: 'other' }, + { capability: 'bash' }, + { error: { code: 'unauthorized' } }, +])('ignores unrelated or rejected enrichment-like data: %j', (overrides) => { + expect(receiptFor(enrichmentResponse(30, true, overrides), 'scrape')).toEqual( + { creditsUsed: 0 } + ); +}); +it('preserves separate SQL and enrichment costs without changing search receipts', () => { + const value = { + data: { + creditsCost: 0, + alexandria: [ + ...sqlResponse(10).data.alexandria, + ...enrichmentResponse(30).data.alexandria, + ], + }, + }; + expect(receiptFor(value, 'scrape')).toEqual({ + creditsUsed: 0, + separatelyBilledCredits: 40, + providerCostsComplete: true, + }); + expect(receiptFor({ ...value, creditsUsed: 2 }, 'search')).toEqual({ + creditsUsed: 2, + }); +}); diff --git a/src/utils/receipt.ts b/src/utils/receipt.ts index 89f996621f..5f5e70fc55 100644 --- a/src/utils/receipt.ts +++ b/src/utils/receipt.ts @@ -1,6 +1,7 @@ export interface Receipt { creditsUsed?: number; separatelyBilledCredits?: number; + providerCostsComplete?: boolean; requestId?: string; operationId?: string; operationType?: 'scrape' | 'search'; @@ -22,27 +23,42 @@ export function receiptFor( ? value?.id : (value?.metadata?.scrapeId ?? value?.scrape_id ?? value?.scrapeId); const entries = value?.data?.alexandria; - const sqlCosts = + const providerResults = operationType === 'scrape' && Array.isArray(entries) - ? entries - .filter( - (entry: any) => - entry?.provider === 'firecrawl' && - entry?.capability === 'sql' && - !entry.error && - entry?.data?.kind === 'result' - ) - .map((entry: any) => entry.data.creditsCost) + ? entries.filter( + (entry: any) => entry?.provider === 'firecrawl' && !entry.error + ) : []; - const validSqlCosts = sqlCosts.filter( - (cost: unknown): cost is number => - typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 + const enrichmentResults = providerResults.filter( + (entry: any) => entry.capability === 'enrich' && entry.data ); + const validCost = (cost: unknown): cost is number => + typeof cost === 'number' && Number.isFinite(cost) && cost >= 0; + const costs = providerResults + .flatMap((entry: any) => { + if (entry.capability === 'sql' && entry.data?.kind === 'result') + return [entry.data.creditsCost]; + if (entry.capability === 'enrich' && entry.data) + return [entry.data.providerCredits]; + return []; + }) + .filter(validCost); const separatelyBilledCredits = - validSqlCosts.length > 0 - ? validSqlCosts.reduce((sum: number, cost: number) => sum + cost, 0) + costs.length > 0 + ? costs.reduce((sum: number, cost: number) => sum + cost, 0) + : undefined; + const providerCostsComplete = + enrichmentResults.length > 0 + ? enrichmentResults.every( + (entry: any) => + entry.data.billingComplete === true && + validCost(entry.data.providerCredits) + ) && + (separatelyBilledCredits === undefined || + Number.isFinite(separatelyBilledCredits)) : undefined; return { + ...(providerCostsComplete !== undefined ? { providerCostsComplete } : {}), ...(separatelyBilledCredits !== undefined && Number.isFinite(separatelyBilledCredits) ? { separatelyBilledCredits } @@ -64,10 +80,12 @@ export function printReceipt(receipt: Receipt, includeRequestId = true): void { console.error( `${receipt.operationType === 'search' ? 'Search' : 'Scrape'} ID: ${receipt.operationId}` ); + const label = + receipt.providerCostsComplete === false ? 'Known credits' : 'Credits'; if (receipt.separatelyBilledCredits !== undefined) { if (receipt.creditsUsed !== undefined) { console.error( - `Credits: ${receipt.creditsUsed + receipt.separatelyBilledCredits} (${receipt.creditsUsed} outer request + ${receipt.separatelyBilledCredits} separately billed provider calls)` + `${label}: ${receipt.creditsUsed + receipt.separatelyBilledCredits} (${receipt.creditsUsed} outer request + ${receipt.separatelyBilledCredits} separately billed provider calls)` ); } else { console.error( @@ -75,8 +93,12 @@ export function printReceipt(receipt: Receipt, includeRequestId = true): void { ); } } else if (receipt.creditsUsed !== undefined) { - console.error(`Credits: ${receipt.creditsUsed}`); + console.error(`${label}: ${receipt.creditsUsed}`); } + if (receipt.providerCostsComplete === false) + console.error( + 'Provider costs are incomplete; additional credits may have been incurred.' + ); } export function printRetry(failure: Record): void {