77
88import { generateCacheKey } from '../caching/key'
99import type { ResponseCache } from '../caching/types'
10+ import { createAIUsageRecords } from '../costs/calculator'
11+ import type { CostTracker } from '../costs/tracker'
1012import { type HttpResponse , httpFetch } from '../http'
1113import type { AIClassificationConfig , ClassificationResult , DeferredItem } from './types'
1214import { DEFAULT_TIMEOUT } from './types'
@@ -21,6 +23,8 @@ const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20'
2123export interface ClassificationFullConfig extends AIClassificationConfig {
2224 /** Response cache for API calls */
2325 cache ?: ResponseCache | undefined
26+ /** Cost tracker for API usage billing */
27+ costTracker ?: CostTracker | undefined
2428 /** Request timeout in milliseconds */
2529 timeout ?: number | undefined
2630 /** Custom fetch function (for testing) */
@@ -61,13 +65,26 @@ Return JSON with 1-indexed result numbers, best first:
6165Empty array if no result matches the entity (a book by the same author is NOT a match).`
6266}
6367
68+ /** Token usage from Gemini API response */
69+ interface GeminiUsageMetadata {
70+ promptTokenCount ?: number
71+ candidatesTokenCount ?: number
72+ totalTokenCount ?: number
73+ }
74+
75+ /** Result from Gemini API call including usage */
76+ interface GeminiCallResult {
77+ result : { url_indexes : number [ ] ; explanation : string } | null
78+ usage : GeminiUsageMetadata | null
79+ }
80+
6481/**
6582 * Call Gemini API and parse JSON response.
6683 */
6784async function callGemini (
6885 prompt : string ,
6986 config : ClassificationFullConfig
70- ) : Promise < { url_indexes : number [ ] ; explanation : string } | null > {
87+ ) : Promise < GeminiCallResult > {
7188 const timeout = config . timeout ?? DEFAULT_TIMEOUT
7289 const model = config . model ?? DEFAULT_MODEL
7390
@@ -100,11 +117,11 @@ async function callGemini(
100117 } )
101118 }
102119 } catch {
103- return null
120+ return { result : null , usage : null }
104121 }
105122
106123 if ( ! response . ok ) {
107- return null
124+ return { result : null , usage : null }
108125 }
109126
110127 const data = ( await response . json ( ) ) as {
@@ -113,17 +130,21 @@ async function callGemini(
113130 parts ?: Array < { text ?: string } >
114131 }
115132 } >
133+ usageMetadata ?: GeminiUsageMetadata
116134 }
117135
118136 const text = data . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ?. text
137+ const usage = data . usageMetadata ?? null
138+
119139 if ( ! text ) {
120- return null
140+ return { result : null , usage }
121141 }
122142
123143 try {
124- return JSON . parse ( text ) as { url_indexes : number [ ] ; explanation : string }
144+ const result = JSON . parse ( text ) as { url_indexes : number [ ] ; explanation : string }
145+ return { result, usage }
125146 } catch {
126- return null
147+ return { result : null , usage }
127148 }
128149}
129150
@@ -167,9 +188,24 @@ async function executeClassification(
167188 config : ClassificationFullConfig
168189) : Promise < ClassificationResult > {
169190 const { title, category, searchResults } = item
191+ const model = config . model ?? DEFAULT_MODEL
170192
171193 const prompt = buildPrompt ( item )
172- const aiResponse = await callGemini ( prompt , config )
194+ const { result : aiResponse , usage } = await callGemini ( prompt , config )
195+
196+ // Record AI costs if we have usage data and a cost tracker
197+ if ( usage && config . costTracker ) {
198+ const inputTokens = usage . promptTokenCount ?? 0
199+ const outputTokens = usage . candidatesTokenCount ?? 0
200+ if ( inputTokens > 0 || outputTokens > 0 ) {
201+ const usageRecords = createAIUsageRecords ( model , inputTokens , outputTokens , {
202+ operation : 'entity_classification' ,
203+ title,
204+ category
205+ } )
206+ config . costTracker . addRecords ( usageRecords )
207+ }
208+ }
173209
174210 if ( ! aiResponse ) {
175211 return {
0 commit comments