llm_cost_tracker 0.7.3 → 0.9.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +173 -0
  4. data/README.md +60 -220
  5. data/app/assets/llm_cost_tracker/application.css +282 -45
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
  14. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
  15. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
  16. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  17. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  18. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
  19. data/app/models/llm_cost_tracker/call.rb +166 -0
  20. data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
  21. data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
  22. data/app/models/llm_cost_tracker/call_tag.rb +12 -0
  23. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
  24. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  25. data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
  26. data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
  27. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
  28. data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
  29. data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
  30. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
  31. data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
  32. data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
  33. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
  35. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
  36. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
  37. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  38. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
  39. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  40. data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
  41. data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
  42. data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
  43. data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
  44. data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
  45. data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
  46. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  47. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  48. data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
  49. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  50. data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
  51. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
  52. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
  53. data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
  54. data/config/routes.rb +3 -2
  55. data/lib/llm_cost_tracker/billing/components.rb +95 -0
  56. data/lib/llm_cost_tracker/billing/components.yml +188 -0
  57. data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
  58. data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
  59. data/lib/llm_cost_tracker/budget.rb +26 -36
  60. data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
  61. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  62. data/lib/llm_cost_tracker/configuration.rb +86 -17
  63. data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
  64. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
  65. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
  66. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  67. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
  68. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
  69. data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
  70. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
  71. data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
  72. data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
  73. data/lib/llm_cost_tracker/doctor.rb +111 -44
  74. data/lib/llm_cost_tracker/engine.rb +9 -0
  75. data/lib/llm_cost_tracker/errors.rb +5 -19
  76. data/lib/llm_cost_tracker/event.rb +11 -3
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
  92. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
  93. data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
  94. data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
  95. data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
  96. data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
  97. data/lib/llm_cost_tracker/ingestion.rb +66 -22
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
  99. data/lib/llm_cost_tracker/integrations/base.rb +56 -32
  100. data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
  101. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
  102. data/lib/llm_cost_tracker/integrations.rb +21 -3
  103. data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
  104. data/lib/llm_cost_tracker/ledger/period.rb +5 -5
  105. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
  106. data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
  107. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
  108. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
  109. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
  110. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
  111. data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
  112. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
  113. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
  114. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
  115. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
  116. data/lib/llm_cost_tracker/ledger/store.rb +103 -20
  117. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  118. data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
  119. data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
  120. data/lib/llm_cost_tracker/ledger.rb +5 -2
  121. data/lib/llm_cost_tracker/logging.rb +2 -5
  122. data/lib/llm_cost_tracker/masking.rb +39 -0
  123. data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
  124. data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
  125. data/lib/llm_cost_tracker/parsers/base.rb +13 -4
  126. data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
  127. data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
  128. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
  129. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
  130. data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
  131. data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
  132. data/lib/llm_cost_tracker/parsers.rb +1 -1
  133. data/lib/llm_cost_tracker/prices.json +198 -22
  134. data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
  135. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
  136. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
  137. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  138. data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
  139. data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
  140. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
  141. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
  142. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
  143. data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
  144. data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
  145. data/lib/llm_cost_tracker/pricing.rb +220 -28
  146. data/lib/llm_cost_tracker/railtie.rb +6 -8
  147. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  148. data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
  149. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
  150. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  151. data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
  152. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
  153. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  154. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  155. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  156. data/lib/llm_cost_tracker/report/data.rb +19 -8
  157. data/lib/llm_cost_tracker/report.rb +0 -4
  158. data/lib/llm_cost_tracker/retention.rb +22 -9
  159. data/lib/llm_cost_tracker/tags/context.rb +2 -5
  160. data/lib/llm_cost_tracker/tags/key.rb +4 -0
  161. data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
  162. data/lib/llm_cost_tracker/timing.rb +15 -0
  163. data/lib/llm_cost_tracker/token_usage.rb +64 -42
  164. data/lib/llm_cost_tracker/tracker.rb +97 -27
  165. data/lib/llm_cost_tracker/usage_capture.rb +29 -8
  166. data/lib/llm_cost_tracker/version.rb +1 -1
  167. data/lib/llm_cost_tracker.rb +45 -35
  168. data/lib/tasks/llm_cost_tracker.rake +45 -17
  169. metadata +71 -41
  170. data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
  171. data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
  172. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
  173. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
  174. data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
  175. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
  176. data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
  182. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
  183. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
  184. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
  185. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
  186. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
  187. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
  188. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
  189. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
  190. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
  191. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
  192. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
  193. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
  194. data/lib/llm_cost_tracker/pricing/components.rb +0 -37
  195. 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-08",
4
4
  "currency": "USD",
5
5
  "unit": "1M tokens",
6
6
  "source_urls": [
@@ -15,13 +15,26 @@
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
+ "web_fetch_request": 0.0,
22
+ "code_execution_hour": 0.05
23
+ },
24
+ "openai": {
25
+ "web_search_request": 10.0,
26
+ "web_search_preview_request_reasoning": 10.0,
27
+ "web_search_preview_request_non_reasoning": 25.0,
28
+ "file_search_call": 2.5
29
+ }
30
+ },
18
31
  "models": {
19
32
  "anthropic/claude-haiku-4-5": {
20
33
  "input": 1.0,
21
34
  "output": 5.0,
22
35
  "cache_read_input": 0.1,
23
36
  "cache_write_input": 1.25,
24
- "cache_write_1h_input": 2.0,
37
+ "cache_write_extended_input": 2.0,
25
38
  "batch_input": 0.5,
26
39
  "batch_output": 2.5
27
40
  },
@@ -30,7 +43,7 @@
30
43
  "output": 75.0,
31
44
  "cache_read_input": 1.5,
32
45
  "cache_write_input": 18.75,
33
- "cache_write_1h_input": 30.0,
46
+ "cache_write_extended_input": 30.0,
34
47
  "batch_input": 7.5,
35
48
  "batch_output": 37.5
36
49
  },
@@ -39,7 +52,7 @@
39
52
  "output": 75.0,
40
53
  "cache_read_input": 1.5,
41
54
  "cache_write_input": 18.75,
42
- "cache_write_1h_input": 30.0,
55
+ "cache_write_extended_input": 30.0,
43
56
  "batch_input": 7.5,
44
57
  "batch_output": 37.5
45
58
  },
@@ -48,7 +61,7 @@
48
61
  "output": 25.0,
49
62
  "cache_read_input": 0.5,
50
63
  "cache_write_input": 6.25,
51
- "cache_write_1h_input": 10.0,
64
+ "cache_write_extended_input": 10.0,
52
65
  "batch_input": 2.5,
53
66
  "batch_output": 12.5
54
67
  },
@@ -57,24 +70,24 @@
57
70
  "output": 25.0,
58
71
  "cache_read_input": 0.5,
59
72
  "cache_write_input": 6.25,
60
- "cache_write_1h_input": 10.0,
73
+ "cache_write_extended_input": 10.0,
61
74
  "batch_input": 2.5,
62
75
  "batch_output": 12.5,
63
76
  "data_residency_input": 5.5,
64
77
  "data_residency_cache_write_input": 6.875,
65
- "data_residency_cache_write_1h_input": 11.0,
78
+ "data_residency_cache_write_extended_input": 11.0,
66
79
  "data_residency_cache_read_input": 0.55,
67
80
  "data_residency_output": 27.5,
68
81
  "data_residency_batch_input": 2.75,
69
82
  "data_residency_batch_output": 13.75,
70
83
  "fast_input": 30.0,
71
84
  "fast_cache_write_input": 37.5,
72
- "fast_cache_write_1h_input": 60.0,
85
+ "fast_cache_write_extended_input": 60.0,
73
86
  "fast_cache_read_input": 3.0,
74
87
  "fast_output": 150.0,
75
88
  "fast_data_residency_input": 33.0,
76
89
  "fast_data_residency_cache_write_input": 41.25,
77
- "fast_data_residency_cache_write_1h_input": 66.0,
90
+ "fast_data_residency_cache_write_extended_input": 66.0,
78
91
  "fast_data_residency_cache_read_input": 3.3,
79
92
  "fast_data_residency_output": 165.0
80
93
  },
@@ -83,12 +96,12 @@
83
96
  "output": 25.0,
84
97
  "cache_read_input": 0.5,
85
98
  "cache_write_input": 6.25,
86
- "cache_write_1h_input": 10.0,
99
+ "cache_write_extended_input": 10.0,
87
100
  "batch_input": 2.5,
88
101
  "batch_output": 12.5,
89
102
  "data_residency_input": 5.5,
90
103
  "data_residency_cache_write_input": 6.875,
91
- "data_residency_cache_write_1h_input": 11.0,
104
+ "data_residency_cache_write_extended_input": 11.0,
92
105
  "data_residency_cache_read_input": 0.55,
93
106
  "data_residency_output": 27.5,
94
107
  "data_residency_batch_input": 2.75,
@@ -99,7 +112,7 @@
99
112
  "output": 15.0,
100
113
  "cache_read_input": 0.3,
101
114
  "cache_write_input": 3.75,
102
- "cache_write_1h_input": 6.0,
115
+ "cache_write_extended_input": 6.0,
103
116
  "batch_input": 1.5,
104
117
  "batch_output": 7.5
105
118
  },
@@ -108,7 +121,7 @@
108
121
  "output": 15.0,
109
122
  "cache_read_input": 0.3,
110
123
  "cache_write_input": 3.75,
111
- "cache_write_1h_input": 6.0,
124
+ "cache_write_extended_input": 6.0,
112
125
  "batch_input": 1.5,
113
126
  "batch_output": 7.5
114
127
  },
@@ -117,12 +130,12 @@
117
130
  "output": 15.0,
118
131
  "cache_read_input": 0.3,
119
132
  "cache_write_input": 3.75,
120
- "cache_write_1h_input": 6.0,
133
+ "cache_write_extended_input": 6.0,
121
134
  "batch_input": 1.5,
122
135
  "batch_output": 7.5,
123
136
  "data_residency_input": 3.3,
124
137
  "data_residency_cache_write_input": 4.125,
125
- "data_residency_cache_write_1h_input": 6.6,
138
+ "data_residency_cache_write_extended_input": 6.6,
126
139
  "data_residency_cache_read_input": 0.33,
127
140
  "data_residency_output": 16.5,
128
141
  "data_residency_batch_input": 1.65,
@@ -130,10 +143,12 @@
130
143
  },
131
144
  "gemini/gemini-2.0-flash": {
132
145
  "input": 0.1,
133
- "cache_read_input": 0.025,
134
146
  "output": 0.4,
147
+ "audio_input": 0.7,
148
+ "cache_read_input": 0.025,
135
149
  "batch_input": 0.05,
136
150
  "batch_output": 0.2,
151
+ "batch_audio_input": 0.35,
137
152
  "batch_cache_read_input": 0.025
138
153
  },
139
154
  "gemini/gemini-2.0-flash-lite": {
@@ -145,44 +160,52 @@
145
160
  "gemini/gemini-2.5-flash": {
146
161
  "input": 0.3,
147
162
  "output": 2.5,
163
+ "audio_input": 1.0,
148
164
  "cache_read_input": 0.03,
149
165
  "batch_input": 0.15,
150
166
  "batch_output": 1.25,
167
+ "batch_audio_input": 0.5,
151
168
  "batch_cache_read_input": 0.03,
152
169
  "flex_input": 0.15,
153
170
  "flex_output": 1.25,
171
+ "flex_audio_input": 0.5,
154
172
  "flex_cache_read_input": 0.03,
155
173
  "priority_input": 0.54,
156
174
  "priority_output": 4.5,
175
+ "priority_audio_input": 1.8,
157
176
  "priority_cache_read_input": 0.054
158
177
  },
159
178
  "gemini/gemini-2.5-flash-lite": {
160
179
  "input": 0.1,
161
180
  "output": 0.4,
181
+ "audio_input": 0.3,
162
182
  "cache_read_input": 0.01,
163
183
  "batch_input": 0.05,
164
184
  "batch_output": 0.2,
185
+ "batch_audio_input": 0.15,
165
186
  "batch_cache_read_input": 0.01,
166
187
  "flex_input": 0.05,
167
188
  "flex_output": 0.2,
189
+ "flex_audio_input": 0.15,
168
190
  "flex_cache_read_input": 0.01,
169
191
  "priority_input": 0.18,
170
192
  "priority_output": 0.72,
193
+ "priority_audio_input": 0.54,
171
194
  "priority_cache_read_input": 0.018
172
195
  },
173
196
  "gemini/gemini-2.5-pro": {
174
197
  "input": 1.25,
175
198
  "output": 10.0,
176
- "cache_read_input": 0.125,
177
- "batch_input": 0.625,
178
- "batch_output": 5.0,
179
- "batch_cache_read_input": 0.125,
180
199
  "_context_price_threshold_tokens": 200000,
181
200
  "above_context_input": 2.5,
182
201
  "above_context_output": 15.0,
202
+ "cache_read_input": 0.125,
183
203
  "above_context_cache_read_input": 0.25,
204
+ "batch_input": 0.625,
205
+ "batch_output": 5.0,
184
206
  "above_context_batch_input": 1.25,
185
207
  "above_context_batch_output": 7.5,
208
+ "batch_cache_read_input": 0.125,
186
209
  "above_context_batch_cache_read_input": 0.25,
187
210
  "flex_input": 0.625,
188
211
  "flex_output": 5.0,
@@ -309,6 +332,141 @@
309
332
  "priority_output": 1.0,
310
333
  "priority_cache_read_input": 0.125
311
334
  },
335
+ "openai/gpt-4o-realtime-preview": {
336
+ "input": 5.0,
337
+ "cache_read_input": 2.5,
338
+ "audio_input": 40.0,
339
+ "output": 20.0,
340
+ "audio_output": 80.0
341
+ },
342
+ "openai/gpt-4o-mini-realtime-preview": {
343
+ "input": 0.6,
344
+ "cache_read_input": 0.3,
345
+ "audio_input": 10.0,
346
+ "output": 2.4,
347
+ "audio_output": 20.0
348
+ },
349
+ "openai/gpt-realtime": {
350
+ "input": 4.0,
351
+ "cache_read_input": 0.4,
352
+ "audio_input": 32.0,
353
+ "output": 16.0,
354
+ "audio_output": 64.0
355
+ },
356
+ "openai/gpt-realtime-1.5": {
357
+ "input": 4.0,
358
+ "cache_read_input": 0.4,
359
+ "audio_input": 32.0,
360
+ "output": 16.0,
361
+ "audio_output": 64.0
362
+ },
363
+ "openai/gpt-realtime-mini": {
364
+ "input": 0.6,
365
+ "cache_read_input": 0.06,
366
+ "audio_input": 10.0,
367
+ "output": 2.4,
368
+ "audio_output": 20.0
369
+ },
370
+ "openai/gpt-audio-1.5": {
371
+ "input": 2.5,
372
+ "audio_input": 32.0,
373
+ "output": 10.0,
374
+ "audio_output": 64.0
375
+ },
376
+ "openai/gpt-audio-mini": {
377
+ "input": 0.6,
378
+ "audio_input": 10.0,
379
+ "output": 2.4,
380
+ "audio_output": 20.0
381
+ },
382
+ "openai/gpt-audio": {
383
+ "input": 2.5,
384
+ "audio_input": 32.0,
385
+ "output": 10.0,
386
+ "audio_output": 64.0
387
+ },
388
+ "openai/gpt-4o-audio-preview": {
389
+ "input": 2.5,
390
+ "audio_input": 40.0,
391
+ "output": 10.0,
392
+ "audio_output": 80.0
393
+ },
394
+ "openai/gpt-4o-mini-audio-preview": {
395
+ "input": 0.15,
396
+ "audio_input": 10.0,
397
+ "output": 0.6,
398
+ "audio_output": 20.0
399
+ },
400
+ "openai/text-embedding-3-small": {
401
+ "input": 0.02,
402
+ "batch_input": 0.01
403
+ },
404
+ "openai/text-embedding-3-large": {
405
+ "input": 0.13,
406
+ "batch_input": 0.065
407
+ },
408
+ "openai/text-embedding-ada-002": {
409
+ "input": 0.10,
410
+ "batch_input": 0.05
411
+ },
412
+ "openai/gpt-4o-transcribe": {
413
+ "input": 2.5,
414
+ "audio_input": 6.0,
415
+ "output": 10.0
416
+ },
417
+ "openai/gpt-4o-mini-transcribe": {
418
+ "input": 1.25,
419
+ "audio_input": 3.0,
420
+ "output": 5.0
421
+ },
422
+ "openai/tts-1": {
423
+ "text_to_speech_character": 15.0
424
+ },
425
+ "openai/tts-1-hd": {
426
+ "text_to_speech_character": 30.0
427
+ },
428
+ "openai/gpt-image-1": {
429
+ "input": 5.0,
430
+ "cache_read_input": 1.25,
431
+ "image_input": 10.0,
432
+ "image_output": 40.0,
433
+ "batch_input": 2.5,
434
+ "batch_cache_read_input": 0.63,
435
+ "batch_image_input": 5.0,
436
+ "batch_image_output": 20.0
437
+ },
438
+ "openai/gpt-image-1-mini": {
439
+ "input": 2.0,
440
+ "cache_read_input": 0.2,
441
+ "image_input": 2.5,
442
+ "image_output": 8.0,
443
+ "batch_input": 1.0,
444
+ "batch_cache_read_input": 0.1,
445
+ "batch_image_input": 1.25,
446
+ "batch_image_output": 4.0
447
+ },
448
+ "openai/gpt-image-1.5": {
449
+ "input": 5.0,
450
+ "cache_read_input": 1.25,
451
+ "output": 10.0,
452
+ "image_input": 8.0,
453
+ "image_output": 32.0,
454
+ "batch_input": 2.5,
455
+ "batch_cache_read_input": 0.63,
456
+ "batch_output": 5.0,
457
+ "batch_image_input": 4.0,
458
+ "batch_image_output": 16.0
459
+ },
460
+ "openai/gpt-image-2": {
461
+ "input": 5.0,
462
+ "cache_read_input": 1.25,
463
+ "image_input": 8.0,
464
+ "image_output": 30.0,
465
+ "batch_input": 2.5,
466
+ "batch_cache_read_input": 0.625,
467
+ "batch_image_input": 4.0,
468
+ "batch_image_output": 15.0
469
+ },
312
470
  "openai/gpt-5": {
313
471
  "input": 1.25,
314
472
  "output": 10.0,
@@ -672,7 +830,7 @@
672
830
  "anthropic/claude-haiku-3-5": {
673
831
  "input": 0.8,
674
832
  "cache_write_input": 1.0,
675
- "cache_write_1h_input": 1.6,
833
+ "cache_write_extended_input": 1.6,
676
834
  "cache_read_input": 0.08,
677
835
  "output": 4.0,
678
836
  "batch_input": 0.4,
@@ -681,7 +839,7 @@
681
839
  "anthropic/claude-haiku-3": {
682
840
  "input": 0.25,
683
841
  "cache_write_input": 0.3,
684
- "cache_write_1h_input": 0.5,
842
+ "cache_write_extended_input": 0.5,
685
843
  "cache_read_input": 0.03,
686
844
  "output": 1.25,
687
845
  "batch_input": 0.125,
@@ -720,6 +878,24 @@
720
878
  "input": 1.5,
721
879
  "output": 6.0,
722
880
  "cache_read_input": 0.375
881
+ },
882
+ "gemini/gemini-3.1-flash-lite": {
883
+ "input": 0.25,
884
+ "output": 1.5,
885
+ "audio_input": 0.5,
886
+ "cache_read_input": 0.025,
887
+ "batch_input": 0.125,
888
+ "batch_output": 0.75,
889
+ "batch_audio_input": 0.25,
890
+ "batch_cache_read_input": 0.0125,
891
+ "flex_input": 0.125,
892
+ "flex_output": 0.75,
893
+ "flex_audio_input": 0.25,
894
+ "flex_cache_read_input": 0.0125,
895
+ "priority_input": 0.45,
896
+ "priority_output": 2.7,
897
+ "priority_audio_input": 0.9,
898
+ "priority_cache_read_input": 0.045
723
899
  }
724
900
  }
725
901
  }
@@ -1,23 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "components"
3
+ require_relative "../billing/components"
4
+ require_relative "mode"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Pricing
7
8
  module EffectivePrices
8
9
  class << self
9
10
  def call(usage:, prices:, pricing_mode:)
10
- quantities = usage.price_quantities
11
11
  context_tier = context_tier?(usage: usage, prices: prices)
12
+ orderings = pricing_mode && Mode.parse(pricing_mode).permutations
12
13
 
13
- Pricing::COMPONENTS.to_h do |component|
14
- price_key = component.price_key
15
- tokens = quantities.fetch(price_key)
14
+ Billing::Components::TOKEN_PRICED.to_h do |component|
15
+ price_key = component.key
16
+ tokens = usage.public_send(component.token_key)
16
17
  price = if tokens.positive?
17
18
  price_for(
18
19
  prices: prices,
19
20
  key: price_key,
20
- pricing_mode: pricing_mode,
21
+ orderings: orderings,
21
22
  context_tier: context_tier
22
23
  )
23
24
  else
@@ -29,12 +30,16 @@ module LlmCostTracker
29
30
 
30
31
  private
31
32
 
32
- 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
33
+ def price_for(prices:, key:, orderings:, context_tier:)
34
+ return contextual_price(prices: prices, key: key, context_tier: context_tier) unless orderings
35
35
 
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)
36
+ orderings.each do |mode|
37
+ direct = contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier)
38
+ return direct if direct
39
+ end
40
+ return nil if %i[input output].include?(key)
41
+
42
+ derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
38
43
  end
39
44
 
40
45
  def contextual_price(prices:, key:, context_tier:)
@@ -43,16 +48,17 @@ module LlmCostTracker
43
48
  prices[:"above_context_#{key}"]
44
49
  end
45
50
 
46
- def derived_mode_price(prices:, key:, mode:, context_tier:)
51
+ def derived_mode_price(prices:, key:, modes:, context_tier:)
47
52
  standard_price = contextual_price(prices: prices, key: key, context_tier: context_tier)
48
- return nil unless standard_price
53
+ base_price = contextual_price(prices: prices, key: :input, context_tier: context_tier)
54
+ return nil unless standard_price && base_price
55
+ return nil if base_price.zero?
49
56
 
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
54
-
55
- standard_price * (mode_base_price.to_f / base_price)
57
+ modes.each do |mode|
58
+ mode_base_price = contextual_price(prices: prices, key: :"#{mode}_input", context_tier: context_tier)
59
+ return standard_price * (mode_base_price / base_price) if mode_base_price
60
+ end
61
+ nil
56
62
  end
57
63
 
58
64
  def context_tier?(usage:, prices:)
@@ -62,8 +68,9 @@ module LlmCostTracker
62
68
  input_tokens = usage.input_tokens +
63
69
  usage.cache_read_input_tokens +
64
70
  usage.cache_write_input_tokens +
65
- usage.cache_write_1h_input_tokens
66
- input_tokens > threshold.to_i
71
+ usage.cache_write_extended_input_tokens +
72
+ usage.audio_input_tokens
73
+ input_tokens > threshold
67
74
  end
68
75
  end
69
76
  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,55 +7,89 @@ 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
10
+ LOOKUP_CACHE_LIMIT = 2_048
11
+ PRICE_FILE_RECHECK_INTERVAL = 1.0
12
+ private_constant :PRICE_FILE_RECHECK_INTERVAL
11
13
 
12
14
  class << self
13
15
  def call(provider:, model:)
14
16
  provider_name = provider.to_s.presence
15
17
  model_name = model.to_s
18
+ return nil if model_name.empty?
19
+
20
+ invalidate_cache_if_prices_file_changed!
21
+
16
22
  cache_key = [provider_name, model_name]
17
23
  cached = cached_lookup(cache_key)
18
24
  return cached unless cached.equal?(CACHE_MISS)
19
25
 
26
+ match = lookup_match(provider_name: provider_name, model_name: model_name)
27
+ cache_lookup(cache_key, match)
28
+ match
29
+ end
30
+
31
+ def reset!
32
+ MUTEX.synchronize do
33
+ reset_prices_caches!(signature: nil)
34
+ @prices_file_last_check_at = nil
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def invalidate_cache_if_prices_file_changed!
41
+ path = LlmCostTracker.configuration.prices_file
42
+
43
+ unless path
44
+ return if @prices_file_signature.nil?
45
+
46
+ MUTEX.synchronize { reset_prices_caches!(signature: nil) unless @prices_file_signature.nil? }
47
+ return
48
+ end
49
+
50
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
+ last_check = @prices_file_last_check_at
52
+ return if last_check && (now - last_check) < PRICE_FILE_RECHECK_INTERVAL
53
+
54
+ signature = File.exist?(path) ? File.mtime(path) : nil
55
+ MUTEX.synchronize do
56
+ @prices_file_last_check_at = now
57
+ reset_prices_caches!(signature: signature) if @prices_file_signature != signature
58
+ end
59
+ end
60
+
61
+ def reset_prices_caches!(signature:)
62
+ @prices_cache = nil
63
+ @lookup_cache = nil
64
+ @sorted_price_keys_cache = nil
65
+ @prices_file_signature = signature
66
+ end
67
+
68
+ def lookup_match(provider_name:, model_name:)
20
69
  provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
21
70
  normalized_model = normalize_model_name(model_name)
22
71
  current = current_price_tables
23
72
 
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,
73
+ ordered_table_lookups(current).each do |source, table|
74
+ match = explain_table(
75
+ table: table,
76
+ source: source,
42
77
  provider_model: provider_model,
43
78
  model_name: model_name,
44
79
  normalized_model: normalized_model
45
80
  )
46
- cache_lookup(cache_key, match)
47
- match
48
- end
49
-
50
- def reset!
51
- MUTEX.synchronize do
52
- @prices_cache = nil
53
- @lookup_cache = nil
54
- @sorted_price_keys_cache = nil
81
+ return match if match
55
82
  end
83
+ nil
56
84
  end
57
85
 
58
- private
86
+ def ordered_table_lookups(current)
87
+ [
88
+ [:pricing_overrides, current.fetch(:pricing_overrides)],
89
+ [:prices_file, current.fetch(:file_prices)],
90
+ [:bundled, Registry.builtin_prices]
91
+ ]
92
+ end
59
93
 
60
94
  def current_price_tables
61
95
  cached = @prices_cache
@@ -67,8 +101,7 @@ module LlmCostTracker
67
101
 
68
102
  config = LlmCostTracker.configuration
69
103
  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
104
+ value = { pricing_overrides: config.pricing_overrides, file_prices: file_prices }.freeze
72
105
  @prices_cache = value
73
106
  value
74
107
  end
@@ -85,7 +118,7 @@ module LlmCostTracker
85
118
  def cache_lookup(cache_key, match)
86
119
  MUTEX.synchronize do
87
120
  values = (@lookup_cache || {}).dup
88
- values.clear if values.size >= MAX_LOOKUP_CACHE_ENTRIES
121
+ values.shift while values.size >= LOOKUP_CACHE_LIMIT
89
122
  values[cache_key] = match || NO_MATCH
90
123
  @lookup_cache = values.freeze
91
124
  end
@@ -135,7 +168,7 @@ module LlmCostTracker
135
168
  end
136
169
 
137
170
  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)
171
+ Match.new(source: source, key: key, prices: table[key], matched_by: matched_by)
139
172
  end
140
173
 
141
174
  def snapshot_variant?(model, key)
@@ -147,14 +180,18 @@ module LlmCostTracker
147
180
 
148
181
  def sorted_price_keys(table)
149
182
  cached = @sorted_price_keys_cache
150
- return cached[:keys] if cached && cached[:table].equal?(table)
183
+ existing = cached && cached[table]
184
+ return existing if existing
151
185
 
152
186
  MUTEX.synchronize do
153
187
  cached = @sorted_price_keys_cache
154
- return cached[:keys] if cached && cached[:table].equal?(table)
188
+ existing = cached && cached[table]
189
+ return existing if existing
155
190
 
156
191
  keys = table.keys.sort_by { |key| -key.length }
157
- @sorted_price_keys_cache = { table: table, keys: keys }.freeze
192
+ next_cache = cached ? cached.dup : {}.compare_by_identity
193
+ next_cache[table] = keys
194
+ @sorted_price_keys_cache = next_cache.freeze
158
195
  keys
159
196
  end
160
197
  end