llm_cost_tracker 0.10.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -17,7 +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].freeze
20
+ ingestion_pool_size auto_enable_stream_usage cache_rollups].freeze
21
21
  ENUM_ATTRIBUTES = {
22
22
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
23
23
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn],
@@ -34,11 +34,7 @@ module LlmCostTracker
34
34
  :report_tag_breakdowns,
35
35
  :redacted_tag_keys,
36
36
  :unknown_pricing_behavior,
37
- :openai_compatible_providers,
38
- :reconciliation_importers,
39
- :reconciliation_enabled,
40
- :auto_enable_stream_usage,
41
- :cache_rollups
37
+ :openai_compatible_providers
42
38
  )
43
39
 
44
40
  def initialize
@@ -60,51 +56,12 @@ module LlmCostTracker
60
56
  @report_tag_breakdowns = []
61
57
  @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
62
58
  self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
63
- @reconciliation_importers = {}
64
- @reconciliation_enabled = false
65
59
  @auto_enable_stream_usage = true
66
60
  self.ingestion = :inline
67
61
  @cache_rollups = false
68
62
  @finalized = false
69
63
  end
70
64
 
71
- def cache_rollups=(value)
72
- ensure_mutable!
73
- @cache_rollups = value
74
- end
75
-
76
- def reconciliation_enabled=(value)
77
- ensure_mutable!
78
- @reconciliation_enabled = value
79
- end
80
-
81
- def auto_enable_stream_usage=(value)
82
- ensure_mutable!
83
- @auto_enable_stream_usage = value
84
- end
85
-
86
- def reconciliation_importers=(importers)
87
- ensure_mutable!
88
- raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
89
-
90
- @reconciliation_importers = (importers || {}).to_h do |source, importer|
91
- raise Error, "reconciliation_importers[#{source}] must respond to call" unless importer.respond_to?(:call)
92
-
93
- [source.to_sym, importer]
94
- end
95
- end
96
-
97
- def register_reconciliation_importer(source, &block)
98
- ensure_mutable!
99
- raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
100
- raise Error, "register_reconciliation_importer requires a block" unless block
101
-
102
- @reconciliation_importers[source.to_sym] = block
103
- end
104
-
105
- RECONCILIATION_DISABLED_MESSAGE = "reconciliation is disabled; set config.reconciliation_enabled = true first"
106
- private_constant :RECONCILIATION_DISABLED_MESSAGE
107
-
108
65
  def openai_compatible_providers=(providers)
109
66
  ensure_mutable!
110
67
  @openai_compatible_providers = normalize_openai_compatible_providers(providers)
@@ -112,8 +69,8 @@ module LlmCostTracker
112
69
 
113
70
  def pricing_overrides=(value)
114
71
  ensure_mutable!
115
- @pricing_overrides = Pricing::Registry.normalize_price_table(value || {})
116
- rescue ArgumentError => e
72
+ @pricing_overrides = Pricing::Registry.normalize_price_entries(value || {}, context: "pricing_overrides")
73
+ rescue ArgumentError, TypeError => e
117
74
  raise Error, "invalid pricing_overrides: #{e.message}"
118
75
  end
119
76
 
@@ -129,7 +86,9 @@ module LlmCostTracker
129
86
 
130
87
  def instrument(*names)
131
88
  ensure_mutable!
132
- @instrumented_integrations.merge(normalize_instrumentation_names(names))
89
+ names = names.flatten
90
+ names = Integrations.names if names == [:all]
91
+ @instrumented_integrations.merge(names)
133
92
  end
134
93
 
135
94
  def instrumented?(name)
@@ -167,6 +126,12 @@ module LlmCostTracker
167
126
  Array(@redacted_tag_keys).map { |key| Tags::Sanitizer.normalized_key(key) }.freeze
168
127
  end
169
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
+
170
135
  def finalized?
171
136
  @finalized
172
137
  end
@@ -186,19 +151,6 @@ module LlmCostTracker
186
151
  end
187
152
  end
188
153
 
189
- def normalize_instrumentation_names(names)
190
- names = names.flatten
191
- integrations = Integrations.names
192
- return integrations if names == [:all]
193
-
194
- names.each do |name|
195
- next if integrations.include?(name)
196
-
197
- raise Error, "Unknown integration: #{name.inspect}. Use one of: #{integrations.join(', ')}"
198
- end
199
- names
200
- end
201
-
202
154
  def ensure_mutable!
203
155
  return unless finalized?
204
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,35 +1,83 @@
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"
12
+ STATUS_GLYPHS = { ok: "✓", warn: "!", error: "x" }.freeze
13
+ STATUS_COLORS = { ok: 32, warn: 33, error: 31 }.freeze
14
+
15
+ SECTIONS = %w[Setup Schema Operations].freeze
16
+
17
+ SECTION_FOR_CHECK = {
18
+ "configuration" => "Setup",
19
+ "capture" => "Setup",
20
+ "active_record" => "Schema",
21
+ "llm_cost_tracker_calls" => "Schema",
22
+ "llm_cost_tracker_calls columns" => "Schema",
23
+ "call line items" => "Schema",
24
+ "call tags" => "Schema",
25
+ "call rollups" => "Operations",
26
+ "inline ingestion" => "Operations",
27
+ "async ingestion" => "Operations",
28
+ "prices" => "Operations",
29
+ "tracked calls" => "Operations"
30
+ }.freeze
31
+
32
+ private_constant :STATUS_GLYPHS, :STATUS_COLORS, :SECTIONS, :SECTION_FOR_CHECK
18
33
 
19
34
  class << self
20
35
  def call
21
36
  new.checks
22
37
  end
23
38
 
24
- def report(checks = call)
25
- (["LLM Cost Tracker doctor"] + checks.map do |check|
26
- "[#{check.status}] #{check.name}: #{check.message}"
27
- end).join("\n")
39
+ def report(checks = call, color: $stdout.tty?)
40
+ name_width = checks.map { |c| c.name.length }.max.to_i
41
+
42
+ lines = [bold("LLM Cost Tracker doctor", color), ""]
43
+ each_section(checks) do |section, members|
44
+ lines << bold(section, color)
45
+ members.each do |check|
46
+ status = paint_status("[#{STATUS_GLYPHS.fetch(check.status, check.status)}]", check.status, color)
47
+ lines << " #{status} #{"#{check.name}:".ljust(name_width + 1)} #{check.message}"
48
+ end
49
+ lines << ""
50
+ end
51
+ lines.pop if lines.last == ""
52
+ lines.join("\n")
28
53
  end
29
54
 
30
55
  def healthy?(checks = call)
31
56
  checks.none? { |check| check.status == :error }
32
57
  end
58
+
59
+ private
60
+
61
+ def each_section(checks)
62
+ SECTIONS.each do |section|
63
+ members = checks.select { |c| (SECTION_FOR_CHECK[c.name] || "Setup") == section }
64
+ next if members.empty?
65
+
66
+ yield section, members
67
+ end
68
+ end
69
+
70
+ def paint_status(text, status, color)
71
+ return text unless color && STATUS_COLORS.key?(status)
72
+
73
+ "\e[#{STATUS_COLORS[status]}m#{text}\e[0m"
74
+ end
75
+
76
+ def bold(text, color)
77
+ return text unless color
78
+
79
+ "\e[1m#{text}\e[0m"
80
+ end
33
81
  end
34
82
 
35
83
  def checks
@@ -40,16 +88,7 @@ module LlmCostTracker
40
88
  active_record_check,
41
89
  table_check,
42
90
  column_check,
43
- SchemaCheck.new(name: "call line items", schema: Ledger::Schema::CallLineItems,
44
- table: "llm_cost_tracker_call_line_items").call,
45
- SchemaCheck.new(name: "call tags", schema: Ledger::Schema::CallTags,
46
- table: "llm_cost_tracker_call_tags").call,
47
- *reconciliation_schema_checks,
48
- CostDriftCheck.new.call,
49
- PricingSnapshotDriftCheck.new.call,
50
- *reconciliation_invoice_check,
51
- LegacyBillingStatusCheck.new.call,
52
- LegacyAuditCheck.new.call,
91
+ *dependent_core_schema_checks,
53
92
  call_rollups_check,
54
93
  IngestionCheck.new.call,
55
94
  PriceCheck.new.call,
@@ -59,20 +98,12 @@ module LlmCostTracker
59
98
 
60
99
  private
61
100
 
62
- def reconciliation_schema_checks
63
- return [] unless LlmCostTracker.reconciliation_enabled?
64
-
65
- Reconciliation::SCHEMA_TABLES.map do |schema, table|
101
+ def dependent_core_schema_checks
102
+ Ledger::Schema::CORE_SCHEMAS.reject { |schema, _| schema == Ledger::Schema::Calls }.map do |schema, table|
66
103
  SchemaCheck.new(name: table.delete_prefix("llm_cost_tracker_").tr("_", " "),
67
- schema: schema, table: table,
68
- optional: false, install_command: "llm_cost_tracker:reconciliation").call
69
- end.compact
70
- end
71
-
72
- def reconciliation_invoice_check
73
- return [] unless LlmCostTracker.reconciliation_enabled?
74
-
75
- Array(InvoiceReconciliationCheck.new.call)
104
+ schema: schema,
105
+ table: table).call
106
+ end
76
107
  end
77
108
 
78
109
  def configuration_check
@@ -136,7 +167,7 @@ module LlmCostTracker
136
167
  return live_rollups_check unless LlmCostTracker.configuration.cache_rollups
137
168
 
138
169
  errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
139
- return rollups_drift_check if errors.empty?
170
+ return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
140
171
 
141
172
  Check.new(
142
173
  :error,
@@ -145,35 +176,6 @@ module LlmCostTracker
145
176
  )
146
177
  end
147
178
 
148
- ROLLUPS_DRIFT_TOLERANCE_PERCENT = 1.0
149
- private_constant :ROLLUPS_DRIFT_TOLERANCE_PERCENT
150
-
151
- def rollups_drift_check
152
- drift_window = Time.now.utc.beginning_of_day
153
- calls_total = LlmCostTracker::Call
154
- .where(tracked_at: drift_window..)
155
- .where.not(total_cost: nil)
156
- .sum(:total_cost)
157
- rollup_total = LlmCostTracker::CallRollup
158
- .where(period: "day", period_start: drift_window.to_date)
159
- .sum(:total_cost)
160
- return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if calls_total.zero?
161
-
162
- drift_percent = ((calls_total - rollup_total).abs * 100.0 / calls_total)
163
- if drift_percent > ROLLUPS_DRIFT_TOLERANCE_PERCENT
164
- return Check.new(
165
- :warn, "call rollups",
166
- "rollups drift detected: today's calls SUM=#{calls_total} vs rollups SUM=#{rollup_total} " \
167
- "(#{drift_percent.round(2)}% > #{ROLLUPS_DRIFT_TOLERANCE_PERCENT}% threshold). " \
168
- "Cached budget reads may understate spend until a rebuild."
169
- )
170
- end
171
-
172
- Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists")
173
- rescue StandardError => e
174
- Check.new(:warn, "call rollups", "rollups drift check failed: #{e.class}: #{e.message}")
175
- end
176
-
177
179
  def live_rollups_check
178
180
  if Probe.table_exists?("llm_cost_tracker_call_rollups")
179
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"
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.normalize_mode(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
@@ -11,11 +11,14 @@ module LlmCostTracker
11
11
  class PricesGenerator < Rails::Generators::Base
12
12
  desc "Creates a local LLM Cost Tracker price snapshot"
13
13
 
14
+ PRICES_PATH = "config/llm_cost_tracker_prices.yml"
15
+
14
16
  def create_prices_file
15
- LlmCostTracker::Pricing::Sync::RegistryWriter.new.call(
16
- path: File.join(destination_root, "config/llm_cost_tracker_prices.yml"),
17
+ payload = LlmCostTracker::Pricing::Sync::RegistryWriter.new.render(
18
+ path: File.join(destination_root, PRICES_PATH),
17
19
  registry: YAML.safe_load_file(LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
18
20
  )
21
+ create_file(PRICES_PATH, payload)
19
22
  end
20
23
  end
21
24
  end
@@ -1,5 +1,4 @@
1
- require "llm_cost_tracker/billing/components"
2
- require "llm_cost_tracker/billing/cost_status"
1
+ require "llm_cost_tracker/charges/cost_status"
3
2
  require "llm_cost_tracker/ledger/schema/adapter"
4
3
 
5
4
  class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %>
@@ -8,7 +7,7 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
8
7
  t.string :event_id, null: false
9
8
  t.string :provider, null: false
10
9
  t.string :model, null: false
11
- <% LlmCostTracker::TokenUsage.members.each do |column| -%>
10
+ <% LlmCostTracker::Usage::TokenUsage.members.each do |column| -%>
12
11
  t.integer :<%= column %>, null: false, default: 0
13
12
  <% end -%>
14
13
  t.decimal :total_cost, precision: 20, scale: 8
@@ -21,7 +20,7 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
21
20
  t.string :provider_workspace_id
22
21
  t.boolean :batch, null: false, default: false
23
22
  t.string :pricing_mode
24
- t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
23
+ t.string :cost_status, null: false, default: LlmCostTracker::Charges::CostStatus::UNKNOWN
25
24
  if postgresql?
26
25
  t.jsonb :pricing_snapshot
27
26
  elsif mysql?
@@ -50,7 +49,7 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
50
49
  t.decimal :rate_quantity, precision: 30, scale: 10, null: false, default: 1
51
50
  t.decimal :cost, precision: 20, scale: 8
52
51
  t.string :currency, null: false, default: "USD"
53
- t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
52
+ t.string :cost_status, null: false, default: LlmCostTracker::Charges::CostStatus::UNKNOWN
54
53
  t.string :pricing_basis
55
54
  t.string :price_key
56
55
  t.string :price_source
@@ -57,8 +57,9 @@ LlmCostTracker.configure do |config|
57
57
  # thread. Set to :async for a write-ahead inbox + background worker that batches
58
58
  # inserts and survives caller transaction rollbacks. Requires the optional
59
59
  # inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
60
- # The worker uses a dedicated ActiveRecord pool (defaults to 2 connections) so it
61
- # doesn't compete with request threads bump ingestion_pool_size if your Puma
60
+ # Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
61
+ # so they don't compete with request threads for the default pool when a tracked call
62
+ # happens inside an open caller transaction. Bump ingestion_pool_size if your Puma
62
63
  # worker count outgrows that.
63
64
  # config.ingestion = :async
64
65
  # config.ingestion_pool_size = 5
@@ -8,12 +8,16 @@ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migr
8
8
  NEW_INDEX = %i[period period_start currency provider].freeze
9
9
 
10
10
  def up
11
+ return unless table_exists?(TABLE)
12
+
11
13
  add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
12
14
  add_unique_index NEW_INDEX
13
15
  remove_index TABLE, column: OLD_INDEX, unique: true, if_exists: true
14
16
  end
15
17
 
16
18
  def down
19
+ return unless table_exists?(TABLE)
20
+
17
21
  add_unique_index OLD_INDEX
18
22
  remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
19
23
  remove_column TABLE, :provider if column_exists?(TABLE, :provider)