Skip to content

Commit e7ffa6e

Browse files
committed
google cost tracking
1 parent 7086023 commit e7ffa6e

File tree

11 files changed

+486
-11
lines changed

11 files changed

+486
-11
lines changed

src/costs/calculator.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
calculateAIOutputCost,
1010
calculateEmbeddingCost,
1111
calculateGeocodingCost,
12+
calculateGoogleSearchCost,
1213
calculatePexelsCost,
1314
calculatePixabayCost,
1415
calculatePlacesLookupCost,
@@ -17,6 +18,7 @@ import {
1718
createAIUsageRecords,
1819
createEmbeddingUsageRecord,
1920
createGeocodingUsageRecord,
21+
createGoogleSearchUsageRecord,
2022
createImageUsageRecord,
2123
formatMicrosAsDollars,
2224
groupByProvider,
@@ -235,6 +237,43 @@ describe('calculatePexelsCost', () => {
235237
})
236238
})
237239

240+
describe('calculateGoogleSearchCost', () => {
241+
it('should calculate cost based on query count', () => {
242+
// $0.005 per query = 5,000 micro-dollars
243+
expect(calculateGoogleSearchCost(1)).toBe(5_000)
244+
expect(calculateGoogleSearchCost(10)).toBe(50_000)
245+
expect(calculateGoogleSearchCost(1000)).toBe(5_000_000) // $5 for 1000 queries
246+
})
247+
248+
it('should handle zero queries', () => {
249+
expect(calculateGoogleSearchCost(0)).toBe(0)
250+
})
251+
})
252+
253+
describe('createGoogleSearchUsageRecord', () => {
254+
it('should create record for google search', () => {
255+
const record = createGoogleSearchUsageRecord(5)
256+
expect(record.resource).toBe('google_search')
257+
expect(record.quantity).toBe(5)
258+
expect(record.provider).toBe('google_search')
259+
expect(record.costMicros).toBe(25_000) // 5 * 5,000
260+
})
261+
262+
it('should include metadata if provided', () => {
263+
const record = createGoogleSearchUsageRecord(1, { query: 'test movie' })
264+
expect(record.metadata).toEqual({ query: 'test movie' })
265+
})
266+
267+
it('should include timestamp', () => {
268+
const before = new Date()
269+
const record = createGoogleSearchUsageRecord(1)
270+
const after = new Date()
271+
272+
expect(record.timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime())
273+
expect(record.timestamp.getTime()).toBeLessThanOrEqual(after.getTime())
274+
})
275+
})
276+
238277
describe('createImageUsageRecord', () => {
239278
it('should create record for Google Places photos', () => {
240279
const record = createImageUsageRecord('google_places', 10)

src/costs/calculator.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import {
99
GOOGLE_MAPS_PRICING,
10+
GOOGLE_SEARCH_PRICING,
1011
getAIModelPricing,
1112
getEmbeddingModelPricing,
1213
IMAGE_SERVICE_PRICING
@@ -217,6 +218,36 @@ export function createGeocodingUsageRecord(
217218
return record
218219
}
219220

221+
// =============================================================================
222+
// SEARCH COST CALCULATIONS
223+
// =============================================================================
224+
225+
/**
226+
* Calculate cost for Google Programmable Search API.
227+
* Pricing: $5.00 per 1,000 queries ($0.005 per query).
228+
*/
229+
export function calculateGoogleSearchCost(queryCount: number): MicroDollars {
230+
return GOOGLE_SEARCH_PRICING.searchQuery * queryCount
231+
}
232+
233+
/**
234+
* Create a usage record for Google Search.
235+
*/
236+
export function createGoogleSearchUsageRecord(
237+
queryCount: number,
238+
metadata?: Record<string, unknown>
239+
): UsageRecord {
240+
const record: UsageRecord = {
241+
resource: 'google_search',
242+
provider: 'google_search',
243+
quantity: queryCount,
244+
costMicros: calculateGoogleSearchCost(queryCount),
245+
timestamp: new Date()
246+
}
247+
if (metadata) record.metadata = metadata
248+
return record
249+
}
250+
220251
// =============================================================================
221252
// IMAGE COST CALCULATIONS
222253
// =============================================================================

src/costs/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type {
4242
MicroDollars,
4343
ModelPricing,
4444
Provider,
45+
SearchProvider,
4546
// Usage types
4647
UsageRecord,
4748
UsageSummary
@@ -60,6 +61,7 @@ export {
6061
DEFAULT_GEOCODING_PROVIDER,
6162
EMBEDDING_MODEL_PRICING,
6263
GOOGLE_MAPS_PRICING,
64+
GOOGLE_SEARCH_PRICING,
6365
// Pricing helpers
6466
getAIModelPricing,
6567
getDefaultAIModel,
@@ -82,6 +84,8 @@ export {
8284
// Embedding costs
8385
calculateEmbeddingCost,
8486
calculateGeocodingCost,
87+
// Search costs
88+
calculateGoogleSearchCost,
8589
calculatePexelsCost,
8690
calculatePixabayCost,
8791
// Geocoding costs
@@ -92,6 +96,7 @@ export {
9296
createAIUsageRecords,
9397
createEmbeddingUsageRecord,
9498
createGeocodingUsageRecord,
99+
createGoogleSearchUsageRecord,
95100
createImageUsageRecord,
96101
formatMicrosAsDollars,
97102
groupByProvider,

src/costs/pricing.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ export const GOOGLE_MAPS_PRICING = {
185185
updatedAt: '2025-01-01'
186186
} as const
187187

188+
/**
189+
* Google Search API pricing (Programmable Search Engine).
190+
* Prices are per query in micro-dollars.
191+
*
192+
* Source: https://developers.google.com/custom-search/v1/overview#pricing
193+
* Pricing: $5.00 per 1,000 queries
194+
*/
195+
export const GOOGLE_SEARCH_PRICING = {
196+
/** Cost per search query ($0.005 per query = 5,000 micro-dollars) */
197+
searchQuery: 5_000 as MicroDollars,
198+
updatedAt: '2026-01-01'
199+
} as const
200+
188201
/**
189202
* Image service pricing.
190203
* Prices are per request in micro-dollars.

src/costs/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,19 @@ export type EmbeddingProvider = 'openai' | 'google'
1818
/** Geocoding providers */
1919
export type GeocodingProvider = 'google_places' | 'google_geocoding'
2020

21+
/** Search providers */
22+
export type SearchProvider = 'google_search'
23+
2124
/** Image providers */
2225
export type ImageProvider = 'google_places_photos' | 'pixabay' | 'pexels'
2326

2427
/** All provider types */
25-
export type Provider = AIProvider | EmbeddingProvider | GeocodingProvider | ImageProvider
28+
export type Provider =
29+
| AIProvider
30+
| EmbeddingProvider
31+
| GeocodingProvider
32+
| ImageProvider
33+
| SearchProvider
2634

2735
// =============================================================================
2836
// RESOURCE TYPES
@@ -38,6 +46,7 @@ export type MeteredResource =
3846
| 'google_places_lookup'
3947
| 'google_geocoding_lookup'
4048
| 'google_places_photo'
49+
| 'google_search'
4150
| 'pixabay_search'
4251
| 'pexels_search'
4352
// Compute

src/search/classification.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import { generateCacheKey } from '../caching/key'
99
import type { ResponseCache } from '../caching/types'
10+
import { createAIUsageRecords } from '../costs/calculator'
11+
import type { CostTracker } from '../costs/tracker'
1012
import { type HttpResponse, httpFetch } from '../http'
1113
import type { AIClassificationConfig, ClassificationResult, DeferredItem } from './types'
1214
import { DEFAULT_TIMEOUT } from './types'
@@ -21,6 +23,8 @@ const DEFAULT_MODEL = 'gemini-2.5-flash-preview-05-20'
2123
export 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:
6165
Empty 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
*/
6784
async 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

Comments
 (0)