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
@@ -2,11 +2,12 @@
2
2
 
3
3
  require "bigdecimal"
4
4
 
5
- require_relative "components"
5
+ require_relative "../currency"
6
+ require_relative "../usage/catalog"
6
7
  require_relative "cost_status"
7
8
 
8
9
  module LlmCostTracker
9
- module Billing
10
+ module Charges
10
11
  LineItem = Data.define(
11
12
  :kind,
12
13
  :direction,
@@ -29,26 +30,24 @@ module LlmCostTracker
29
30
  )
30
31
 
31
32
  class LineItem
32
- USD = "USD"
33
-
34
33
  def self.build(attributes)
35
34
  attributes = attributes.to_h
36
- component = component_for(attributes)
35
+ dimension = dimension_for(attributes)
37
36
  new(
38
- kind: symbol_or_nil(attributes[:kind]) || component&.kind,
39
- direction: symbol_or_nil(attributes[:direction]) || component&.direction,
40
- modality: symbol_or_nil(attributes[:modality]) || component&.modality,
41
- cache_state: symbol_or_nil(attributes[:cache_state]) || component&.cache_state,
42
- quantity: decimal_or_zero(attributes[:quantity]),
43
- unit: symbol_or_nil(attributes[:unit]) || component&.unit,
37
+ kind: attributes[:kind]&.to_s || dimension&.kind,
38
+ direction: attributes[:direction]&.to_s || dimension&.direction,
39
+ modality: attributes[:modality]&.to_s || dimension&.modality,
40
+ cache_state: attributes[:cache_state]&.to_s || dimension&.cache_state || "none",
41
+ quantity: decimal_or_nil(attributes[:quantity]) || BigDecimal("0"),
42
+ unit: attributes[:unit]&.to_s || dimension&.unit,
44
43
  rate_amount: decimal_or_nil(attributes[:rate_amount]),
45
44
  rate_quantity: decimal_or_nil(attributes[:rate_quantity]) || BigDecimal("1"),
46
45
  cost: decimal_or_nil(attributes[:cost]),
47
- currency: attributes[:currency] || USD,
46
+ currency: canonical_currency(attributes[:currency]),
48
47
  cost_status: cost_status_for(attributes),
49
- pricing_basis: symbol_or_nil(attributes[:pricing_basis]),
50
- price_key: attributes[:price_key],
51
- price_source: symbol_or_nil(attributes[:price_source]),
48
+ pricing_basis: attributes[:pricing_basis]&.to_s,
49
+ price_key: attributes[:price_key]&.to_s,
50
+ price_source: attributes[:price_source]&.to_s,
52
51
  price_source_version: attributes[:price_source_version],
53
52
  provider_field: attributes[:provider_field],
54
53
  provider_item_id: attributes[:provider_item_id],
@@ -62,14 +61,14 @@ module LlmCostTracker
62
61
  token_usage.priced_quantities.filter_map do |key, quantity|
63
62
  next unless quantity.positive?
64
63
 
65
- component = Components::BY_KEY.fetch(key)
64
+ dimension = Usage::Catalog.fetch(key)
66
65
  build(
67
- kind: component.kind,
68
- direction: component.direction,
69
- modality: component.modality,
70
- cache_state: component.cache_state,
66
+ kind: dimension.kind,
67
+ direction: dimension.direction,
68
+ modality: dimension.modality,
69
+ cache_state: dimension.cache_state,
71
70
  quantity: quantity,
72
- unit: component.unit
71
+ unit: dimension.unit
73
72
  )
74
73
  end
75
74
  end
@@ -84,17 +83,11 @@ module LlmCostTracker
84
83
  cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE
85
84
  end
86
85
 
87
- def self.component_for(attributes)
88
- component_key = attributes[:component_key] || attributes[:price_key]
89
- return nil unless component_key
90
-
91
- Components::BY_KEY[component_key.to_sym]
92
- end
93
-
94
- def self.symbol_or_nil(value)
95
- return nil if value.nil?
86
+ def self.dimension_for(attributes)
87
+ dimension_key = attributes[:dimension_key] || attributes[:price_key]
88
+ return nil unless dimension_key
96
89
 
97
- value.to_s.to_sym
90
+ Usage::Catalog[dimension_key.to_s]
98
91
  end
99
92
 
100
93
  def self.decimal_or_nil(value)
@@ -103,11 +96,11 @@ module LlmCostTracker
103
96
  BigDecimal(value.to_s)
104
97
  end
105
98
 
106
- def self.decimal_or_zero(value)
107
- decimal_or_nil(value) || BigDecimal("0")
99
+ def self.canonical_currency(value)
100
+ (value || LlmCostTracker::DEFAULT_CURRENCY).to_s.upcase
108
101
  end
109
102
 
110
- private_class_method :cost_status_for, :component_for, :symbol_or_nil, :decimal_or_nil, :decimal_or_zero
103
+ private_class_method :cost_status_for, :dimension_for, :decimal_or_nil, :canonical_currency
111
104
 
112
105
  def billable?
113
106
  quantity.positive?
@@ -122,7 +115,12 @@ module LlmCostTracker
122
115
  end
123
116
 
124
117
  def token?
125
- unit == :token
118
+ unit == "token"
119
+ end
120
+
121
+ def dimension
122
+ Usage::Catalog[price_key] ||
123
+ Usage::Catalog.token_priced_for(kind: kind, direction: direction, cache_state: cache_state)
126
124
  end
127
125
 
128
126
  def cost_value
@@ -130,18 +128,16 @@ module LlmCostTracker
130
128
  end
131
129
 
132
130
  def with_rate(rate)
133
- rate_amount = rate.fetch(:amount)
134
- rate_quantity = rate.fetch(:quantity)
135
- applied_cost = (quantity / rate_quantity) * rate_amount
131
+ applied_cost = (quantity / rate.quantity) * rate.amount
136
132
  with(
137
- rate_amount: rate_amount,
138
- rate_quantity: rate_quantity,
133
+ rate_amount: rate.amount,
134
+ rate_quantity: rate.quantity,
139
135
  cost: applied_cost,
140
- currency: rate.fetch(:currency),
136
+ currency: rate.currency.upcase,
141
137
  cost_status: applied_cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE,
142
- price_key: rate.fetch(:source_key),
143
- price_source: rate.fetch(:source),
144
- price_source_version: rate.fetch(:source_version)
138
+ price_key: rate.source_key,
139
+ price_source: rate.source,
140
+ price_source_version: rate.source_version
145
141
  )
146
142
  end
147
143
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ Check = Data.define(:status, :name, :message)
5
+ end
@@ -17,8 +17,7 @@ module LlmCostTracker
17
17
  INGESTION_MODES = %i[inline async].freeze
18
18
  SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
19
19
  log_level prices_file max_tag_count max_tag_value_bytesize
20
- ingestion_pool_size auto_enable_stream_usage cache_rollups
21
- reconciliation_enabled].freeze
20
+ ingestion_pool_size auto_enable_stream_usage cache_rollups].freeze
22
21
  ENUM_ATTRIBUTES = {
23
22
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
24
23
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn],
@@ -35,8 +34,7 @@ module LlmCostTracker
35
34
  :report_tag_breakdowns,
36
35
  :redacted_tag_keys,
37
36
  :unknown_pricing_behavior,
38
- :openai_compatible_providers,
39
- :reconciliation_importers
37
+ :openai_compatible_providers
40
38
  )
41
39
 
42
40
  def initialize
@@ -58,36 +56,12 @@ module LlmCostTracker
58
56
  @report_tag_breakdowns = []
59
57
  @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
60
58
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
61
- @reconciliation_importers = {}
62
- @reconciliation_enabled = false
63
59
  @auto_enable_stream_usage = true
64
60
  self.ingestion = :inline
65
61
  @cache_rollups = false
66
62
  @finalized = false
67
63
  end
68
64
 
69
- def reconciliation_importers=(importers)
70
- ensure_mutable!
71
- raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
72
-
73
- @reconciliation_importers = (importers || {}).to_h do |source, importer|
74
- raise Error, "reconciliation_importers[#{source}] must respond to call" unless importer.respond_to?(:call)
75
-
76
- [source.to_sym, importer]
77
- end
78
- end
79
-
80
- def register_reconciliation_importer(source, &block)
81
- ensure_mutable!
82
- raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
83
- raise Error, "register_reconciliation_importer requires a block" unless block
84
-
85
- @reconciliation_importers[source.to_sym] = block
86
- end
87
-
88
- RECONCILIATION_DISABLED_MESSAGE = "reconciliation is disabled; set config.reconciliation_enabled = true first"
89
- private_constant :RECONCILIATION_DISABLED_MESSAGE
90
-
91
65
  def openai_compatible_providers=(providers)
92
66
  ensure_mutable!
93
67
  @openai_compatible_providers = normalize_openai_compatible_providers(providers)
@@ -95,8 +69,8 @@ module LlmCostTracker
95
69
 
96
70
  def pricing_overrides=(value)
97
71
  ensure_mutable!
98
- @pricing_overrides = Pricing::Registry.normalize_price_table(value || {})
99
- rescue ArgumentError => e
72
+ @pricing_overrides = Pricing::Registry.normalize_price_entries(value || {}, context: "pricing_overrides")
73
+ rescue ArgumentError, TypeError => e
100
74
  raise Error, "invalid pricing_overrides: #{e.message}"
101
75
  end
102
76
 
@@ -112,7 +86,9 @@ module LlmCostTracker
112
86
 
113
87
  def instrument(*names)
114
88
  ensure_mutable!
115
- @instrumented_integrations.merge(normalize_instrumentation_names(names))
89
+ names = names.flatten
90
+ names = Integrations.names if names == [:all]
91
+ @instrumented_integrations.merge(names)
116
92
  end
117
93
 
118
94
  def instrumented?(name)
@@ -150,6 +126,12 @@ module LlmCostTracker
150
126
  Array(@redacted_tag_keys).map { |key| Tags::Sanitizer.normalized_key(key) }.freeze
151
127
  end
152
128
 
129
+ def static_sanitized_default_tags
130
+ return nil if @default_tags.respond_to?(:call)
131
+
132
+ @static_sanitized_default_tags ||= Tags::Sanitizer.call((@default_tags || {}).to_h).freeze
133
+ end
134
+
153
135
  def finalized?
154
136
  @finalized
155
137
  end
@@ -169,19 +151,6 @@ module LlmCostTracker
169
151
  end
170
152
  end
171
153
 
172
- def normalize_instrumentation_names(names)
173
- names = names.flatten
174
- integrations = Integrations.names
175
- return integrations if names == [:all]
176
-
177
- names.each do |name|
178
- next if integrations.include?(name)
179
-
180
- raise Error, "Unknown integration: #{name.inspect}. Use one of: #{integrations.join(', ')}"
181
- end
182
- names
183
- end
184
-
185
154
  def ensure_mutable!
186
155
  return unless finalized?
187
156
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ DEFAULT_CURRENCY = "USD"
5
+ end
@@ -1,39 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "check"
3
+ require_relative "../check"
4
4
  require_relative "probe"
5
5
  require_relative "../ingestion"
6
6
 
7
7
  module LlmCostTracker
8
8
  class Doctor
9
9
  class IngestionCheck
10
- PENDING_AGE_WARNING_SECONDS = 60
11
-
12
10
  def call
13
11
  return unless Probe.table_exists?("llm_cost_tracker_calls")
14
12
  return inline_check unless LlmCostTracker::Ingestion.async?
15
13
 
16
14
  missing = missing_parts
17
- if missing.empty?
18
- inbox = inbox_snapshot
19
- quarantined = inbox.try(:quarantined_count).to_i
20
- if quarantined.positive?
21
- return Check.new(:warn, "async ingestion", "#{quarantined} inbox entries quarantined after retries")
22
- end
23
-
24
- pending_count = inbox.try(:pending_count).to_i
25
- oldest_pending_at = inbox.try(:oldest_pending_at)&.to_time&.utc
26
- pending_age = oldest_pending_at && (Time.now.utc - oldest_pending_at)
27
- if pending_count.positive? && pending_age && pending_age >= PENDING_AGE_WARNING_SECONDS
28
- return Check.new(
29
- :warn,
30
- "async ingestion",
31
- "#{pending_count} inbox entries pending; oldest pending age #{pending_age.round}s"
32
- )
33
- end
34
-
35
- return Check.new(:ok, "async ingestion", "inbox and ingestion lease tables available")
36
- end
15
+ return async_ok if missing.empty?
37
16
 
38
17
  Check.new(
39
18
  :error,
@@ -44,14 +23,16 @@ module LlmCostTracker
44
23
 
45
24
  private
46
25
 
26
+ def async_ok
27
+ Check.new(:ok, "async ingestion", "inbox and ingestion lease tables available")
28
+ end
29
+
47
30
  def inline_check
48
31
  leftovers = inline_leftover_tables
49
32
  if leftovers.empty?
50
- return Check.new(
51
- :ok,
52
- "inline ingestion",
53
- "config.ingestion = :inline; events write directly to the ledger"
54
- )
33
+ return Check.new(:ok,
34
+ "inline ingestion",
35
+ "config.ingestion = :inline; events write directly to the ledger")
55
36
  end
56
37
 
57
38
  Check.new(
@@ -63,33 +44,18 @@ module LlmCostTracker
63
44
  end
64
45
 
65
46
  def inline_leftover_tables
66
- [
67
- LlmCostTracker::Ingestion::InboxEntry.table_name,
68
- LlmCostTracker::Ingestion::Lease.table_name
69
- ].select { |table| Probe.table_exists?(table) }
47
+ async_tables.select { |table| Probe.table_exists?(table) }
70
48
  end
71
49
 
72
50
  def missing_parts
51
+ async_tables.reject { |table| Probe.table_exists?(table) }
52
+ end
53
+
54
+ def async_tables
73
55
  [
74
56
  LlmCostTracker::Ingestion::InboxEntry.table_name,
75
57
  LlmCostTracker::Ingestion::Lease.table_name
76
- ].reject { |table| Probe.table_exists?(table) }
77
- end
78
-
79
- def inbox_snapshot
80
- max_attempts = LlmCostTracker::Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE
81
- LlmCostTracker::Ingestion::InboxEntry
82
- .select(
83
- "COALESCE(SUM(CASE WHEN attempts >= #{max_attempts} " \
84
- "THEN 1 ELSE 0 END), 0) AS quarantined_count, " \
85
- "COALESCE(SUM(CASE WHEN attempts < #{max_attempts} " \
86
- "THEN 1 ELSE 0 END), 0) AS pending_count, " \
87
- "MIN(CASE WHEN attempts < #{max_attempts} " \
88
- "THEN created_at ELSE NULL END) AS oldest_pending_at"
89
- )
90
- .take
91
- rescue StandardError
92
- nil
58
+ ]
93
59
  end
94
60
  end
95
61
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "date"
4
4
 
5
- require_relative "check"
5
+ require_relative "../check"
6
6
 
7
7
  module LlmCostTracker
8
8
  class Doctor
@@ -5,11 +5,10 @@ require_relative "../ledger"
5
5
  module LlmCostTracker
6
6
  class Doctor
7
7
  module Probe
8
- module_function
9
-
10
- def table_exists?(name)
8
+ def self.table_exists?(name)
11
9
  LlmCostTracker::Call.connection.data_source_exists?(name)
12
- rescue StandardError
10
+ rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError,
11
+ ActiveRecord::ConnectionFailed, ActiveRecord::StatementInvalid
13
12
  false
14
13
  end
15
14
  end
@@ -1,23 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "check"
3
+ require_relative "../check"
4
4
  require_relative "probe"
5
5
  require_relative "../ledger"
6
6
 
7
7
  module LlmCostTracker
8
8
  class Doctor
9
9
  class SchemaCheck
10
- def initialize(name:, schema:, table:, optional: false, install_command: "llm_cost_tracker:install")
10
+ def initialize(name:, schema:, table:)
11
11
  @name = name
12
12
  @schema = schema
13
13
  @table = table
14
- @optional = optional
15
- @install_command = install_command
16
14
  end
17
15
 
18
16
  def call
19
17
  return unless Probe.table_exists?("llm_cost_tracker_calls")
20
- return if @optional && !Probe.table_exists?(@table)
21
18
 
22
19
  errors = @schema.current_schema_errors
23
20
  return Check.new(:ok, @name, "#{@table} exists") if errors.empty?
@@ -26,7 +23,7 @@ module LlmCostTracker
26
23
  :error,
27
24
  @name,
28
25
  "current schema required; #{errors.join('; ')}; " \
29
- "run bin/rails generate #{@install_command} && bin/rails db:migrate"
26
+ "run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
30
27
  )
31
28
  end
32
29
  end
@@ -1,25 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "ledger"
4
- require_relative "doctor/check"
4
+ require_relative "check"
5
5
  require_relative "doctor/probe"
6
6
  require_relative "doctor/ingestion_check"
7
- require_relative "doctor/legacy_audit_check"
8
- require_relative "doctor/legacy_billing_status_check"
9
7
  require_relative "doctor/price_check"
10
8
  require_relative "doctor/schema_check"
11
- require_relative "doctor/cost_drift_check"
12
- require_relative "doctor/pricing_snapshot_drift_check"
13
9
 
14
10
  module LlmCostTracker
15
11
  class Doctor
16
- autoload :InvoiceReconciliationCheck, "llm_cost_tracker/doctor/invoice_reconciliation_check"
17
- autoload :CaptureVerifier, "llm_cost_tracker/doctor/capture_verifier"
18
-
19
12
  STATUS_GLYPHS = { ok: "✓", warn: "!", error: "x" }.freeze
20
13
  STATUS_COLORS = { ok: 32, warn: 33, error: 31 }.freeze
21
14
 
22
- SECTIONS = ["Setup", "Schema", "Data integrity", "Operations"].freeze
15
+ SECTIONS = %w[Setup Schema Operations].freeze
23
16
 
24
17
  SECTION_FOR_CHECK = {
25
18
  "configuration" => "Setup",
@@ -29,13 +22,6 @@ module LlmCostTracker
29
22
  "llm_cost_tracker_calls columns" => "Schema",
30
23
  "call line items" => "Schema",
31
24
  "call tags" => "Schema",
32
- "provider invoices" => "Schema",
33
- "provider invoice imports" => "Schema",
34
- "cost drift" => "Data integrity",
35
- "pricing snapshot drift" => "Data integrity",
36
- "pricing snapshot audit" => "Data integrity",
37
- "cost status" => "Data integrity",
38
- "invoice reconciliation" => "Data integrity",
39
25
  "call rollups" => "Operations",
40
26
  "inline ingestion" => "Operations",
41
27
  "async ingestion" => "Operations",
@@ -103,12 +89,6 @@ module LlmCostTracker
103
89
  table_check,
104
90
  column_check,
105
91
  *dependent_core_schema_checks,
106
- *reconciliation_schema_checks,
107
- CostDriftCheck.new.call,
108
- PricingSnapshotDriftCheck.new.call,
109
- *reconciliation_invoice_check,
110
- LegacyBillingStatusCheck.new.call,
111
- LegacyAuditCheck.new.call,
112
92
  call_rollups_check,
113
93
  IngestionCheck.new.call,
114
94
  PriceCheck.new.call,
@@ -121,26 +101,11 @@ module LlmCostTracker
121
101
  def dependent_core_schema_checks
122
102
  Ledger::Schema::CORE_SCHEMAS.reject { |schema, _| schema == Ledger::Schema::Calls }.map do |schema, table|
123
103
  SchemaCheck.new(name: table.delete_prefix("llm_cost_tracker_").tr("_", " "),
124
- schema: schema, table: table).call
104
+ schema: schema,
105
+ table: table).call
125
106
  end
126
107
  end
127
108
 
128
- def reconciliation_schema_checks
129
- return [] unless LlmCostTracker.reconciliation_enabled?
130
-
131
- Reconciliation::SCHEMA_TABLES.map do |schema, table|
132
- SchemaCheck.new(name: table.delete_prefix("llm_cost_tracker_").tr("_", " "),
133
- schema: schema, table: table,
134
- optional: false, install_command: "llm_cost_tracker:reconciliation").call
135
- end.compact
136
- end
137
-
138
- def reconciliation_invoice_check
139
- return [] unless LlmCostTracker.reconciliation_enabled?
140
-
141
- Array(InvoiceReconciliationCheck.new.call)
142
- end
143
-
144
109
  def configuration_check
145
110
  config = LlmCostTracker.configuration
146
111
  Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
@@ -202,7 +167,7 @@ module LlmCostTracker
202
167
  return live_rollups_check unless LlmCostTracker.configuration.cache_rollups
203
168
 
204
169
  errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
205
- return rollups_drift_check if errors.empty?
170
+ return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
206
171
 
207
172
  Check.new(
208
173
  :error,
@@ -211,35 +176,6 @@ module LlmCostTracker
211
176
  )
212
177
  end
213
178
 
214
- ROLLUPS_DRIFT_TOLERANCE_PERCENT = 1.0
215
- private_constant :ROLLUPS_DRIFT_TOLERANCE_PERCENT
216
-
217
- def rollups_drift_check
218
- drift_window = Time.now.utc.beginning_of_day
219
- calls_total = LlmCostTracker::Call
220
- .where(tracked_at: drift_window..)
221
- .where.not(total_cost: nil)
222
- .sum(:total_cost)
223
- rollup_total = LlmCostTracker::CallRollup
224
- .where(period: "day", period_start: drift_window.to_date)
225
- .sum(:total_cost)
226
- return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if calls_total.zero?
227
-
228
- drift_percent = ((calls_total - rollup_total).abs * 100.0 / calls_total)
229
- if drift_percent > ROLLUPS_DRIFT_TOLERANCE_PERCENT
230
- return Check.new(
231
- :warn, "call rollups",
232
- "rollups drift detected: today's calls SUM=#{calls_total} vs rollups SUM=#{rollup_total} " \
233
- "(#{drift_percent.round(2)}% > #{ROLLUPS_DRIFT_TOLERANCE_PERCENT}% threshold). " \
234
- "Cached budget reads may understate spend until a rebuild."
235
- )
236
- end
237
-
238
- Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists")
239
- rescue StandardError => e
240
- Check.new(:warn, "call rollups", "rollups drift check failed: #{e.class}: #{e.message}")
241
- end
242
-
243
179
  def live_rollups_check
244
180
  if Probe.table_exists?("llm_cost_tracker_call_rollups")
245
181
  Check.new(
@@ -9,12 +9,12 @@ module LlmCostTracker
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace LlmCostTracker
11
11
 
12
- initializer "llm_cost_tracker.filter_parameters" do |app|
13
- app.config.filter_parameters += %i[tag tag_value]
14
- end
15
-
16
12
  initializer "llm_cost_tracker.dashboard_setup_state" do |app|
17
13
  app.reloader.to_prepare { LlmCostTracker::Dashboard::SetupState.reset! }
18
14
  end
15
+
16
+ initializer "llm_cost_tracker.pricing_cache" do |app|
17
+ app.reloader.to_prepare { LlmCostTracker::Pricing::Registry.reset! }
18
+ end
19
19
  end
20
20
  end
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "pricing/mode"
4
- require_relative "billing/line_item"
5
-
6
3
  module LlmCostTracker
7
4
  Event = Data.define(
8
5
  :event_id,
@@ -19,38 +16,30 @@ module LlmCostTracker
19
16
  :provider_project_id,
20
17
  :provider_api_key_id,
21
18
  :provider_workspace_id,
22
- :batch,
23
19
  :tracked_at,
24
20
  :cost_status,
25
21
  :pricing_snapshot,
26
22
  :line_items
27
23
  ) do
28
- def self.batch_from_pricing_mode?(pricing_mode)
29
- pricing_mode.to_s.split("_").include?("batch")
30
- end
31
-
32
24
  def self.build(**attributes)
33
- pricing_mode = Pricing::Mode.normalize(attributes[:pricing_mode])
34
25
  token_usage = attributes.fetch(:token_usage)
35
- batch = attributes[:batch].nil? ? batch_from_pricing_mode?(pricing_mode) : attributes[:batch]
36
- line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items], token_usage)
26
+ line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items])
37
27
 
38
28
  new(
39
29
  event_id: attributes[:event_id],
40
30
  provider: attributes.fetch(:provider).to_s,
41
31
  model: attributes.fetch(:model).to_s.strip.presence || Event::UNKNOWN_MODEL,
42
32
  token_usage: token_usage,
43
- pricing_mode: pricing_mode,
33
+ pricing_mode: attributes[:pricing_mode],
44
34
  cost: attributes[:cost],
45
35
  tags: attributes[:tags],
46
36
  latency_ms: attributes[:latency_ms],
47
37
  stream: attributes[:stream] || false,
48
- usage_source: attributes[:usage_source],
38
+ usage_source: attributes[:usage_source]&.to_s,
49
39
  provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
50
40
  provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
51
41
  provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
52
42
  provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
53
- batch: batch,
54
43
  tracked_at: attributes[:tracked_at],
55
44
  cost_status: attributes[:cost_status],
56
45
  pricing_snapshot: attributes[:pricing_snapshot],
@@ -58,21 +47,24 @@ module LlmCostTracker
58
47
  )
59
48
  end
60
49
 
61
- def self.resolve_line_items(service_items, token_usage)
62
- service_line_items = Array(service_items).map do |item|
63
- item.is_a?(Billing::LineItem) ? item : Billing::LineItem.build(item)
50
+ def batch?
51
+ pricing_mode.to_s.split("_").include?("batch")
52
+ end
53
+
54
+ def self.resolve_line_items(service_items)
55
+ Array(service_items).map do |item|
56
+ item.is_a?(Charges::LineItem) ? item : Charges::LineItem.build(item)
64
57
  end
65
- Billing::LineItem.from_token_usage(token_usage) + service_line_items
66
58
  end
67
59
 
68
60
  def total_cost
69
- cost&.fetch(:total_cost, nil)
61
+ cost&.total
70
62
  end
71
63
 
72
64
  def to_h
73
65
  super.merge(
74
66
  token_usage: token_usage.to_h,
75
- cost: cost && cost.to_h.transform_values { |v| v.is_a?(BigDecimal) ? v.to_f : v },
67
+ cost: cost&.to_h,
76
68
  tags: tags ? tags.to_h : {},
77
69
  line_items: (line_items || []).map(&:to_h)
78
70
  )
@@ -2,10 +2,9 @@
2
2
 
3
3
  require "rails/generators"
4
4
  require "rails/generators/active_record"
5
- require "llm_cost_tracker/billing/components"
6
- require "llm_cost_tracker/billing/cost_status"
5
+ require "llm_cost_tracker/charges/cost_status"
7
6
  require "llm_cost_tracker/pricing"
8
- require "llm_cost_tracker/token_usage"
7
+ require "llm_cost_tracker/usage/token_usage"
9
8
 
10
9
  module LlmCostTracker
11
10
  module Generators