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
@@ -8,7 +8,6 @@ require "rubygems"
8
8
  require_relative "registry"
9
9
  require_relative "sync/fetcher"
10
10
  require_relative "sync/registry_diff"
11
- require_relative "sync/registry_loader"
12
11
  require_relative "sync/registry_writer"
13
12
 
14
13
  module LlmCostTracker
@@ -39,7 +38,7 @@ module LlmCostTracker
39
38
 
40
39
  def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
41
40
  today: Date.today)
42
- current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
41
+ current = load_registry(path)
43
42
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
44
43
 
45
44
  if response.not_modified
@@ -55,7 +54,10 @@ module LlmCostTracker
55
54
  end
56
55
 
57
56
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
58
- RegistryWriter.new.call(path: path, registry: remote) unless preview
57
+ unless preview
58
+ RegistryWriter.new.call(path: path, registry: remote)
59
+ invalidate_pricing_caches!
60
+ end
59
61
  refresh_result(
60
62
  path: path,
61
63
  url: url,
@@ -67,8 +69,14 @@ module LlmCostTracker
67
69
  )
68
70
  end
69
71
 
72
+ def invalidate_pricing_caches!
73
+ Pricing::Lookup.reset!
74
+ Pricing::Registry.reset!
75
+ Pricing::ServiceCharges.reset!
76
+ end
77
+
70
78
  def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
71
- current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
79
+ current = load_registry(path)
72
80
  response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
73
81
 
74
82
  if response.not_modified
@@ -82,7 +90,7 @@ module LlmCostTracker
82
90
  end
83
91
 
84
92
  remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
85
- changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
93
+ changes = registry_changes(current, remote)
86
94
 
87
95
  CheckResult.new(
88
96
  path: path,
@@ -118,10 +126,15 @@ module LlmCostTracker
118
126
  raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
119
127
  end
120
128
 
121
- models = registry.fetch("models", {})
122
- Registry.normalize_price_table(models)
129
+ raw_models = registry.fetch("models", {})
130
+ models = Registry.normalize_price_table(raw_models).each_with_object({}) do |(model, prices), normalized|
131
+ model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
132
+ normalized[model] = model_metadata.merge(prices.to_h { |key, value| [key.name, value] })
133
+ end
134
+ service_charges = registry["service_charges"]
135
+ ServiceCharges.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
123
136
 
124
- registry.merge(
137
+ normalized = {
125
138
  "metadata" => metadata.merge(
126
139
  "schema_version" => schema_version,
127
140
  "updated_at" => metadata["updated_at"] || today.iso8601,
@@ -129,11 +142,21 @@ module LlmCostTracker
129
142
  "source_version" => response.source_version
130
143
  ),
131
144
  "models" => models
132
- )
145
+ }
146
+ normalized["service_charges"] = service_charges if service_charges.present?
147
+ normalized
133
148
  rescue ArgumentError, TypeError => e
134
149
  raise Error, "Unable to load remote pricing snapshot: #{e.message}"
135
150
  end
136
151
 
152
+ def load_registry(path)
153
+ return {} unless File.exist?(path)
154
+
155
+ YAML.safe_load_file(path, aliases: false) || {}
156
+ rescue Psych::Exception, ArgumentError, TypeError => e
157
+ raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
158
+ end
159
+
137
160
  def parse_registry(body)
138
161
  registry = JSON.parse(body.to_s)
139
162
  raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
@@ -148,11 +171,37 @@ module LlmCostTracker
148
171
  path: path,
149
172
  source_url: url,
150
173
  source_version: response.source_version,
151
- changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
174
+ changes: registry_changes(current, remote),
152
175
  written: written,
153
176
  not_modified: not_modified
154
177
  )
155
178
  end
179
+
180
+ def registry_changes(current, remote)
181
+ model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
182
+ charge_changes = service_charges_diff(
183
+ current.fetch("service_charges", {}),
184
+ remote.fetch("service_charges", {})
185
+ )
186
+ return model_changes if charge_changes.empty?
187
+
188
+ model_changes.merge("service_charges" => charge_changes)
189
+ end
190
+
191
+ def service_charges_diff(current, remote)
192
+ (current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
193
+ current_rates = (current[provider] || {}).transform_keys(&:to_s)
194
+ remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
195
+ (current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
196
+ from = current_rates[component]
197
+ to = remote_rates[component]
198
+ next if from == to
199
+
200
+ changes[provider] ||= {}
201
+ changes[provider][component] = { "from" => from, "to" => to }
202
+ end
203
+ end
204
+ end
156
205
  end
157
206
  end
158
207
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ module SyncChangePrinter
6
+ class << self
7
+ def call(changes, output: $stdout)
8
+ service_changes = changes["service_charges"]
9
+ model_changes = changes.except("service_charges")
10
+
11
+ output.puts " changed models: #{model_changes.size}"
12
+ model_changes.each do |model, fields|
13
+ output.puts " - #{model}"
14
+ fields.each do |field, values|
15
+ output.puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
16
+ end
17
+ end
18
+
19
+ return if service_changes.nil? || service_changes.empty?
20
+
21
+ output.puts " changed service charges: #{service_changes.values.sum(&:size)}"
22
+ service_changes.each do |provider, components|
23
+ components.each do |component, values|
24
+ output.puts " - #{provider}.#{component}: " \
25
+ "#{values['from'].inspect} -> #{values['to'].inspect}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,75 +1,267 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/hash/keys"
4
3
  require "active_support/core_ext/object/blank"
4
+ require "time"
5
5
 
6
- require_relative "pricing/components"
6
+ require_relative "version"
7
+ require_relative "token_usage"
8
+ require_relative "billing/components"
9
+ require_relative "pricing/mode"
7
10
  require_relative "pricing/registry"
8
11
  require_relative "pricing/lookup"
9
12
  require_relative "pricing/effective_prices"
10
13
  require_relative "pricing/explainer"
14
+ require_relative "pricing/service_charges"
11
15
 
12
16
  module LlmCostTracker
13
- module Pricing
14
- STANDARD_MODE_VALUES = %w[auto default standard standard_only].freeze
15
- private_constant :STANDARD_MODE_VALUES
17
+ module Pricing # rubocop:disable Metrics/ModuleLength
18
+ extend ServiceCharges
19
+
20
+ STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
21
+ RATE_DENOMINATOR_TOKENS = 1_000_000
22
+ private_constant :STANDARD_MODE_VALUES, :RATE_DENOMINATOR_TOKENS
16
23
 
17
24
  class << self
18
25
  def normalize_mode(value)
19
- mode = value.to_s.strip.presence
26
+ return nil if value.nil?
27
+
28
+ mode = normalize_string_mode(value.to_s)
20
29
  return nil unless mode
21
30
 
22
- mode = mode.tr("-", "_")
23
31
  STANDARD_MODE_VALUES.include?(mode) ? nil : mode
24
32
  end
25
33
 
26
- def cost_for(provider:, model:, token_usage:, pricing_mode: nil)
27
- prices = lookup(provider: provider, model: model)
28
- return nil unless prices
34
+ def cost_for(provider:, model:, tokens:, pricing_mode: nil)
35
+ calculation = calculation_for(
36
+ provider: provider,
37
+ model: model,
38
+ tokens: tokens,
39
+ pricing_mode: pricing_mode
40
+ )
41
+ return nil unless calculation
29
42
 
30
- costs = calculate_costs(token_usage, prices, pricing_mode: pricing_mode)
31
- return nil unless costs
43
+ cost_from(calculation)
44
+ end
32
45
 
33
- values = COMPONENTS.to_h do |component|
34
- [component.cost_key, costs.fetch(component.price_key).round(8)]
35
- end
46
+ def calculate(provider:, model:, tokens:, line_items:, pricing_mode: nil)
47
+ calculation = calculation_for(
48
+ provider: provider,
49
+ model: model,
50
+ tokens: tokens,
51
+ pricing_mode: pricing_mode
52
+ )
53
+ cost_data = calculation && cost_from(calculation)
54
+ snapshot = calculation && snapshot_from(calculation)
55
+ priced = apply_calculation_to_line_items(line_items, calculation,
56
+ provider: provider, pricing_mode: pricing_mode)
57
+ [cost_data, snapshot, priced]
58
+ end
36
59
 
37
- values.merge(total_cost: costs.values.sum.round(8))
60
+ def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
61
+ token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
62
+ calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
63
+ snapshot = calculation && snapshot_from(calculation)
64
+ priced = apply_calculation_to_line_items(line_items, calculation,
65
+ provider: provider, pricing_mode: pricing_mode)
66
+ [priced, snapshot]
38
67
  end
39
68
 
40
- def lookup(provider:, model:)
41
- Lookup.call(provider: provider, model: model)&.prices
69
+ def snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
70
+ calculation = calculation_for(
71
+ provider: provider,
72
+ model: model,
73
+ tokens: tokens,
74
+ pricing_mode: pricing_mode
75
+ )
76
+ return nil unless calculation
77
+
78
+ snapshot_from(calculation)
42
79
  end
43
80
 
44
- def explain(provider:, model:, token_usage:, pricing_mode: nil)
81
+ def explain(provider:, model:, tokens:, pricing_mode: nil)
45
82
  Explainer.call(
46
83
  provider: provider,
47
84
  model: model,
48
- token_usage: token_usage,
85
+ tokens: tokens,
49
86
  pricing_mode: pricing_mode
50
87
  )
51
88
  end
52
89
 
53
90
  def stored_cost_attributes(attributes)
54
- attributes.to_h.symbolize_keys.slice(*COST_KEYS).compact
91
+ value = attributes.to_h[:total_cost]
92
+ value ? { total_cost: value } : {}
55
93
  end
56
94
 
57
95
  private
58
96
 
59
- def calculate_costs(usage, prices, pricing_mode:)
60
- effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
61
- return nil if effective.value?(nil)
97
+ def normalize_string_mode(value)
98
+ normalized = value.strip
99
+ return nil if normalized.empty?
100
+
101
+ normalized.downcase.tr("-", "_").to_sym
102
+ end
103
+
104
+ def cost_from(calculation)
105
+ costs = calculation[:costs]
106
+ values = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, result|
107
+ cost = costs[component.key]
108
+ result[component.cost_key] = cost.round(8) unless cost.nil?
109
+ end
110
+ values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
111
+ values
112
+ end
113
+
114
+ def snapshot_from(calculation)
115
+ match = calculation[:match]
116
+ effective = calculation[:effective]
117
+ token_usage = calculation[:token_usage]
118
+ rates = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, values|
119
+ quantity = token_usage.public_send(component.token_key)
120
+ price = effective[component.key]
121
+ next if quantity.zero? || price.nil?
122
+
123
+ values[component.key] = {
124
+ amount: price,
125
+ quantity: RATE_DENOMINATOR_TOKENS
126
+ }
127
+ end
128
+
129
+ {
130
+ schema_version: 1,
131
+ source: match.source,
132
+ source_key: match.key,
133
+ source_version: source_version_for(match.source),
134
+ matched_by: match.matched_by,
135
+ currency: "USD",
136
+ rates: rates
137
+ }
138
+ end
139
+
140
+ def calculation_for(provider:, model:, tokens:, pricing_mode:)
141
+ match = Lookup.call(provider: provider, model: model)
142
+ return nil unless match
143
+
144
+ token_usage = TokenUsage.build_from_tokens(tokens)
145
+ mode = normalize_mode(pricing_mode)
146
+ effective = EffectivePrices.call(usage: token_usage, prices: match.prices, pricing_mode: mode)
147
+ return nil unless any_billable_priced?(token_usage, effective)
148
+
149
+ { match: match, effective: effective, token_usage: token_usage, costs: costs_for(token_usage, effective) }
150
+ end
151
+
152
+ def any_billable_priced?(token_usage, effective)
153
+ billable = Billing::Components::TOKEN_PRICED.select { |c| token_usage.public_send(c.token_key).positive? }
154
+ billable.empty? || billable.any? { |c| effective[c.key] }
155
+ end
156
+
157
+ def costs_for(usage, effective)
158
+ Billing::Components::TOKEN_PRICED.to_h do |component|
159
+ tokens = usage.public_send(component.token_key)
160
+ [component.key, token_cost(tokens, effective[component.key])]
161
+ end
162
+ end
163
+
164
+ def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
165
+ line_items.map do |line_item|
166
+ next price_token_line_item(line_item, calculation) if line_item.unit == :token
167
+
168
+ price_service_charge_line_item(line_item,
169
+ provider: provider,
170
+ calculation: calculation,
171
+ pricing_mode: pricing_mode)
172
+ end
173
+ end
174
+
175
+ def token_attributes_from(line_items)
176
+ line_items.each_with_object({}) do |line_item, totals|
177
+ next unless line_item.unit == :token
178
+
179
+ component = component_for_line_item(line_item)
180
+ next unless component
181
+
182
+ totals[component.key] = (totals[component.key] || 0) + line_item.quantity.to_i
183
+ end
184
+ end
185
+
186
+ def price_token_line_item(line_item, calculation)
187
+ component = component_for_line_item(line_item)
188
+ return line_item unless component
189
+ return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) unless calculation
190
+
191
+ effective_price = calculation[:effective][component.key]
192
+ return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) if effective_price.nil?
193
+
194
+ cost = (line_item.quantity * BigDecimal(effective_price.to_s)) / RATE_DENOMINATOR_TOKENS
195
+ match = calculation[:match]
196
+ line_item.with(
197
+ rate_amount: BigDecimal(effective_price.to_s),
198
+ rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
199
+ cost: cost,
200
+ cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
201
+ price_key: component.key,
202
+ price_source: match.source,
203
+ price_source_version: source_version_for(match.source)
204
+ )
205
+ end
206
+
207
+ def price_service_charge_line_item(line_item, provider:, calculation:, pricing_mode:)
208
+ return line_item if line_item.priced?
209
+ return line_item unless line_item.billable?
210
+
211
+ rate = model_rate_for(line_item, calculation) ||
212
+ charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
213
+ return line_item unless rate
214
+
215
+ line_item.apply_rate(rate)
216
+ end
217
+
218
+ def model_rate_for(line_item, calculation)
219
+ return nil unless calculation
220
+
221
+ match = calculation[:match]
222
+ amount = match.prices[line_item.kind] || match.prices[line_item.kind.to_s]
223
+ return nil unless amount.is_a?(Numeric)
224
+
225
+ component = Billing::Components::BY_KEY[line_item.kind]
226
+ {
227
+ amount: BigDecimal(amount.to_s),
228
+ quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
229
+ currency: "USD",
230
+ source: match.source,
231
+ source_key: "#{match.key}.#{line_item.kind}",
232
+ source_version: source_version_for(match.source)
233
+ }
234
+ end
235
+
236
+ def component_for_line_item(line_item)
237
+ Billing::Components::REGISTRY.find do |component|
238
+ component.kind == line_item.kind &&
239
+ component.direction == line_item.direction &&
240
+ component.modality == line_item.modality &&
241
+ component.cache_state == line_item.cache_state &&
242
+ component.unit == line_item.unit
243
+ end
244
+ end
62
245
 
63
- usage.price_quantities.to_h do |key, tokens|
64
- [key, token_cost(tokens, effective.fetch(key))]
246
+ def source_version_for(source)
247
+ case source
248
+ when :bundled
249
+ LlmCostTracker::VERSION
250
+ when :prices_file
251
+ path = LlmCostTracker.configuration.prices_file
252
+ path ? File.mtime(path).utc.iso8601 : nil
253
+ when :pricing_overrides
254
+ "configuration"
65
255
  end
256
+ rescue Errno::ENOENT
257
+ nil
66
258
  end
67
259
 
68
260
  def token_cost(tokens, per_million_price)
69
- return 0.0 if tokens.to_i.zero?
261
+ return BigDecimal("0") if tokens.zero?
70
262
  return nil if per_million_price.nil?
71
263
 
72
- (tokens.to_f / 1_000_000) * per_million_price
264
+ (BigDecimal(tokens.to_s) * BigDecimal(per_million_price.to_s)) / RATE_DENOMINATOR_TOKENS
73
265
  end
74
266
  end
75
267
  end
@@ -9,16 +9,14 @@ module LlmCostTracker
9
9
  end
10
10
 
11
11
  generators do
12
- require_relative "generators/llm_cost_tracker/add_ingestion_generator"
13
- require_relative "generators/llm_cost_tracker/add_period_totals_generator"
14
- require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
15
- require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
16
- require_relative "generators/llm_cost_tracker/add_streaming_generator"
17
- require_relative "generators/llm_cost_tracker/add_token_usage_generator"
18
12
  require_relative "generators/llm_cost_tracker/install_generator"
19
13
  require_relative "generators/llm_cost_tracker/prices_generator"
20
- require_relative "generators/llm_cost_tracker/upgrade_cost_precision_generator"
21
- require_relative "generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator"
14
+ require_relative "generators/llm_cost_tracker/call_rollups_generator"
15
+ require_relative "generators/llm_cost_tracker/durable_ingestion_generator"
16
+ require_relative "generators/llm_cost_tracker/reconciliation_generator"
17
+ require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
18
+ require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
19
+ require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
22
20
  end
23
21
 
24
22
  rake_tasks do
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "reconciliation"
6
+
7
+ module LlmCostTracker
8
+ module ReconcileTasks
9
+ SOURCE_PARSERS = {
10
+ "openai" => Reconciliation::Sources::OpenaiUsage,
11
+ "anthropic" => Reconciliation::Sources::AnthropicUsage
12
+ }.freeze
13
+ GENERIC_SOURCES = %w[csv].freeze
14
+
15
+ module_function
16
+
17
+ def run_import(env: ENV, output: $stdout, error_output: $stderr)
18
+ result = import_from_env(env: env)
19
+ output.puts "llm_cost_tracker: imported #{result.total_imported} rows " \
20
+ "(inserted=#{result.inserted}, updated=#{result.updated}, skipped=#{result.skipped})"
21
+ result.errors.each { |error| error_output.puts " error: #{error}" }
22
+ raise "llm_cost_tracker: reconcile import had errors" unless result.success?
23
+
24
+ result
25
+ end
26
+
27
+ def run_diff(env: ENV, output: $stdout)
28
+ diff = diff_from_env(env: env)
29
+ print_diff(diff, output: output)
30
+ diff
31
+ end
32
+
33
+ def import_from_env(env: ENV)
34
+ source = required_env(env, "SOURCE")
35
+ input_path = required_env(env, "INPUT")
36
+ raise ArgumentError, "INPUT file not found: #{input_path}" unless File.exist?(input_path)
37
+
38
+ payload = JSON.parse(File.read(input_path))
39
+ rows = parse_rows(source: source, payload: payload)
40
+ Reconciliation.import(source: source.to_sym, rows: rows, provider: env["PROVIDER"])
41
+ end
42
+
43
+ def diff_from_env(env: ENV)
44
+ source = required_env(env, "SOURCE")
45
+ period_start = Date.parse(required_env(env, "PERIOD_START"))
46
+ period_end = Date.parse(required_env(env, "PERIOD_END"))
47
+ Reconciliation.diff(source: source.to_sym, period_start: period_start, period_end: period_end,
48
+ provider: env["PROVIDER"],
49
+ drilldown_limit: parse_drilldown_limit(env["DRILLDOWN_LIMIT"]))
50
+ end
51
+
52
+ def parse_drilldown_limit(value)
53
+ return Reconciliation::Diff::DEFAULT_DRILLDOWN_LIMIT if value.nil? || value.to_s.empty?
54
+ return nil if value.to_s.downcase == "all"
55
+
56
+ Integer(value)
57
+ end
58
+
59
+ def print_diff(diff, output: $stdout)
60
+ output.puts "llm_cost_tracker: reconciliation diff for #{diff.source} " \
61
+ "#{diff.period_start}..#{diff.period_end}"
62
+ output.puts " provider_total: #{diff.provider_total.to_s('F')} #{diff.currency}"
63
+ output.puts " local_total: #{diff.local_total.to_s('F')} #{diff.currency} " \
64
+ "(from #{diff.local_total_source})"
65
+ output.puts " delta: #{diff.delta_amount.to_s('F')} (#{diff.delta_percent || 'n/a'}%)"
66
+ print_unmatched_provider_rows(diff, output)
67
+ print_unmatched_local_calls(diff, output)
68
+ print_non_cost_rows(diff, output)
69
+ end
70
+
71
+ def parse_rows(source:, payload:)
72
+ parser = SOURCE_PARSERS[source.to_s]
73
+ return parser.parse(payload) if parser
74
+ return Array(payload["rows"]) if GENERIC_SOURCES.include?(source.to_s)
75
+
76
+ known = (SOURCE_PARSERS.keys + GENERIC_SOURCES).join(", ")
77
+ raise ArgumentError, "unknown SOURCE #{source.inspect}; known sources: #{known}"
78
+ end
79
+
80
+ def required_env(env, key)
81
+ value = env[key].to_s.strip
82
+ raise ArgumentError, "missing #{key}" if value.empty?
83
+
84
+ value
85
+ end
86
+
87
+ def print_unmatched_provider_rows(diff, output)
88
+ return if diff.unmatched_provider_rows.empty?
89
+
90
+ output.puts " unmatched provider rows#{truncation_suffix(diff.unmatched_provider_rows.size,
91
+ diff.unmatched_provider_rows_total)}:"
92
+ diff.unmatched_provider_rows.each do |row|
93
+ output.puts " #{row[:external_id]} (#{row[:match_basis]}): " \
94
+ "#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
95
+ end
96
+ end
97
+
98
+ def print_unmatched_local_calls(diff, output)
99
+ return if diff.unmatched_local_calls.empty?
100
+
101
+ output.puts " unmatched local calls#{truncation_suffix(diff.unmatched_local_calls.size,
102
+ diff.unmatched_local_calls_total)}:"
103
+ diff.unmatched_local_calls.each do |row|
104
+ output.puts " #{row[:count]} calls / #{row[:total_cost].to_s('F')} " \
105
+ "#{format_attribution(row[:attribution])}"
106
+ end
107
+ end
108
+
109
+ def print_non_cost_rows(diff, output)
110
+ return if diff.non_cost_rows.empty?
111
+
112
+ output.puts " non-cost evidence#{truncation_suffix(diff.non_cost_rows.size,
113
+ diff.non_cost_rows_total)}:"
114
+ diff.non_cost_rows.each do |row|
115
+ output.puts " [#{row[:row_type]}/#{row[:meter]}] #{format_amount(row[:billed_amount])} " \
116
+ "#{format_attribution(row[:attribution])}"
117
+ end
118
+ end
119
+
120
+ def truncation_suffix(shown, total)
121
+ return "" if shown >= total
122
+
123
+ " (showing #{shown} of #{total} — pass DRILLDOWN_LIMIT=all to see every row)"
124
+ end
125
+
126
+ def format_amount(value)
127
+ value.nil? ? "n/a" : value.to_s("F")
128
+ end
129
+
130
+ def format_attribution(attribution)
131
+ LlmCostTracker::Masking.format_attribution(attribution, separator: ",")
132
+ end
133
+ end
134
+ end