llm_cost_tracker 0.11.0 → 0.12.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/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Usage
5
+ Dimension = Data.define(
6
+ :key, :kind, :direction, :modality, :cache_state, :unit, :rate_basis
7
+ ) do
8
+ def token?
9
+ unit == "token"
10
+ end
11
+
12
+ def token_key
13
+ :"#{key}_tokens" if token?
14
+ end
15
+
16
+ def cost_key
17
+ :"#{key}_cost" if token?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -4,9 +4,6 @@
4
4
  modality: text
5
5
  cache_state: none
6
6
  unit: token
7
- category: token
8
- token_key: input_tokens
9
- cost_key: input_cost
10
7
 
11
8
  - key: cache_read_input
12
9
  kind: text_token
@@ -14,9 +11,6 @@
14
11
  modality: text
15
12
  cache_state: read
16
13
  unit: token
17
- category: token
18
- token_key: cache_read_input_tokens
19
- cost_key: cache_read_input_cost
20
14
 
21
15
  - key: cache_write_input
22
16
  kind: text_token
@@ -24,9 +18,6 @@
24
18
  modality: text
25
19
  cache_state: write_default
26
20
  unit: token
27
- category: token
28
- token_key: cache_write_input_tokens
29
- cost_key: cache_write_input_cost
30
21
 
31
22
  - key: cache_write_extended_input
32
23
  kind: text_token
@@ -34,9 +25,6 @@
34
25
  modality: text
35
26
  cache_state: write_extended
36
27
  unit: token
37
- category: token
38
- token_key: cache_write_extended_input_tokens
39
- cost_key: cache_write_extended_input_cost
40
28
 
41
29
  - key: output
42
30
  kind: text_token
@@ -44,9 +32,6 @@
44
32
  modality: text
45
33
  cache_state: none
46
34
  unit: token
47
- category: token
48
- token_key: output_tokens
49
- cost_key: output_cost
50
35
 
51
36
  - key: audio_input
52
37
  kind: audio_token
@@ -54,9 +39,6 @@
54
39
  modality: audio
55
40
  cache_state: none
56
41
  unit: token
57
- category: token
58
- token_key: audio_input_tokens
59
- cost_key: audio_input_cost
60
42
 
61
43
  - key: audio_output
62
44
  kind: audio_token
@@ -64,9 +46,6 @@
64
46
  modality: audio
65
47
  cache_state: none
66
48
  unit: token
67
- category: token
68
- token_key: audio_output_tokens
69
- cost_key: audio_output_cost
70
49
 
71
50
  - key: image_input
72
51
  kind: image_token
@@ -74,9 +53,6 @@
74
53
  modality: image
75
54
  cache_state: none
76
55
  unit: token
77
- category: token
78
- token_key: image_input_tokens
79
- cost_key: image_input_cost
80
56
 
81
57
  - key: image_output
82
58
  kind: image_token
@@ -84,9 +60,6 @@
84
60
  modality: image
85
61
  cache_state: none
86
62
  unit: token
87
- category: token
88
- token_key: image_output_tokens
89
- cost_key: image_output_cost
90
63
 
91
64
  - key: web_search_request
92
65
  kind: web_search_request
@@ -94,7 +67,6 @@
94
67
  modality: text
95
68
  cache_state: none
96
69
  unit: request
97
- category: tool
98
70
  rate_basis: per_1k_requests
99
71
 
100
72
  - key: web_search_preview_request_reasoning
@@ -103,7 +75,6 @@
103
75
  modality: text
104
76
  cache_state: none
105
77
  unit: request
106
- category: tool
107
78
  rate_basis: per_1k_requests
108
79
 
109
80
  - key: web_search_preview_request_non_reasoning
@@ -112,7 +83,6 @@
112
83
  modality: text
113
84
  cache_state: none
114
85
  unit: request
115
- category: tool
116
86
  rate_basis: per_1k_requests
117
87
 
118
88
  - key: web_fetch_request
@@ -121,7 +91,6 @@
121
91
  modality: text
122
92
  cache_state: none
123
93
  unit: request
124
- category: tool
125
94
  rate_basis: per_1k_requests
126
95
 
127
96
  - key: file_search_call
@@ -130,7 +99,6 @@
130
99
  modality: text
131
100
  cache_state: none
132
101
  unit: request
133
- category: tool
134
102
  rate_basis: per_1k_requests
135
103
 
136
104
  - key: container_session
@@ -139,25 +107,14 @@
139
107
  modality: none
140
108
  cache_state: none
141
109
  unit: session
142
- category: runtime
143
110
  rate_basis: per_session
144
111
 
145
- - key: code_execution_request
146
- kind: code_execution_request
147
- direction: neither
148
- modality: none
149
- cache_state: none
150
- unit: request
151
- category: runtime
152
- rate_basis: per_1k_requests
153
-
154
112
  - key: code_execution_hour
155
113
  kind: code_execution_hour
156
114
  direction: neither
157
115
  modality: none
158
116
  cache_state: none
159
117
  unit: hour
160
- category: runtime
161
118
  rate_basis: per_hour
162
119
 
163
120
  - key: grounding_request
@@ -166,7 +123,6 @@
166
123
  modality: text
167
124
  cache_state: none
168
125
  unit: request
169
- category: tool
170
126
  rate_basis: per_1k_requests
171
127
 
172
128
  - key: text_to_speech_character
@@ -175,14 +131,36 @@
175
131
  modality: audio
176
132
  cache_state: none
177
133
  unit: character
178
- category: tool
179
134
  rate_basis: per_million_characters
180
135
 
136
+ - key: transcription_minute
137
+ kind: transcription_minute
138
+ direction: input
139
+ modality: audio
140
+ cache_state: none
141
+ unit: minute
142
+ rate_basis: per_minute
143
+
144
+ - key: image_generation_call
145
+ kind: image_generation_call
146
+ direction: output
147
+ modality: image
148
+ cache_state: none
149
+ unit: image
150
+ rate_basis: per_image
151
+
152
+ - key: computer_call
153
+ kind: computer_call
154
+ direction: neither
155
+ modality: text
156
+ cache_state: none
157
+ unit: request
158
+ rate_basis: per_request
159
+
181
160
  - key: mcp_call
182
161
  kind: mcp_call
183
162
  direction: neither
184
163
  modality: text
185
164
  cache_state: none
186
165
  unit: request
187
- category: tool
188
166
  rate_basis: per_request
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Usage
5
+ module Source
6
+ MANUAL = "manual"
7
+ UNKNOWN = "unknown"
8
+ RESPONSE = "response"
9
+ STREAM_FINAL = "stream_final"
10
+ SDK_RESPONSE = "sdk_response"
11
+ SDK_BATCH_RESULT = "sdk_batch_result"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "catalog"
4
+
5
+ module LlmCostTracker
6
+ module Usage
7
+ KNOWN_TOKEN_KEYS = (
8
+ Catalog.token_priced.map { |dimension| dimension.token_key.to_s } + %w[total_tokens hidden_output_tokens]
9
+ ).freeze
10
+
11
+ TokenUsage = Data.define(
12
+ :input_tokens,
13
+ :cache_read_input_tokens,
14
+ :cache_write_input_tokens,
15
+ :cache_write_extended_input_tokens,
16
+ :audio_input_tokens,
17
+ :image_input_tokens,
18
+ :output_tokens,
19
+ :audio_output_tokens,
20
+ :image_output_tokens,
21
+ :total_tokens,
22
+ :hidden_output_tokens
23
+ ) do
24
+ def priced_quantities
25
+ Catalog.token_priced.to_h { |dimension| [dimension.key, public_send(dimension.token_key)] }
26
+ end
27
+
28
+ def self.build_from_tokens(tokens)
29
+ return tokens if tokens.is_a?(self)
30
+ raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
31
+
32
+ values = tokens.to_h.transform_keys(&:to_s)
33
+ warn_on_unknown_keys(values)
34
+ token_attributes = Catalog.token_priced.to_h do |dimension|
35
+ [dimension.token_key, values.fetch(dimension.token_key.to_s, 0)]
36
+ end
37
+
38
+ build(
39
+ **token_attributes,
40
+ total_tokens: values["total_tokens"],
41
+ hidden_output_tokens: values.fetch("hidden_output_tokens", 0)
42
+ )
43
+ end
44
+
45
+ def self.warn_on_unknown_keys(values)
46
+ return if values.empty?
47
+ return if values.keys.intersect?(KNOWN_TOKEN_KEYS)
48
+
49
+ Logging.warn(
50
+ "tokens hash contains no recognized keys (#{values.keys.inspect}); " \
51
+ "expected one of #{KNOWN_TOKEN_KEYS.inspect}. Did you pass a raw provider response?"
52
+ )
53
+ end
54
+
55
+ def self.non_negative_int(value)
56
+ [value.to_i, 0].max
57
+ end
58
+
59
+ def self.build(input_tokens:,
60
+ output_tokens:,
61
+ cache_read_input_tokens: 0,
62
+ cache_write_input_tokens: 0,
63
+ cache_write_extended_input_tokens: 0,
64
+ audio_input_tokens: 0,
65
+ audio_output_tokens: 0,
66
+ image_input_tokens: 0,
67
+ image_output_tokens: 0,
68
+ total_tokens: nil,
69
+ hidden_output_tokens: 0)
70
+ input = non_negative_int(input_tokens)
71
+ output = non_negative_int(output_tokens)
72
+ cache_read = non_negative_int(cache_read_input_tokens)
73
+ cache_write = non_negative_int(cache_write_input_tokens)
74
+ cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
75
+ audio_input = non_negative_int(audio_input_tokens)
76
+ audio_output = non_negative_int(audio_output_tokens)
77
+ image_input = non_negative_int(image_input_tokens)
78
+ image_output = non_negative_int(image_output_tokens)
79
+ hidden_output = non_negative_int(hidden_output_tokens)
80
+ calculated_total = input + cache_read + cache_write + cache_write_extended +
81
+ audio_input + image_input + output + audio_output + image_output
82
+ total = total_tokens ? [non_negative_int(total_tokens), calculated_total].max : calculated_total
83
+
84
+ new(
85
+ input_tokens: input,
86
+ cache_read_input_tokens: cache_read,
87
+ cache_write_input_tokens: cache_write,
88
+ cache_write_extended_input_tokens: cache_write_extended,
89
+ audio_input_tokens: audio_input,
90
+ image_input_tokens: image_input,
91
+ output_tokens: output,
92
+ audio_output_tokens: audio_output,
93
+ image_output_tokens: image_output,
94
+ total_tokens: total,
95
+ hidden_output_tokens: hidden_output
96
+ )
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -1,13 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails"
4
- require "active_support"
5
4
  require "active_support/core_ext/object/blank"
6
- require "active_support/core_ext/object/deep_dup"
7
5
  require "active_support/core_ext/object/try"
8
- require "active_support/core_ext/hash/indifferent_access"
9
- require "active_support/core_ext/string/inflections"
10
- require "active_support/notifications"
11
6
 
12
7
  require_relative "llm_cost_tracker/version"
13
8
  require_relative "llm_cost_tracker/configuration"
@@ -16,32 +11,34 @@ require_relative "llm_cost_tracker/logging"
16
11
  require_relative "llm_cost_tracker/tags/key"
17
12
  require_relative "llm_cost_tracker/tags/context"
18
13
  require_relative "llm_cost_tracker/tags/sanitizer"
19
- require_relative "llm_cost_tracker/masking"
20
- require_relative "llm_cost_tracker/token_usage"
21
- require_relative "llm_cost_tracker/billing/components"
22
- require_relative "llm_cost_tracker/billing/line_item"
23
- require_relative "llm_cost_tracker/billing/cost_status"
14
+ require_relative "llm_cost_tracker/currency"
15
+ require_relative "llm_cost_tracker/usage/catalog"
16
+ require_relative "llm_cost_tracker/usage/token_usage"
17
+ require_relative "llm_cost_tracker/usage/source"
18
+ require_relative "llm_cost_tracker/pricing/rate"
19
+ require_relative "llm_cost_tracker/charges/cost"
20
+ require_relative "llm_cost_tracker/charges/line_item"
21
+ require_relative "llm_cost_tracker/charges/cost_status"
24
22
  require_relative "llm_cost_tracker/event"
25
23
  require_relative "llm_cost_tracker/pricing"
26
24
  require_relative "llm_cost_tracker/parsers"
27
25
  require_relative "llm_cost_tracker/middleware/faraday"
28
26
  require_relative "llm_cost_tracker/integrations"
29
27
  require_relative "llm_cost_tracker/budget"
30
- require_relative "llm_cost_tracker/pricing/unknown"
31
28
  require_relative "llm_cost_tracker/ledger"
32
29
  require_relative "llm_cost_tracker/ingestion"
33
30
  require_relative "llm_cost_tracker/tracker"
34
31
 
35
32
  module LlmCostTracker
36
- autoload :Engine, "llm_cost_tracker/engine"
37
- autoload :Reconciliation, "llm_cost_tracker/reconciliation"
38
- autoload :ReconcileTasks, "llm_cost_tracker/reconcile_tasks"
39
- autoload :Doctor, "llm_cost_tracker/doctor"
40
- autoload :Report, "llm_cost_tracker/report"
41
- autoload :Retention, "llm_cost_tracker/retention"
33
+ autoload :Engine, "llm_cost_tracker/engine"
34
+ autoload :Doctor, "llm_cost_tracker/doctor"
35
+ autoload :CaptureVerifier, "llm_cost_tracker/capture_verifier"
36
+ autoload :Report, "llm_cost_tracker/report"
37
+ autoload :Retention, "llm_cost_tracker/retention"
42
38
 
43
39
  module Pricing
44
40
  autoload :Sync, "llm_cost_tracker/pricing/sync"
41
+ autoload :Unknown, "llm_cost_tracker/pricing/unknown"
45
42
  end
46
43
 
47
44
  @configuration = Configuration.new
@@ -53,72 +50,67 @@ module LlmCostTracker
53
50
  "llm_cost_tracker_"
54
51
  end
55
52
 
56
- def reconciliation_enabled?
57
- configuration.reconciliation_enabled
58
- end
59
-
60
53
  def configure
61
54
  config = configuration
62
55
  raise Error, "LlmCostTracker is already configured" if config.finalized?
63
56
 
64
57
  yield(config)
65
58
  config.finalize!
66
- Pricing::Lookup.reset!
67
59
  Pricing::Registry.reset!
68
- Pricing::ServiceCharges.reset!
69
60
  Integrations.install!
70
61
  config
71
62
  end
72
63
 
73
- def reset_configuration!
74
- Ingestion::Worker.shutdown!(drain: false)
75
- Ingestion::Pool.reset!
76
- @configuration = Configuration.new
77
- Pricing::Lookup.reset!
78
- Pricing::Registry.reset!
79
- Pricing::ServiceCharges.reset!
80
- Pricing::Unknown.reset!
81
- Ingestion::Worker.reset!
82
- Tags::Context.clear!
83
- Dashboard::SetupState.reset!
84
- end
85
-
86
64
  def with_tags(tags = nil, **kwargs, &)
87
65
  Tags::Context.with((tags || {}).merge(kwargs), &)
88
66
  end
89
67
 
90
- def track(provider:, tokens:, model: nil, tags: {}, latency_ms: nil, stream: false,
91
- usage_source: :manual, enforce_budget: false,
92
- provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
93
- provider_workspace_id: nil, batch: nil, pricing_mode: nil, service_line_items: [])
94
- Tracker.enforce_budget! if enforce_budget
95
-
68
+ def track(provider:,
69
+ tokens:,
70
+ model: nil,
71
+ tags: {},
72
+ latency_ms: nil,
73
+ stream: false,
74
+ usage_source: Usage::Source::MANUAL,
75
+ enforce_budget: false,
76
+ provider_response_id: nil,
77
+ provider_project_id: nil,
78
+ provider_api_key_id: nil,
79
+ provider_workspace_id: nil,
80
+ pricing_mode: nil,
81
+ service_line_items: [])
96
82
  Tracker.record(
97
83
  event: Event.build(
98
84
  provider: provider,
99
85
  model: model,
100
- token_usage: TokenUsage.build_from_tokens(tokens),
86
+ token_usage: Usage::TokenUsage.build_from_tokens(tokens),
101
87
  stream: stream,
102
88
  usage_source: usage_source,
103
89
  provider_response_id: provider_response_id,
104
90
  provider_project_id: provider_project_id,
105
91
  provider_api_key_id: provider_api_key_id,
106
92
  provider_workspace_id: provider_workspace_id,
107
- batch: batch,
108
93
  pricing_mode: pricing_mode,
109
94
  service_line_items: service_line_items
110
95
  ),
111
96
  latency_ms: latency_ms,
112
- pricing_mode: pricing_mode,
113
- metadata: tags
97
+ metadata: tags,
98
+ enforce_budget: enforce_budget
114
99
  )
115
100
  end
116
101
 
117
- def track_stream(provider:, model: nil, tags: {}, latency_ms: nil, enforce_budget: false,
118
- provider_response_id: nil, provider_project_id: nil, provider_api_key_id: nil,
119
- provider_workspace_id: nil, batch: nil, pricing_mode: nil)
102
+ def track_stream(provider:,
103
+ model: nil,
104
+ tags: {},
105
+ latency_ms: nil,
106
+ enforce_budget: false,
107
+ provider_response_id: nil,
108
+ provider_project_id: nil,
109
+ provider_api_key_id: nil,
110
+ provider_workspace_id: nil,
111
+ pricing_mode: nil)
120
112
  require_relative "llm_cost_tracker/capture/stream_collector"
121
- Tracker.enforce_budget! if enforce_budget
113
+ Budget.enforce!(provider: provider, model: model, force: true) if enforce_budget
122
114
  collector = Capture::StreamCollector.new(
123
115
  provider: provider.to_s,
124
116
  model: model,
@@ -127,7 +119,6 @@ module LlmCostTracker
127
119
  provider_project_id: provider_project_id,
128
120
  provider_api_key_id: provider_api_key_id,
129
121
  provider_workspace_id: provider_workspace_id,
130
- batch: batch,
131
122
  pricing_mode: pricing_mode,
132
123
  metadata: tags
133
124
  )
@@ -146,4 +137,4 @@ Faraday::Middleware.register_middleware(
146
137
  llm_cost_tracker: LlmCostTracker::Middleware::Faraday
147
138
  )
148
139
 
149
- at_exit { LlmCostTracker::Ingestion::Worker.shutdown!(drain: false) }
140
+ at_exit { LlmCostTracker::Ingestion::Worker.shutdown!(drain: false) if LlmCostTracker::Ingestion.async? }
@@ -35,11 +35,9 @@ namespace :llm_cost_tracker do
35
35
  task :verify_capture do
36
36
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
37
37
  require_relative "../llm_cost_tracker"
38
- checks = LlmCostTracker::Doctor::CaptureVerifier.call
39
- puts LlmCostTracker::Doctor::CaptureVerifier.report(checks)
40
- unless LlmCostTracker::Doctor::CaptureVerifier.healthy?(checks)
41
- abort("llm_cost_tracker: capture verification failed")
42
- end
38
+ checks = LlmCostTracker::CaptureVerifier.call
39
+ puts LlmCostTracker::CaptureVerifier.report(checks)
40
+ abort("llm_cost_tracker: capture verification failed") unless LlmCostTracker::CaptureVerifier.healthy?(checks)
43
41
  end
44
42
 
45
43
  desc "Print an LLM cost report from ActiveRecord storage"
@@ -66,8 +64,15 @@ namespace :llm_cost_tracker do
66
64
  puts "llm_cost_tracker: pruned #{deleted} calls older than #{days} days"
67
65
  inbox_pruned = LlmCostTracker::Retention.prune_inbox(older_than: days)
68
66
  puts "llm_cost_tracker: pruned #{inbox_pruned} inbox entries older than #{days} days"
69
- invoice_imports_pruned = LlmCostTracker::Retention.prune_invoice_imports(older_than: days)
70
- puts "llm_cost_tracker: pruned #{invoice_imports_pruned} provider invoice imports older than #{days} days"
67
+ end
68
+
69
+ desc "Rebuild llm_cost_tracker_call_rollups from the calls ledger (resync the rollup cache)"
70
+ task rebuild_rollups: :environment do
71
+ unless LlmCostTracker::CallRollup.table_exists?
72
+ abort("llm_cost_tracker: rollups table missing; run the call_rollups generator and migrate first")
73
+ end
74
+ rows = LlmCostTracker::Ledger::Rollups.rebuild!
75
+ puts "llm_cost_tracker: rebuilt #{rows} rollup rows from the calls ledger"
71
76
  end
72
77
 
73
78
  namespace :prices do
@@ -99,7 +104,7 @@ namespace :llm_cost_tracker do
99
104
  puts "llm_cost_tracker: #{action} pricing file #{result.path}"
100
105
  puts " source: #{result.source_url}"
101
106
  puts " version: #{result.source_version.inspect}" if result.source_version
102
- print_changes(result.changes)
107
+ LlmCostTracker::Pricing::Sync::ChangePrinter.call(result.changes)
103
108
  end
104
109
 
105
110
  desc "Compare the current pricing file with the maintained LLM Cost Tracker price snapshot."
@@ -114,80 +119,16 @@ namespace :llm_cost_tracker do
114
119
  puts "llm_cost_tracker: checked pricing file #{result.path}"
115
120
  puts " source: #{result.source_url}"
116
121
  puts " version: #{result.source_version.inspect}" if result.source_version
117
- print_changes(result.changes)
122
+ LlmCostTracker::Pricing::Sync::ChangePrinter.call(result.changes)
118
123
  puts " pricing is up to date" if result.up_to_date
119
124
  abort("llm_cost_tracker: pricing check failed") unless result.up_to_date
120
125
  end
121
-
122
- desc "Explain how a provider/model price is matched. Use PROVIDER=... MODEL=..."
123
- task :explain do
124
- Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
125
- require_relative "../llm_cost_tracker"
126
-
127
- explanation = price_explanation_from_env
128
- puts "llm_cost_tracker: #{explanation.message}"
129
- print_price_explanation(explanation)
130
- abort("llm_cost_tracker: price is incomplete or unknown") unless explanation.complete?
131
- end
132
- end
133
-
134
- namespace :reconcile do
135
- desc "Import provider invoice rows from a JSON INPUT file. " \
136
- "Use SOURCE=openai INPUT=path/to/file.json. Pass PROVIDER=openai for unmapped sources (csv, ...)."
137
- task(:import) { reconcile_run(:run_import) }
138
-
139
- desc "Print a reconciliation diff. " \
140
- "Use SOURCE=openai PERIOD_START=YYYY-MM-DD PERIOD_END=YYYY-MM-DD. PROVIDER=openai for unmapped sources."
141
- task(:diff) { reconcile_run(:run_diff) }
142
126
  end
143
127
  end
144
128
  # rubocop:enable Metrics/BlockLength
145
129
 
146
- def reconcile_run(method)
147
- Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
148
- require_relative "../llm_cost_tracker"
149
- LlmCostTracker::ReconcileTasks.public_send(method, env: ENV)
150
- end
151
-
152
- def print_changes(changes)
153
- LlmCostTracker::Pricing::Sync::ChangePrinter.call(changes)
154
- end
155
-
156
130
  def price_refresh_output_path
157
131
  path = LlmCostTracker::Pricing::Sync.configured_output_path
158
132
  FileUtils.mkdir_p(File.dirname(path))
159
133
  path
160
134
  end
161
-
162
- def price_explanation_from_env
163
- provider = ENV["PROVIDER"].to_s.strip
164
- model = ENV["MODEL"].to_s.strip
165
- abort("llm_cost_tracker: use PROVIDER=... MODEL=...") if provider.empty? || model.empty?
166
-
167
- LlmCostTracker::Pricing.explain(
168
- provider: provider,
169
- model: model,
170
- pricing_mode: ENV.fetch("PRICING_MODE", nil),
171
- tokens: {
172
- input: ENV.fetch("INPUT_TOKENS", 1).to_i,
173
- output: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
174
- cache_read_input: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
175
- cache_write_input: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i,
176
- cache_write_extended_input: ENV.fetch("CACHE_WRITE_EXTENDED_INPUT_TOKENS", 0).to_i,
177
- audio_input: ENV.fetch("AUDIO_INPUT_TOKENS", 0).to_i,
178
- audio_output: ENV.fetch("AUDIO_OUTPUT_TOKENS", 0).to_i
179
- }
180
- )
181
- end
182
-
183
- def print_price_explanation(explanation)
184
- return unless explanation.matched?
185
-
186
- puts " source: #{explanation.source}"
187
- puts " matched_key: #{explanation.matched_key}"
188
- puts " matched_by: #{explanation.matched_by}"
189
- puts " pricing_mode: #{explanation.pricing_mode || 'standard'}"
190
- explanation.effective_prices.each do |key, value|
191
- puts " #{key}: #{value.nil? ? 'missing' : value}"
192
- end
193
- end