llm_cost_tracker 0.7.3 → 0.8.0

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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +66 -1
  4. data/README.md +58 -225
  5. data/app/assets/llm_cost_tracker/application.css +218 -41
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
  11. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  13. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
  15. data/app/models/llm_cost_tracker/call.rb +169 -0
  16. data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
  17. data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
  18. data/app/models/llm_cost_tracker/call_tag.rb +16 -0
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +121 -30
  23. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  27. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  28. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  32. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  33. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  39. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  40. data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
  41. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  42. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  43. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  44. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
  45. data/lib/llm_cost_tracker/billing/components.rb +53 -0
  46. data/lib/llm_cost_tracker/billing/components.yml +117 -0
  47. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  48. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  49. data/lib/llm_cost_tracker/budget.rb +23 -35
  50. data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
  51. data/lib/llm_cost_tracker/configuration.rb +36 -19
  52. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
  54. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  55. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  57. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  58. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  59. data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
  60. data/lib/llm_cost_tracker/doctor.rb +43 -45
  61. data/lib/llm_cost_tracker/errors.rb +5 -19
  62. data/lib/llm_cost_tracker/event.rb +10 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
  66. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  67. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
  68. data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
  69. data/lib/llm_cost_tracker/ingestion.rb +28 -22
  70. data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
  71. data/lib/llm_cost_tracker/integrations/base.rb +36 -29
  72. data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
  74. data/lib/llm_cost_tracker/integrations.rb +2 -2
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
  84. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +96 -13
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +4 -10
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  88. data/lib/llm_cost_tracker/ledger.rb +4 -2
  89. data/lib/llm_cost_tracker/logging.rb +2 -5
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
  92. data/lib/llm_cost_tracker/parsers/base.rb +8 -3
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
  94. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
  95. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
  96. data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
  97. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  98. data/lib/llm_cost_tracker/parsers.rb +1 -1
  99. data/lib/llm_cost_tracker/prices.json +105 -20
  100. data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
  101. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  102. data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
  103. data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
  104. data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
  105. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  106. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  107. data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
  108. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  109. data/lib/llm_cost_tracker/pricing.rb +190 -26
  110. data/lib/llm_cost_tracker/railtie.rb +0 -8
  111. data/lib/llm_cost_tracker/report/data.rb +16 -8
  112. data/lib/llm_cost_tracker/report.rb +0 -4
  113. data/lib/llm_cost_tracker/retention.rb +8 -8
  114. data/lib/llm_cost_tracker/tags/context.rb +2 -4
  115. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
  117. data/lib/llm_cost_tracker/timing.rb +15 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +56 -42
  119. data/lib/llm_cost_tracker/tracker.rb +67 -24
  120. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +36 -35
  123. data/lib/tasks/llm_cost_tracker.rake +22 -17
  124. metadata +36 -41
  125. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  126. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  127. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  128. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  129. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  130. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  131. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  133. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
  136. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
  137. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  138. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  139. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  140. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  141. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  142. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  143. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  144. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  145. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  146. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  147. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  148. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  149. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  150. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  151. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  152. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "metadata": {
3
- "updated_at": "2026-05-01",
3
+ "updated_at": "2026-05-02",
4
4
  "currency": "USD",
5
5
  "unit": "1M tokens",
6
6
  "source_urls": [
@@ -15,13 +15,23 @@
15
15
  "schema_version": 1,
16
16
  "min_gem_version": "0.4.0"
17
17
  },
18
+ "service_charges": {
19
+ "anthropic": {
20
+ "web_search_request": 10.0,
21
+ "code_execution_hour": 0.05
22
+ },
23
+ "openai": {
24
+ "web_search_request": 10.0,
25
+ "file_search_call": 2.5
26
+ }
27
+ },
18
28
  "models": {
19
29
  "anthropic/claude-haiku-4-5": {
20
30
  "input": 1.0,
21
31
  "output": 5.0,
22
32
  "cache_read_input": 0.1,
23
33
  "cache_write_input": 1.25,
24
- "cache_write_1h_input": 2.0,
34
+ "cache_write_extended_input": 2.0,
25
35
  "batch_input": 0.5,
26
36
  "batch_output": 2.5
27
37
  },
@@ -30,7 +40,7 @@
30
40
  "output": 75.0,
31
41
  "cache_read_input": 1.5,
32
42
  "cache_write_input": 18.75,
33
- "cache_write_1h_input": 30.0,
43
+ "cache_write_extended_input": 30.0,
34
44
  "batch_input": 7.5,
35
45
  "batch_output": 37.5
36
46
  },
@@ -39,7 +49,7 @@
39
49
  "output": 75.0,
40
50
  "cache_read_input": 1.5,
41
51
  "cache_write_input": 18.75,
42
- "cache_write_1h_input": 30.0,
52
+ "cache_write_extended_input": 30.0,
43
53
  "batch_input": 7.5,
44
54
  "batch_output": 37.5
45
55
  },
@@ -48,7 +58,7 @@
48
58
  "output": 25.0,
49
59
  "cache_read_input": 0.5,
50
60
  "cache_write_input": 6.25,
51
- "cache_write_1h_input": 10.0,
61
+ "cache_write_extended_input": 10.0,
52
62
  "batch_input": 2.5,
53
63
  "batch_output": 12.5
54
64
  },
@@ -57,24 +67,24 @@
57
67
  "output": 25.0,
58
68
  "cache_read_input": 0.5,
59
69
  "cache_write_input": 6.25,
60
- "cache_write_1h_input": 10.0,
70
+ "cache_write_extended_input": 10.0,
61
71
  "batch_input": 2.5,
62
72
  "batch_output": 12.5,
63
73
  "data_residency_input": 5.5,
64
74
  "data_residency_cache_write_input": 6.875,
65
- "data_residency_cache_write_1h_input": 11.0,
75
+ "data_residency_cache_write_extended_input": 11.0,
66
76
  "data_residency_cache_read_input": 0.55,
67
77
  "data_residency_output": 27.5,
68
78
  "data_residency_batch_input": 2.75,
69
79
  "data_residency_batch_output": 13.75,
70
80
  "fast_input": 30.0,
71
81
  "fast_cache_write_input": 37.5,
72
- "fast_cache_write_1h_input": 60.0,
82
+ "fast_cache_write_extended_input": 60.0,
73
83
  "fast_cache_read_input": 3.0,
74
84
  "fast_output": 150.0,
75
85
  "fast_data_residency_input": 33.0,
76
86
  "fast_data_residency_cache_write_input": 41.25,
77
- "fast_data_residency_cache_write_1h_input": 66.0,
87
+ "fast_data_residency_cache_write_extended_input": 66.0,
78
88
  "fast_data_residency_cache_read_input": 3.3,
79
89
  "fast_data_residency_output": 165.0
80
90
  },
@@ -83,12 +93,12 @@
83
93
  "output": 25.0,
84
94
  "cache_read_input": 0.5,
85
95
  "cache_write_input": 6.25,
86
- "cache_write_1h_input": 10.0,
96
+ "cache_write_extended_input": 10.0,
87
97
  "batch_input": 2.5,
88
98
  "batch_output": 12.5,
89
99
  "data_residency_input": 5.5,
90
100
  "data_residency_cache_write_input": 6.875,
91
- "data_residency_cache_write_1h_input": 11.0,
101
+ "data_residency_cache_write_extended_input": 11.0,
92
102
  "data_residency_cache_read_input": 0.55,
93
103
  "data_residency_output": 27.5,
94
104
  "data_residency_batch_input": 2.75,
@@ -99,7 +109,7 @@
99
109
  "output": 15.0,
100
110
  "cache_read_input": 0.3,
101
111
  "cache_write_input": 3.75,
102
- "cache_write_1h_input": 6.0,
112
+ "cache_write_extended_input": 6.0,
103
113
  "batch_input": 1.5,
104
114
  "batch_output": 7.5
105
115
  },
@@ -108,7 +118,7 @@
108
118
  "output": 15.0,
109
119
  "cache_read_input": 0.3,
110
120
  "cache_write_input": 3.75,
111
- "cache_write_1h_input": 6.0,
121
+ "cache_write_extended_input": 6.0,
112
122
  "batch_input": 1.5,
113
123
  "batch_output": 7.5
114
124
  },
@@ -117,12 +127,12 @@
117
127
  "output": 15.0,
118
128
  "cache_read_input": 0.3,
119
129
  "cache_write_input": 3.75,
120
- "cache_write_1h_input": 6.0,
130
+ "cache_write_extended_input": 6.0,
121
131
  "batch_input": 1.5,
122
132
  "batch_output": 7.5,
123
133
  "data_residency_input": 3.3,
124
134
  "data_residency_cache_write_input": 4.125,
125
- "data_residency_cache_write_1h_input": 6.6,
135
+ "data_residency_cache_write_extended_input": 6.6,
126
136
  "data_residency_cache_read_input": 0.33,
127
137
  "data_residency_output": 16.5,
128
138
  "data_residency_batch_input": 1.65,
@@ -134,7 +144,9 @@
134
144
  "output": 0.4,
135
145
  "batch_input": 0.05,
136
146
  "batch_output": 0.2,
137
- "batch_cache_read_input": 0.025
147
+ "batch_cache_read_input": 0.025,
148
+ "audio_input": 0.7,
149
+ "batch_audio_input": 0.35
138
150
  },
139
151
  "gemini/gemini-2.0-flash-lite": {
140
152
  "input": 0.075,
@@ -154,7 +166,11 @@
154
166
  "flex_cache_read_input": 0.03,
155
167
  "priority_input": 0.54,
156
168
  "priority_output": 4.5,
157
- "priority_cache_read_input": 0.054
169
+ "priority_cache_read_input": 0.054,
170
+ "audio_input": 1.0,
171
+ "batch_audio_input": 0.5,
172
+ "flex_audio_input": 0.5,
173
+ "priority_audio_input": 1.8
158
174
  },
159
175
  "gemini/gemini-2.5-flash-lite": {
160
176
  "input": 0.1,
@@ -168,7 +184,11 @@
168
184
  "flex_cache_read_input": 0.01,
169
185
  "priority_input": 0.18,
170
186
  "priority_output": 0.72,
171
- "priority_cache_read_input": 0.018
187
+ "priority_cache_read_input": 0.018,
188
+ "audio_input": 0.3,
189
+ "batch_audio_input": 0.15,
190
+ "flex_audio_input": 0.15,
191
+ "priority_audio_input": 0.54
172
192
  },
173
193
  "gemini/gemini-2.5-pro": {
174
194
  "input": 1.25,
@@ -309,6 +329,71 @@
309
329
  "priority_output": 1.0,
310
330
  "priority_cache_read_input": 0.125
311
331
  },
332
+ "openai/gpt-4o-realtime-preview": {
333
+ "input": 5.0,
334
+ "cache_read_input": 2.5,
335
+ "audio_input": 40.0,
336
+ "output": 20.0,
337
+ "audio_output": 80.0
338
+ },
339
+ "openai/gpt-4o-mini-realtime-preview": {
340
+ "input": 0.6,
341
+ "cache_read_input": 0.3,
342
+ "audio_input": 10.0,
343
+ "output": 2.4,
344
+ "audio_output": 20.0
345
+ },
346
+ "openai/gpt-realtime": {
347
+ "input": 4.0,
348
+ "cache_read_input": 0.4,
349
+ "audio_input": 32.0,
350
+ "output": 16.0,
351
+ "audio_output": 64.0
352
+ },
353
+ "openai/gpt-realtime-1.5": {
354
+ "input": 4.0,
355
+ "cache_read_input": 0.4,
356
+ "audio_input": 32.0,
357
+ "output": 16.0,
358
+ "audio_output": 64.0
359
+ },
360
+ "openai/gpt-realtime-mini": {
361
+ "input": 0.6,
362
+ "cache_read_input": 0.06,
363
+ "audio_input": 10.0,
364
+ "output": 2.4,
365
+ "audio_output": 20.0
366
+ },
367
+ "openai/gpt-audio-1.5": {
368
+ "input": 2.5,
369
+ "audio_input": 32.0,
370
+ "output": 10.0,
371
+ "audio_output": 64.0
372
+ },
373
+ "openai/gpt-audio-mini": {
374
+ "input": 0.6,
375
+ "audio_input": 10.0,
376
+ "output": 2.4,
377
+ "audio_output": 20.0
378
+ },
379
+ "openai/gpt-audio": {
380
+ "input": 2.5,
381
+ "audio_input": 32.0,
382
+ "output": 10.0,
383
+ "audio_output": 64.0
384
+ },
385
+ "openai/gpt-4o-audio-preview": {
386
+ "input": 2.5,
387
+ "audio_input": 40.0,
388
+ "output": 10.0,
389
+ "audio_output": 80.0
390
+ },
391
+ "openai/gpt-4o-mini-audio-preview": {
392
+ "input": 0.15,
393
+ "audio_input": 10.0,
394
+ "output": 0.6,
395
+ "audio_output": 20.0
396
+ },
312
397
  "openai/gpt-5": {
313
398
  "input": 1.25,
314
399
  "output": 10.0,
@@ -672,7 +757,7 @@
672
757
  "anthropic/claude-haiku-3-5": {
673
758
  "input": 0.8,
674
759
  "cache_write_input": 1.0,
675
- "cache_write_1h_input": 1.6,
760
+ "cache_write_extended_input": 1.6,
676
761
  "cache_read_input": 0.08,
677
762
  "output": 4.0,
678
763
  "batch_input": 0.4,
@@ -681,7 +766,7 @@
681
766
  "anthropic/claude-haiku-3": {
682
767
  "input": 0.25,
683
768
  "cache_write_input": 0.3,
684
- "cache_write_1h_input": 0.5,
769
+ "cache_write_extended_input": 0.5,
685
770
  "cache_read_input": 0.03,
686
771
  "output": 1.25,
687
772
  "batch_input": 0.125,
@@ -1,18 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "components"
3
+ require_relative "../billing/components"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Pricing
7
7
  module EffectivePrices
8
8
  class << self
9
9
  def call(usage:, prices:, pricing_mode:)
10
- quantities = usage.price_quantities
11
10
  context_tier = context_tier?(usage: usage, prices: prices)
12
11
 
13
- Pricing::COMPONENTS.to_h do |component|
14
- price_key = component.price_key
15
- tokens = quantities.fetch(price_key)
12
+ Billing::Components::TOKEN_PRICED.to_h do |component|
13
+ price_key = component.key
14
+ tokens = usage.public_send(component.token_key)
16
15
  price = if tokens.positive?
17
16
  price_for(
18
17
  prices: prices,
@@ -30,29 +29,67 @@ module LlmCostTracker
30
29
  private
31
30
 
32
31
  def price_for(prices:, key:, pricing_mode:, context_tier:)
33
- mode = Pricing.normalize_mode(pricing_mode)
34
- return contextual_price(prices: prices, key: key, context_tier: context_tier) unless mode
32
+ return contextual_price(prices: prices, key: key, context_tier: context_tier) unless pricing_mode
35
33
 
36
- contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier) ||
37
- derived_mode_price(prices: prices, key: key, mode: mode, context_tier: context_tier)
34
+ orderings = mode_orderings_for(pricing_mode)
35
+ orderings.each do |mode|
36
+ direct = contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier)
37
+ return direct if direct
38
+ end
39
+ return nil if %i[input output].include?(key)
40
+
41
+ derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
38
42
  end
39
43
 
44
+ def mode_orderings_for(pricing_mode)
45
+ mode_string = pricing_mode.to_s
46
+ return [mode_string] unless mode_string.include?("_")
47
+
48
+ tokens = tokenize_mode(mode_string)
49
+ return [mode_string] if tokens.size <= 1
50
+
51
+ [mode_string, *tokens.permutation.map { |permutation| permutation.join("_") }].uniq
52
+ end
53
+
54
+ def tokenize_mode(mode_string)
55
+ remaining = mode_string.dup
56
+ tokens = []
57
+ loop do
58
+ break if remaining.empty?
59
+
60
+ compound = COMPOUND_MODE_TOKENS.find { |token| remaining == token || remaining.start_with?("#{token}_") }
61
+ if compound
62
+ tokens << compound
63
+ remaining = remaining.delete_prefix(compound).delete_prefix("_")
64
+ else
65
+ first, _, rest = remaining.partition("_")
66
+ tokens << first
67
+ remaining = rest
68
+ end
69
+ end
70
+ tokens
71
+ end
72
+
73
+ COMPOUND_MODE_TOKENS = %w[data_residency].freeze
74
+ private_constant :COMPOUND_MODE_TOKENS
75
+
40
76
  def contextual_price(prices:, key:, context_tier:)
41
77
  return prices[key] unless context_tier
42
78
 
43
79
  prices[:"above_context_#{key}"]
44
80
  end
45
81
 
46
- def derived_mode_price(prices:, key:, mode:, context_tier:)
82
+ def derived_mode_price(prices:, key:, modes:, context_tier:)
47
83
  standard_price = contextual_price(prices: prices, key: key, context_tier: context_tier)
48
- return nil unless standard_price
49
-
50
- base_key = key == :output ? :output : :input
51
- base_price = contextual_price(prices: prices, key: base_key, context_tier: context_tier)
52
- mode_base_price = contextual_price(prices: prices, key: :"#{mode}_#{base_key}", context_tier: context_tier)
53
- return nil unless base_price && mode_base_price
84
+ base_price = contextual_price(prices: prices, key: :input, context_tier: context_tier)
85
+ return nil unless standard_price && base_price
86
+ return nil if base_price.zero?
54
87
 
55
- standard_price * (mode_base_price.to_f / base_price)
88
+ modes.each do |mode|
89
+ mode_base_price = contextual_price(prices: prices, key: :"#{mode}_input", context_tier: context_tier)
90
+ return standard_price * (mode_base_price / base_price) if mode_base_price
91
+ end
92
+ nil
56
93
  end
57
94
 
58
95
  def context_tier?(usage:, prices:)
@@ -62,8 +99,9 @@ module LlmCostTracker
62
99
  input_tokens = usage.input_tokens +
63
100
  usage.cache_read_input_tokens +
64
101
  usage.cache_write_input_tokens +
65
- usage.cache_write_1h_input_tokens
66
- input_tokens > threshold.to_i
102
+ usage.cache_write_extended_input_tokens +
103
+ usage.audio_input_tokens
104
+ input_tokens > threshold
67
105
  end
68
106
  end
69
107
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../token_usage"
3
4
  require_relative "effective_prices"
4
5
 
5
6
  module LlmCostTracker
@@ -33,7 +34,7 @@ module LlmCostTracker
33
34
 
34
35
  module Explainer
35
36
  class << self
36
- def call(provider:, model:, token_usage:, pricing_mode: nil)
37
+ def call(provider:, model:, tokens:, pricing_mode: nil)
37
38
  match = Lookup.call(provider: provider, model: model)
38
39
 
39
40
  explanation(
@@ -41,7 +42,7 @@ module LlmCostTracker
41
42
  model: model,
42
43
  pricing_mode: pricing_mode,
43
44
  match: match,
44
- usage: token_usage
45
+ usage: TokenUsage.build_from_tokens(tokens)
45
46
  )
46
47
  end
47
48
 
@@ -50,9 +51,7 @@ module LlmCostTracker
50
51
  def explanation(provider:, model:, pricing_mode:, match:, usage:)
51
52
  prices = match&.prices
52
53
  pricing_mode = Pricing.normalize_mode(pricing_mode)
53
- effective = if prices && usage
54
- EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
55
- end
54
+ effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode) if prices
56
55
 
57
56
  Explanation.new(
58
57
  provider: provider.to_s,
@@ -7,42 +7,18 @@ module LlmCostTracker
7
7
  MUTEX = Mutex.new
8
8
  CACHE_MISS = Object.new.freeze
9
9
  NO_MATCH = Object.new.freeze
10
- MAX_LOOKUP_CACHE_ENTRIES = 512
11
10
 
12
11
  class << self
13
12
  def call(provider:, model:)
14
13
  provider_name = provider.to_s.presence
15
14
  model_name = model.to_s
15
+ return nil if model_name.empty?
16
+
16
17
  cache_key = [provider_name, model_name]
17
18
  cached = cached_lookup(cache_key)
18
19
  return cached unless cached.equal?(CACHE_MISS)
19
20
 
20
- provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
21
- normalized_model = normalize_model_name(model_name)
22
- current = current_price_tables
23
-
24
- match =
25
- explain_table(
26
- table: current.fetch(:pricing_overrides),
27
- source: :pricing_overrides,
28
- provider_model: provider_model,
29
- model_name: model_name,
30
- normalized_model: normalized_model
31
- ) ||
32
- explain_table(
33
- table: current.fetch(:file_prices),
34
- source: :prices_file,
35
- provider_model: provider_model,
36
- model_name: model_name,
37
- normalized_model: normalized_model
38
- ) ||
39
- explain_table(
40
- table: Registry.builtin_prices,
41
- source: :bundled,
42
- provider_model: provider_model,
43
- model_name: model_name,
44
- normalized_model: normalized_model
45
- )
21
+ match = lookup_match(provider_name: provider_name, model_name: model_name)
46
22
  cache_lookup(cache_key, match)
47
23
  match
48
24
  end
@@ -57,6 +33,32 @@ module LlmCostTracker
57
33
 
58
34
  private
59
35
 
36
+ def lookup_match(provider_name:, model_name:)
37
+ provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
38
+ normalized_model = normalize_model_name(model_name)
39
+ current = current_price_tables
40
+
41
+ ordered_table_lookups(current).each do |source, table|
42
+ match = explain_table(
43
+ table: table,
44
+ source: source,
45
+ provider_model: provider_model,
46
+ model_name: model_name,
47
+ normalized_model: normalized_model
48
+ )
49
+ return match if match
50
+ end
51
+ nil
52
+ end
53
+
54
+ def ordered_table_lookups(current)
55
+ [
56
+ [:pricing_overrides, current.fetch(:pricing_overrides)],
57
+ [:prices_file, current.fetch(:file_prices)],
58
+ [:bundled, Registry.builtin_prices]
59
+ ]
60
+ end
61
+
60
62
  def current_price_tables
61
63
  cached = @prices_cache
62
64
  return cached if cached
@@ -67,8 +69,7 @@ module LlmCostTracker
67
69
 
68
70
  config = LlmCostTracker.configuration
69
71
  file_prices = Registry.file_prices(config.prices_file)
70
- overrides = Registry.normalize_price_table(config.pricing_overrides)
71
- value = { pricing_overrides: overrides, file_prices: file_prices }.freeze
72
+ value = { pricing_overrides: config.pricing_overrides, file_prices: file_prices }.freeze
72
73
  @prices_cache = value
73
74
  value
74
75
  end
@@ -85,7 +86,6 @@ module LlmCostTracker
85
86
  def cache_lookup(cache_key, match)
86
87
  MUTEX.synchronize do
87
88
  values = (@lookup_cache || {}).dup
88
- values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
89
89
  values[cache_key] = match || NO_MATCH
90
90
  @lookup_cache = values.freeze
91
91
  end
@@ -135,7 +135,7 @@ module LlmCostTracker
135
135
  end
136
136
 
137
137
  def match(table:, source:, key:, matched_by:)
138
- Match.new(source: source.to_s, key: key, prices: table[key], matched_by: matched_by.to_s)
138
+ Match.new(source: source, key: key, prices: table[key], matched_by: matched_by)
139
139
  end
140
140
 
141
141
  def snapshot_variant?(model, key)
@@ -147,14 +147,18 @@ module LlmCostTracker
147
147
 
148
148
  def sorted_price_keys(table)
149
149
  cached = @sorted_price_keys_cache
150
- return cached[:keys] if cached && cached[:table].equal?(table)
150
+ existing = cached && cached[table]
151
+ return existing if existing
151
152
 
152
153
  MUTEX.synchronize do
153
154
  cached = @sorted_price_keys_cache
154
- return cached[:keys] if cached && cached[:table].equal?(table)
155
+ existing = cached && cached[table]
156
+ return existing if existing
155
157
 
156
158
  keys = table.keys.sort_by { |key| -key.length }
157
- @sorted_price_keys_cache = { table: table, keys: keys }.freeze
159
+ next_cache = cached ? cached.dup : {}.compare_by_identity
160
+ next_cache[table] = keys
161
+ @sorted_price_keys_cache = next_cache.freeze
158
162
  keys
159
163
  end
160
164
  end