llm_cost_tracker 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +132 -405
  4. data/lib/llm_cost_tracker/configuration/instrumentation.rb +37 -0
  5. data/lib/llm_cost_tracker/configuration.rb +10 -5
  6. data/lib/llm_cost_tracker/doctor.rb +166 -0
  7. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +33 -0
  8. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +12 -6
  9. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +53 -21
  10. data/lib/llm_cost_tracker/integrations/anthropic.rb +75 -0
  11. data/lib/llm_cost_tracker/integrations/base.rb +72 -0
  12. data/lib/llm_cost_tracker/integrations/object_reader.rb +56 -0
  13. data/lib/llm_cost_tracker/integrations/openai.rb +95 -0
  14. data/lib/llm_cost_tracker/integrations/registry.rb +41 -0
  15. data/lib/llm_cost_tracker/middleware/faraday.rb +6 -5
  16. data/lib/llm_cost_tracker/parsed_usage.rb +8 -1
  17. data/lib/llm_cost_tracker/parsers/base.rb +1 -1
  18. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  19. data/lib/llm_cost_tracker/price_freshness.rb +38 -0
  20. data/lib/llm_cost_tracker/price_registry.rb +14 -0
  21. data/lib/llm_cost_tracker/price_sync/fetcher.rb +5 -2
  22. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  23. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  24. data/lib/llm_cost_tracker/price_sync.rb +111 -109
  25. data/lib/llm_cost_tracker/prices.json +391 -42
  26. data/lib/llm_cost_tracker/pricing.rb +35 -16
  27. data/lib/llm_cost_tracker/request_url.rb +20 -0
  28. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  29. data/lib/llm_cost_tracker/stream_collector.rb +3 -3
  30. data/lib/llm_cost_tracker/tag_context.rb +52 -0
  31. data/lib/llm_cost_tracker/tracker.rb +7 -60
  32. data/lib/llm_cost_tracker/version.rb +1 -1
  33. data/lib/llm_cost_tracker.rb +14 -4
  34. data/lib/tasks/llm_cost_tracker.rake +33 -69
  35. metadata +28 -12
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +0 -51
  37. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  38. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  39. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  40. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -162
  41. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  42. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  43. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  44. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  45. data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
@@ -1,51 +1,400 @@
1
1
  {
2
2
  "metadata": {
3
- "updated_at": "2026-04-18",
3
+ "updated_at": "2026-04-27",
4
4
  "currency": "USD",
5
5
  "unit": "1M tokens",
6
6
  "source_urls": [
7
- "https://openai.com/api/pricing",
8
- "https://www.anthropic.com/pricing",
9
- "https://ai.google.dev/gemini-api/docs/pricing"
10
- ]
7
+ "https://developers.openai.com/api/docs/pricing",
8
+ "https://platform.claude.com/docs/en/about-claude/pricing",
9
+ "https://ai.google.dev/pricing"
10
+ ],
11
+ "schema_version": 1,
12
+ "min_gem_version": "0.4.0"
11
13
  },
12
14
  "models": {
13
- "gpt-5.2": { "input": 1.75, "cache_read_input": 0.175, "output": 14.0 },
14
- "gpt-5.1": { "input": 1.25, "cache_read_input": 0.125, "output": 10.0 },
15
- "gpt-5": { "input": 1.25, "cache_read_input": 0.125, "output": 10.0 },
16
- "gpt-5-mini": { "input": 0.25, "cache_read_input": 0.025, "output": 2.0 },
17
- "gpt-5-nano": { "input": 0.05, "cache_read_input": 0.005, "output": 0.4 },
18
- "gpt-4.1": { "input": 2.0, "cache_read_input": 0.5, "output": 8.0 },
19
- "gpt-4.1-mini": { "input": 0.4, "cache_read_input": 0.1, "output": 1.6 },
20
- "gpt-4.1-nano": { "input": 0.1, "cache_read_input": 0.025, "output": 0.4 },
21
- "gpt-4o-2024-05-13": { "input": 5.0, "output": 15.0 },
22
- "gpt-4o": { "input": 2.5, "cache_read_input": 1.25, "output": 10.0 },
23
- "gpt-4o-mini": { "input": 0.15, "cache_read_input": 0.075, "output": 0.6 },
24
- "gpt-4-turbo": { "input": 10.0, "output": 30.0 },
25
- "gpt-4": { "input": 30.0, "output": 60.0 },
26
- "gpt-3.5-turbo": { "input": 0.5, "output": 1.5 },
27
- "o1": { "input": 15.0, "cache_read_input": 7.5, "output": 60.0 },
28
- "o1-mini": { "input": 1.1, "cache_read_input": 0.55, "output": 4.4 },
29
- "o3": { "input": 2.0, "cache_read_input": 0.5, "output": 8.0 },
30
- "o3-mini": { "input": 1.1, "cache_read_input": 0.55, "output": 4.4 },
31
- "o4-mini": { "input": 1.1, "cache_read_input": 0.275, "output": 4.4 },
32
- "claude-sonnet-4-6": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
33
- "claude-opus-4-6": { "input": 5.0, "output": 25.0, "cache_read_input": 0.5, "cache_write_input": 6.25 },
34
- "claude-opus-4-1": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_write_input": 18.75 },
35
- "claude-opus-4": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_write_input": 18.75 },
36
- "claude-sonnet-4-5": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
37
- "claude-sonnet-4": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
38
- "claude-haiku-4-5": { "input": 1.0, "output": 5.0, "cache_read_input": 0.1, "cache_write_input": 1.25 },
39
- "claude-3-7-sonnet": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
40
- "claude-3-5-sonnet": { "input": 3.0, "output": 15.0, "cache_read_input": 0.3, "cache_write_input": 3.75 },
41
- "claude-3-5-haiku": { "input": 0.8, "output": 4.0, "cache_read_input": 0.08, "cache_write_input": 1.0 },
42
- "claude-3-opus": { "input": 15.0, "output": 75.0, "cache_read_input": 1.5, "cache_write_input": 18.75 },
43
- "gemini-2.5-pro": { "input": 1.25, "cache_read_input": 0.125, "output": 10.0 },
44
- "gemini-2.5-flash": { "input": 0.3, "cache_read_input": 0.03, "output": 2.5 },
45
- "gemini-2.5-flash-lite": { "input": 0.1, "cache_read_input": 0.01, "output": 0.4 },
46
- "gemini-2.0-flash": { "input": 0.1, "cache_read_input": 0.025, "output": 0.4 },
47
- "gemini-2.0-flash-lite": { "input": 0.075, "output": 0.3 },
48
- "gemini-1.5-pro": { "input": 1.25, "output": 5.0 },
49
- "gemini-1.5-flash": { "input": 0.075, "output": 0.3 }
15
+ "anthropic/claude-haiku-4-5": {
16
+ "input": 1.0,
17
+ "output": 5.0,
18
+ "cache_read_input": 0.1,
19
+ "cache_write_input": 1.25,
20
+ "batch_input": 0.5,
21
+ "batch_output": 2.5
22
+ },
23
+ "anthropic/claude-opus-4": {
24
+ "input": 15.0,
25
+ "output": 75.0,
26
+ "cache_read_input": 1.5,
27
+ "cache_write_input": 18.75,
28
+ "batch_input": 7.5,
29
+ "batch_output": 37.5
30
+ },
31
+ "anthropic/claude-opus-4-1": {
32
+ "input": 15.0,
33
+ "output": 75.0,
34
+ "cache_read_input": 1.5,
35
+ "cache_write_input": 18.75,
36
+ "batch_input": 7.5,
37
+ "batch_output": 37.5
38
+ },
39
+ "anthropic/claude-opus-4-5": {
40
+ "input": 5.0,
41
+ "output": 25.0,
42
+ "cache_read_input": 0.5,
43
+ "cache_write_input": 6.25,
44
+ "batch_input": 2.5,
45
+ "batch_output": 12.5
46
+ },
47
+ "anthropic/claude-opus-4-6": {
48
+ "input": 5.0,
49
+ "output": 25.0,
50
+ "cache_read_input": 0.5,
51
+ "cache_write_input": 6.25,
52
+ "batch_input": 2.5,
53
+ "batch_output": 12.5
54
+ },
55
+ "anthropic/claude-opus-4-7": {
56
+ "input": 5.0,
57
+ "output": 25.0,
58
+ "cache_read_input": 0.5,
59
+ "cache_write_input": 6.25,
60
+ "batch_input": 2.5,
61
+ "batch_output": 12.5
62
+ },
63
+ "anthropic/claude-sonnet-4": {
64
+ "input": 3.0,
65
+ "output": 15.0,
66
+ "cache_read_input": 0.3,
67
+ "cache_write_input": 3.75,
68
+ "batch_input": 1.5,
69
+ "batch_output": 7.5
70
+ },
71
+ "anthropic/claude-sonnet-4-5": {
72
+ "input": 3.0,
73
+ "output": 15.0,
74
+ "cache_read_input": 0.3,
75
+ "cache_write_input": 3.75,
76
+ "batch_input": 1.5,
77
+ "batch_output": 7.5
78
+ },
79
+ "anthropic/claude-sonnet-4-6": {
80
+ "input": 3.0,
81
+ "output": 15.0,
82
+ "cache_read_input": 0.3,
83
+ "cache_write_input": 3.75,
84
+ "batch_input": 1.5,
85
+ "batch_output": 7.5
86
+ },
87
+ "gemini/gemini-2.0-flash": {
88
+ "input": 0.1,
89
+ "cache_read_input": 0.025,
90
+ "output": 0.4,
91
+ "batch_input": 0.05,
92
+ "batch_output": 0.2
93
+ },
94
+ "gemini/gemini-2.0-flash-lite": {
95
+ "input": 0.075,
96
+ "output": 0.3,
97
+ "batch_input": 0.0375,
98
+ "batch_output": 0.15
99
+ },
100
+ "gemini/gemini-2.5-flash": {
101
+ "input": 0.3,
102
+ "output": 2.5,
103
+ "cache_read_input": 0.03,
104
+ "cache_write_input": 0.083333333333,
105
+ "batch_input": 0.15,
106
+ "batch_output": 1.25
107
+ },
108
+ "gemini/gemini-2.5-flash-lite": {
109
+ "input": 0.1,
110
+ "output": 0.4,
111
+ "cache_read_input": 0.01,
112
+ "cache_write_input": 0.083333333333,
113
+ "batch_input": 0.05,
114
+ "batch_output": 0.2
115
+ },
116
+ "gemini/gemini-2.5-pro": {
117
+ "input": 1.25,
118
+ "output": 10.0,
119
+ "cache_read_input": 0.125,
120
+ "cache_write_input": 0.375,
121
+ "batch_input": 0.625,
122
+ "batch_output": 5.0
123
+ },
124
+ "openai/gpt-3.5-turbo": {
125
+ "input": 0.5,
126
+ "output": 1.5
127
+ },
128
+ "openai/gpt-4": {
129
+ "input": 30.0,
130
+ "output": 60.0,
131
+ "batch_input": 15.0,
132
+ "batch_output": 30.0
133
+ },
134
+ "openai/gpt-4-turbo": {
135
+ "input": 10.0,
136
+ "output": 30.0,
137
+ "batch_input": 5.0,
138
+ "batch_output": 15.0
139
+ },
140
+ "openai/gpt-4.1": {
141
+ "input": 2.0,
142
+ "output": 8.0,
143
+ "cache_read_input": 0.5,
144
+ "batch_input": 1.0,
145
+ "batch_output": 4.0
146
+ },
147
+ "openai/gpt-4.1-mini": {
148
+ "input": 0.4,
149
+ "output": 1.6,
150
+ "cache_read_input": 0.1,
151
+ "batch_input": 0.2,
152
+ "batch_output": 0.8
153
+ },
154
+ "openai/gpt-4.1-nano": {
155
+ "input": 0.1,
156
+ "output": 0.4,
157
+ "cache_read_input": 0.025,
158
+ "batch_input": 0.05,
159
+ "batch_output": 0.2
160
+ },
161
+ "openai/gpt-4o": {
162
+ "input": 2.5,
163
+ "output": 10.0,
164
+ "cache_read_input": 1.25,
165
+ "batch_input": 1.25,
166
+ "batch_output": 5.0
167
+ },
168
+ "openai/gpt-4o-2024-05-13": {
169
+ "input": 5.0,
170
+ "output": 15.0,
171
+ "batch_input": 2.5,
172
+ "batch_output": 7.5
173
+ },
174
+ "openai/gpt-4o-mini": {
175
+ "input": 0.15,
176
+ "output": 0.6,
177
+ "cache_read_input": 0.075,
178
+ "batch_input": 0.075,
179
+ "batch_output": 0.3
180
+ },
181
+ "openai/gpt-5": {
182
+ "input": 1.25,
183
+ "output": 10.0,
184
+ "cache_read_input": 0.125,
185
+ "batch_input": 0.625,
186
+ "batch_output": 5.0,
187
+ "batch_cache_read_input": 0.0625
188
+ },
189
+ "openai/gpt-5-chat-latest": {
190
+ "input": 1.25,
191
+ "output": 10.0,
192
+ "cache_read_input": 0.125
193
+ },
194
+ "openai/gpt-5-codex": {
195
+ "input": 1.25,
196
+ "output": 10.0,
197
+ "cache_read_input": 0.125
198
+ },
199
+ "openai/gpt-5-mini": {
200
+ "input": 0.25,
201
+ "output": 2.0,
202
+ "cache_read_input": 0.025,
203
+ "batch_input": 0.125,
204
+ "batch_output": 1.0,
205
+ "batch_cache_read_input": 0.0125
206
+ },
207
+ "openai/gpt-5-nano": {
208
+ "input": 0.05,
209
+ "output": 0.4,
210
+ "cache_read_input": 0.005,
211
+ "batch_input": 0.025,
212
+ "batch_output": 0.2,
213
+ "batch_cache_read_input": 0.0025
214
+ },
215
+ "openai/gpt-5-pro": {
216
+ "input": 15.0,
217
+ "output": 120.0,
218
+ "batch_input": 7.5,
219
+ "batch_output": 60.0
220
+ },
221
+ "openai/gpt-5.1": {
222
+ "input": 1.25,
223
+ "output": 10.0,
224
+ "cache_read_input": 0.125,
225
+ "batch_input": 0.625,
226
+ "batch_output": 5.0,
227
+ "batch_cache_read_input": 0.0625
228
+ },
229
+ "openai/gpt-5.1-chat-latest": {
230
+ "input": 1.25,
231
+ "output": 10.0,
232
+ "cache_read_input": 0.125
233
+ },
234
+ "openai/gpt-5.1-codex": {
235
+ "input": 1.25,
236
+ "output": 10.0,
237
+ "cache_read_input": 0.125
238
+ },
239
+ "openai/gpt-5.1-codex-max": {
240
+ "input": 1.25,
241
+ "output": 10.0,
242
+ "cache_read_input": 0.125
243
+ },
244
+ "openai/gpt-5.1-codex-mini": {
245
+ "input": 0.25,
246
+ "output": 2.0,
247
+ "cache_read_input": 0.025
248
+ },
249
+ "openai/gpt-5.2": {
250
+ "input": 1.75,
251
+ "output": 14.0,
252
+ "cache_read_input": 0.175,
253
+ "batch_input": 0.875,
254
+ "batch_output": 7.0,
255
+ "batch_cache_read_input": 0.0875
256
+ },
257
+ "openai/gpt-5.2-chat-latest": {
258
+ "input": 1.75,
259
+ "output": 14.0,
260
+ "cache_read_input": 0.175
261
+ },
262
+ "openai/gpt-5.2-codex": {
263
+ "input": 1.75,
264
+ "output": 14.0,
265
+ "cache_read_input": 0.175
266
+ },
267
+ "openai/gpt-5.2-pro": {
268
+ "input": 21.0,
269
+ "output": 168.0,
270
+ "batch_input": 10.5,
271
+ "batch_output": 84.0
272
+ },
273
+ "openai/gpt-5.4": {
274
+ "input": 2.5,
275
+ "output": 15.0,
276
+ "cache_read_input": 0.25,
277
+ "batch_input": 1.25,
278
+ "batch_output": 7.5,
279
+ "batch_cache_read_input": 0.13
280
+ },
281
+ "openai/gpt-5.4-mini": {
282
+ "input": 0.75,
283
+ "output": 4.5,
284
+ "cache_read_input": 0.075,
285
+ "batch_input": 0.375,
286
+ "batch_output": 2.25,
287
+ "batch_cache_read_input": 0.0375
288
+ },
289
+ "openai/gpt-5.4-nano": {
290
+ "input": 0.2,
291
+ "output": 1.25,
292
+ "cache_read_input": 0.02,
293
+ "batch_input": 0.1,
294
+ "batch_output": 0.625,
295
+ "batch_cache_read_input": 0.01
296
+ },
297
+ "openai/gpt-5.4-pro": {
298
+ "input": 30.0,
299
+ "output": 180.0,
300
+ "batch_input": 15.0,
301
+ "batch_output": 90.0
302
+ },
303
+ "openai/gpt-5.5": {
304
+ "input": 5.0,
305
+ "output": 30.0,
306
+ "cache_read_input": 0.5,
307
+ "batch_input": 2.5,
308
+ "batch_output": 15.0,
309
+ "batch_cache_read_input": 0.25
310
+ },
311
+ "openai/gpt-5.5-pro": {
312
+ "input": 30.0,
313
+ "output": 180.0,
314
+ "batch_input": 15.0,
315
+ "batch_output": 90.0
316
+ },
317
+ "openai/o1": {
318
+ "input": 15.0,
319
+ "output": 60.0,
320
+ "cache_read_input": 7.5,
321
+ "batch_input": 7.5,
322
+ "batch_output": 30.0
323
+ },
324
+ "openai/o1-mini": {
325
+ "input": 1.1,
326
+ "cache_read_input": 0.55,
327
+ "output": 4.4,
328
+ "batch_input": 0.55,
329
+ "batch_output": 2.2
330
+ },
331
+ "openai/o3": {
332
+ "input": 2.0,
333
+ "output": 8.0,
334
+ "cache_read_input": 0.5,
335
+ "batch_input": 1.0,
336
+ "batch_output": 4.0
337
+ },
338
+ "openai/o3-mini": {
339
+ "input": 1.1,
340
+ "output": 4.4,
341
+ "cache_read_input": 0.55,
342
+ "batch_input": 0.55,
343
+ "batch_output": 2.2
344
+ },
345
+ "openai/o4-mini": {
346
+ "input": 1.1,
347
+ "output": 4.4,
348
+ "cache_read_input": 0.275,
349
+ "batch_input": 0.55,
350
+ "batch_output": 2.2
351
+ },
352
+ "anthropic/claude-haiku-3-5": {
353
+ "input": 0.8,
354
+ "cache_write_input": 1.0,
355
+ "cache_read_input": 0.08,
356
+ "output": 4.0,
357
+ "batch_input": 0.4,
358
+ "batch_output": 2.0
359
+ },
360
+ "anthropic/claude-haiku-3": {
361
+ "input": 0.25,
362
+ "cache_write_input": 0.3,
363
+ "cache_read_input": 0.03,
364
+ "output": 1.25,
365
+ "batch_input": 0.125,
366
+ "batch_output": 0.625
367
+ },
368
+ "openai/o1-pro": {
369
+ "input": 150.0,
370
+ "output": 600.0,
371
+ "batch_input": 75.0,
372
+ "batch_output": 300.0
373
+ },
374
+ "openai/o3-pro": {
375
+ "input": 20.0,
376
+ "output": 80.0,
377
+ "batch_input": 10.0,
378
+ "batch_output": 40.0
379
+ },
380
+ "openai/gpt-5.3-chat-latest": {
381
+ "input": 1.75,
382
+ "output": 14.0,
383
+ "cache_read_input": 0.175
384
+ },
385
+ "openai/chatgpt-4o-latest": {
386
+ "input": 5.0,
387
+ "output": 15.0
388
+ },
389
+ "openai/gpt-5.3-codex": {
390
+ "input": 1.75,
391
+ "output": 14.0,
392
+ "cache_read_input": 0.175
393
+ },
394
+ "openai/codex-mini-latest": {
395
+ "input": 1.5,
396
+ "output": 6.0,
397
+ "cache_read_input": 0.375
398
+ }
50
399
  }
51
400
  }
@@ -32,27 +32,20 @@ module LlmCostTracker
32
32
  end
33
33
 
34
34
  def lookup(provider:, model:)
35
- table = prices
36
35
  provider_name = provider.to_s
37
36
  model_name = model.to_s
38
37
  provider_model = provider_name.empty? ? model_name : "#{provider_name}/#{model_name}"
39
38
  normalized_model = normalize_model_name(model_name)
39
+ current = current_price_tables
40
40
 
41
- table[provider_model] ||
42
- table[model_name] ||
43
- table[normalized_model] ||
44
- fuzzy_match(provider_model, normalized_model, table)
45
- end
46
-
47
- def models
48
- prices.keys
41
+ lookup_in_table(current.fetch(:pricing_overrides), provider_model, model_name, normalized_model) ||
42
+ lookup_in_table(current.fetch(:file_prices), provider_model, model_name, normalized_model) ||
43
+ lookup_in_table(PRICES, provider_model, model_name, normalized_model)
49
44
  end
50
45
 
51
- def metadata
52
- PriceRegistry.metadata
53
- end
46
+ private
54
47
 
55
- def prices
48
+ def current_price_tables
56
49
  file_prices = PriceRegistry.file_prices(LlmCostTracker.configuration.prices_file)
57
50
  overrides = PriceRegistry.normalize_price_table(LlmCostTracker.configuration.pricing_overrides)
58
51
  cache_key = [file_prices.object_id, LlmCostTracker.configuration.pricing_overrides.hash]
@@ -64,13 +57,22 @@ module LlmCostTracker
64
57
  cached = @prices_cache
65
58
  return cached[:value] if cached && cached[:key] == cache_key
66
59
 
67
- value = PRICES.merge(file_prices).merge(overrides).freeze
60
+ value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
68
61
  @prices_cache = { key: cache_key, value: value }.freeze
69
62
  value
70
63
  end
71
64
  end
72
65
 
73
- private
66
+ def lookup_in_table(table, provider_model, model_name, normalized_model)
67
+ return nil if table.empty?
68
+
69
+ table[provider_model] ||
70
+ table[model_name] ||
71
+ table[normalized_model] ||
72
+ unique_providerless_lookup(normalized_model, table) ||
73
+ fuzzy_match(provider_model, normalized_model, table) ||
74
+ unique_providerless_fuzzy_match(normalized_model, table)
75
+ end
74
76
 
75
77
  def calculate_costs(usage, prices, pricing_mode:)
76
78
  {
@@ -113,14 +115,31 @@ module LlmCostTracker
113
115
  model.to_s.split("/").last
114
116
  end
115
117
 
118
+ def unique_providerless_lookup(model, table)
119
+ matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
120
+ table[matches.first] if matches.one?
121
+ end
122
+
116
123
  def fuzzy_match(model, normalized_model, table)
117
124
  sorted_price_keys(table).each do |key|
118
- return table[key] if model.start_with?(key) || normalized_model.start_with?(key)
125
+ return table[key] if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
119
126
  end
120
127
 
121
128
  nil
122
129
  end
123
130
 
131
+ def unique_providerless_fuzzy_match(model, table)
132
+ matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
133
+ table[matches.first] if matches.one?
134
+ end
135
+
136
+ def snapshot_variant?(model, key)
137
+ suffix = model.delete_prefix("#{key}-")
138
+ return false if suffix == model
139
+
140
+ suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
141
+ end
142
+
124
143
  def sorted_price_keys(table)
125
144
  cached = @sorted_price_keys_cache
126
145
  return cached[:keys] if cached && cached[:table].equal?(table)
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module LlmCostTracker
6
+ module RequestUrl
7
+ class << self
8
+ def label(value)
9
+ uri = URI.parse(value.to_s)
10
+ uri.query = nil
11
+ uri.fragment = nil
12
+ uri.user = nil if uri.respond_to?(:user=)
13
+ uri.password = nil if uri.respond_to?(:password=)
14
+ uri.to_s
15
+ rescue URI::InvalidURIError
16
+ value.to_s.split("?", 2).first
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+
5
+ module LlmCostTracker
6
+ module Storage
7
+ class Dispatcher
8
+ class << self
9
+ def save(event)
10
+ config = LlmCostTracker.configuration
11
+ case config.storage_backend
12
+ when :log then log_event(event, config)
13
+ when :active_record then active_record_save(event)
14
+ when :custom then custom_save(event, config)
15
+ end
16
+ rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
17
+ raise
18
+ rescue StandardError => e
19
+ handle_error(e)
20
+ false
21
+ end
22
+
23
+ private
24
+
25
+ def log_event(event, config)
26
+ message = "#{event.provider}/#{event.model} " \
27
+ "tokens=#{event.total_tokens} " \
28
+ "cost=#{log_cost_label(event)}"
29
+ message += " latency=#{event.latency_ms}ms" if event.latency_ms
30
+ message += " stream=#{event.stream}" if event.stream
31
+ message += " source=#{event.usage_source}" if event.usage_source
32
+ message += " tags=#{event.tags}" unless event.tags.empty?
33
+
34
+ Logging.log(config.log_level, message)
35
+ event
36
+ end
37
+
38
+ def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
39
+
40
+ def active_record_save(event)
41
+ require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
42
+ require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
43
+
44
+ ActiveRecordStore.save(event)
45
+ event
46
+ rescue LoadError => e
47
+ raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
48
+ end
49
+
50
+ def custom_save(event, config)
51
+ result = config.custom_storage&.call(event)
52
+ result == false ? false : event
53
+ end
54
+
55
+ def handle_error(error)
56
+ case LlmCostTracker.configuration.storage_error_behavior
57
+ when :ignore
58
+ nil
59
+ when :warn
60
+ Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
61
+ when :raise
62
+ raise StorageError, error
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -115,7 +115,7 @@ module LlmCostTracker
115
115
  def finalize(parsed, snapshot)
116
116
  parsed.with(
117
117
  provider: @provider,
118
- model: present_model(parsed.model) || snapshot[:model]
118
+ model: present_model(parsed.model) || present_model(snapshot[:model]) || ParsedUsage::UNKNOWN_MODEL
119
119
  )
120
120
  end
121
121
 
@@ -136,7 +136,7 @@ module LlmCostTracker
136
136
 
137
137
  ParsedUsage.build(
138
138
  provider: @provider,
139
- model: snapshot[:model],
139
+ model: snapshot[:model] || ParsedUsage::UNKNOWN_MODEL,
140
140
  input_tokens: input,
141
141
  output_tokens: output,
142
142
  stream: true,
@@ -148,7 +148,7 @@ module LlmCostTracker
148
148
  def build_unknown_usage(snapshot)
149
149
  ParsedUsage.build(
150
150
  provider: @provider,
151
- model: snapshot[:model],
151
+ model: snapshot[:model] || ParsedUsage::UNKNOWN_MODEL,
152
152
  input_tokens: 0,
153
153
  output_tokens: 0,
154
154
  total_tokens: 0,