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
@@ -1,95 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "psych"
4
-
5
- require_relative "../errors"
6
-
7
- module LlmCostTracker
8
- module Billing
9
- RATE_BASES = %i[
10
- per_million_tokens
11
- per_million_characters
12
- per_request
13
- per_1k_requests
14
- per_session
15
- per_hour
16
- per_gb_day
17
- per_image
18
- ].freeze
19
-
20
- RATE_BASIS_QUANTITIES = {
21
- per_million_tokens: 1_000_000,
22
- per_million_characters: 1_000_000,
23
- per_request: 1,
24
- per_1k_requests: 1_000,
25
- per_session: 1,
26
- per_hour: 1,
27
- per_gb_day: 1,
28
- per_image: 1
29
- }.freeze
30
-
31
- DEFAULT_RATE_BASIS_BY_UNIT = {
32
- token: :per_million_tokens,
33
- character: :per_million_characters,
34
- request: :per_request,
35
- session: :per_session,
36
- hour: :per_hour,
37
- image: :per_image
38
- }.freeze
39
-
40
- module Components
41
- Component = Data.define(
42
- :key,
43
- :kind,
44
- :direction,
45
- :modality,
46
- :cache_state,
47
- :unit,
48
- :category,
49
- :token_key,
50
- :cost_key,
51
- :rate_basis
52
- )
53
-
54
- REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
55
- DEFINITIONS_PATH = File.expand_path("components.yml", __dir__)
56
-
57
- def self.load_registry
58
- Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
59
- .map { |attributes| build(attributes) }
60
- .freeze
61
- end
62
-
63
- def self.build(attributes)
64
- missing = REQUIRED_FIELDS - attributes.keys
65
- raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
66
-
67
- unit = attributes.fetch(:unit).to_sym
68
- rate_basis = attributes[:rate_basis]&.to_sym || Billing::DEFAULT_RATE_BASIS_BY_UNIT[unit]
69
- if rate_basis.nil?
70
- raise Error, "components.yml entry needs rate_basis for unit #{unit.inspect}: #{attributes.inspect}"
71
- end
72
- unless Billing::RATE_BASES.include?(rate_basis)
73
- raise Error, "components.yml entry has unknown rate_basis #{rate_basis.inspect}: #{attributes.inspect}"
74
- end
75
-
76
- Component.new(
77
- key: attributes.fetch(:key).to_sym,
78
- kind: attributes.fetch(:kind).to_sym,
79
- direction: attributes.fetch(:direction).to_sym,
80
- modality: attributes.fetch(:modality).to_sym,
81
- cache_state: attributes.fetch(:cache_state).to_sym,
82
- unit: unit,
83
- category: attributes.fetch(:category).to_sym,
84
- token_key: attributes[:token_key]&.to_sym,
85
- cost_key: attributes[:cost_key]&.to_sym,
86
- rate_basis: rate_basis
87
- )
88
- end
89
-
90
- REGISTRY = load_registry
91
- BY_KEY = REGISTRY.to_h { |component| [component.key, component] }.freeze
92
- TOKEN_PRICED = REGISTRY.select { |component| component.token_key && component.cost_key }.freeze
93
- end
94
- end
95
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Capture
5
- module Stream
6
- LIMIT_BYTES = 1_048_576
7
- end
8
- end
9
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "check"
4
- require_relative "../ingestion"
5
-
6
- module LlmCostTracker
7
- class Doctor
8
- class CaptureVerifier
9
- class << self
10
- def call
11
- new.checks
12
- end
13
-
14
- def report(checks = call)
15
- (["LLM Cost Tracker capture verification"] + checks.map do |check|
16
- "[#{check.status}] #{check.name}: #{check.message}"
17
- end).join("\n")
18
- end
19
-
20
- def healthy?(checks = call)
21
- checks.none? { |check| check.status == :error }
22
- end
23
- end
24
-
25
- def checks
26
- [
27
- enabled_check,
28
- *integration_checks,
29
- *storage_checks
30
- ].compact
31
- end
32
-
33
- private
34
-
35
- def enabled_check
36
- return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
37
-
38
- Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
39
- end
40
-
41
- def integration_checks
42
- enabled = LlmCostTracker.configuration.instrumented_integrations
43
- if enabled.empty?
44
- return [
45
- Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
46
- ]
47
- end
48
-
49
- LlmCostTracker::Integrations.checks.map do |check|
50
- check.with(name: "sdk integration #{check.name}")
51
- end
52
- end
53
-
54
- def storage_checks
55
- LlmCostTracker::Ingestion.verify
56
- rescue LlmCostTracker::Error => e
57
- [Check.new(:error, "storage", e.message)]
58
- end
59
- end
60
- end
61
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- class Doctor
5
- Check = Data.define(:status, :name, :message)
6
- end
7
- end
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "check"
6
- require_relative "probe"
7
- require_relative "../ledger/rollups"
8
-
9
- module LlmCostTracker
10
- class Doctor
11
- class CostDriftCheck
12
- SAMPLE_SIZE = 200
13
- EPSILON = BigDecimal("0.00000001")
14
-
15
- def call
16
- return unless Probe.table_exists?("llm_cost_tracker_calls")
17
- return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
18
-
19
- sampled = LlmCostTracker::Call
20
- .where.not(total_cost: nil)
21
- .where(cost_status: %w[complete free partial])
22
- .order(id: :desc)
23
- .limit(SAMPLE_SIZE)
24
- .pluck(:id, :total_cost, :cost_status)
25
- return Check.new(:ok, "cost drift", "no priced calls to inspect") if sampled.empty?
26
-
27
- line_item_totals = LlmCostTracker::CallLineItem
28
- .where(llm_cost_tracker_call_id: sampled.map(&:first))
29
- .where(currency: Ledger::Rollups::DEFAULT_CURRENCY)
30
- .group(:llm_cost_tracker_call_id)
31
- .sum(:cost)
32
-
33
- drifted = sampled.filter_map do |id, total_cost, cost_status|
34
- line_total = line_item_totals[id] || BigDecimal("0")
35
- header = BigDecimal(total_cost.to_s)
36
- next if cost_status == "partial" && header >= line_total
37
- next if (header - line_total).abs <= EPSILON
38
-
39
- "##{id}: header=#{header.to_s('F')} line_items=#{line_total.to_s('F')}"
40
- end
41
-
42
- if drifted.empty?
43
- return Check.new(:ok, "cost drift",
44
- "header total_cost matches line items in #{sampled.size} sampled calls")
45
- end
46
-
47
- Check.new(
48
- :warn,
49
- "cost drift",
50
- "header total_cost diverges from line items in #{drifted.size}/#{sampled.size} sampled calls: " \
51
- "#{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
52
- )
53
- end
54
- end
55
- end
56
- end
@@ -1,164 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "check"
6
- require_relative "probe"
7
- require_relative "../ledger/schema/adapter"
8
-
9
- module LlmCostTracker
10
- class Doctor
11
- class InvoiceReconciliationCheck
12
- def call
13
- return unless LlmCostTracker.reconciliation_enabled?
14
- return unless Probe.table_exists?("llm_cost_tracker_provider_invoices")
15
- return if no_imports?
16
-
17
- scopes = imported_scopes
18
- return Check.new(:ok, "invoice reconciliation", "no provider invoices imported yet") if scopes.empty?
19
-
20
- non_canonical = non_canonical_currency_check
21
- checks = scopes.map { |scope| check_scope_safely(scope) }
22
- checks << non_canonical if non_canonical
23
- checks
24
- rescue StandardError => e
25
- Check.new(:error, "invoice reconciliation", e.message)
26
- end
27
-
28
- private
29
-
30
- def no_imports?
31
- LlmCostTracker::ProviderInvoice.none?
32
- end
33
-
34
- def non_canonical_currency_check
35
- legacy = LlmCostTracker::ProviderInvoice.where("currency <> UPPER(currency)").count
36
- return nil if legacy.zero?
37
-
38
- Check.new(
39
- :warn,
40
- "invoice reconciliation: currency canonicalisation",
41
- "#{legacy} provider invoice row(s) stored with non-uppercase currency. Diff queries " \
42
- "are case-sensitive — run " \
43
- "`UPDATE llm_cost_tracker_provider_invoices SET currency = UPPER(currency);` to backfill."
44
- )
45
- end
46
-
47
- def threshold
48
- Reconciliation::DEFAULT_THRESHOLD_PERCENT
49
- end
50
-
51
- def imported_scopes
52
- connection = LlmCostTracker::ProviderInvoice.connection
53
- provider_expr =
54
- if Ledger::Schema::Adapter.postgresql?(connection)
55
- Arel.sql("metadata->>'provider'")
56
- else
57
- Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
58
- end
59
- LlmCostTracker::ProviderInvoice
60
- .group(:source, provider_expr, :currency)
61
- .order(:source, :currency)
62
- .pluck(:source, provider_expr, :currency)
63
- .map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
64
- end
65
-
66
- def scope_label(scope)
67
- "#{scope[:source]}/#{scope[:provider]}/#{scope[:currency]}"
68
- end
69
-
70
- def check_scope_safely(scope)
71
- check_scope(scope)
72
- rescue ArgumentError => e
73
- Check.new(:warn, "invoice reconciliation: #{scope_label(scope)}", e.message)
74
- end
75
-
76
- def check_scope(scope)
77
- window = latest_window_for(scope)
78
- return stale_check(scope) if window.nil?
79
-
80
- diff = run_diff(scope, window)
81
- return ok_check(scope, window, diff) if diff.aligned?(threshold_percent: threshold)
82
-
83
- warn_check(scope, window, diff)
84
- end
85
-
86
- def scope_relation(scope)
87
- relation = LlmCostTracker::ProviderInvoice
88
- .where(source: scope[:source], currency: scope[:currency])
89
- provider = scope[:provider]
90
- return relation if provider.nil? || provider.to_s.empty?
91
-
92
- connection = LlmCostTracker::ProviderInvoice.connection
93
- if Ledger::Schema::Adapter.postgresql?(connection)
94
- relation.where("metadata->>'provider' = ?", provider)
95
- else
96
- relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
97
- end
98
- end
99
-
100
- def latest_window_for(scope)
101
- latest = scope_relation(scope)
102
- .select(:period_start, :period_end)
103
- .order(period_end: :desc, period_start: :desc)
104
- .limit(1)
105
- .first
106
- return nil unless latest
107
- return nil if (Time.now.utc.to_date - latest.period_end).to_i > Reconciliation::INVOICE_FRESHNESS_DAYS
108
-
109
- latest
110
- end
111
-
112
- def run_diff(scope, window)
113
- Reconciliation.diff(
114
- source: scope[:source],
115
- provider: scope[:provider],
116
- currency: scope[:currency],
117
- period_start: window.period_start,
118
- period_end: window.period_end
119
- )
120
- end
121
-
122
- def stale_check(scope)
123
- latest = scope_relation(scope).maximum(:period_end)
124
- return scope_unreachable_check(scope) if latest.nil?
125
-
126
- days = (Time.now.utc.to_date - latest).to_i
127
- Check.new(
128
- :warn,
129
- "invoice reconciliation: #{scope_label(scope)}",
130
- "no invoice imported in #{days} days (threshold #{Reconciliation::INVOICE_FRESHNESS_DAYS} days); " \
131
- "run reconciliation import"
132
- )
133
- end
134
-
135
- def scope_unreachable_check(scope)
136
- Check.new(
137
- :warn,
138
- "invoice reconciliation: #{scope_label(scope)}",
139
- "scope grouped invoices but the filter (likely currency case mismatch) matches zero rows; " \
140
- "the currency-canonicalisation check below points at the backfill SQL"
141
- )
142
- end
143
-
144
- def ok_check(scope, window, diff)
145
- Check.new(
146
- :ok,
147
- "invoice reconciliation: #{scope_label(scope)}",
148
- "#{window.period_start}..#{window.period_end} aligned " \
149
- "(local=#{diff.local_total.to_s('F')}, provider=#{diff.provider_total.to_s('F')})"
150
- )
151
- end
152
-
153
- def warn_check(scope, window, diff)
154
- Check.new(
155
- :warn,
156
- "invoice reconciliation: #{scope_label(scope)}",
157
- "#{window.period_start}..#{window.period_end} drift " \
158
- "delta=#{diff.delta_amount.to_s('F')} (#{diff.delta_percent}%) " \
159
- "exceeds #{threshold}% threshold"
160
- )
161
- end
162
- end
163
- end
164
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "check"
4
- require_relative "probe"
5
- require_relative "../ledger"
6
-
7
- module LlmCostTracker
8
- class Doctor
9
- class LegacyAuditCheck
10
- WARNING_PERCENT = 10
11
-
12
- def call
13
- return unless Probe.table_exists?("llm_cost_tracker_calls")
14
- return unless LlmCostTracker::Call.column_names.include?("pricing_snapshot")
15
-
16
- counts = LlmCostTracker::Call
17
- .select(
18
- "COUNT(*) AS total_count, " \
19
- "COALESCE(SUM(CASE WHEN pricing_snapshot IS NULL THEN 1 ELSE 0 END), 0) AS missing_count"
20
- )
21
- .take
22
- total = counts.total_count.to_i
23
- return if total.zero?
24
-
25
- missing = counts.missing_count.to_i
26
- return unless (missing * 100) > (total * WARNING_PERCENT)
27
-
28
- message = "#{missing}/#{total} tracked calls lack pricing_snapshot; " \
29
- "stored totals remain stable but applied rates cannot be audited"
30
- Check.new(:warn, "pricing snapshot audit", message)
31
- end
32
- end
33
- end
34
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "check"
4
- require_relative "probe"
5
- require_relative "../ledger"
6
-
7
- module LlmCostTracker
8
- class Doctor
9
- class LegacyBillingStatusCheck
10
- def call
11
- return unless Probe.table_exists?("llm_cost_tracker_calls")
12
- return unless LlmCostTracker::Call.column_names.include?("cost_status")
13
-
14
- return unless LlmCostTracker::Call.where(cost_status: nil).exists?
15
-
16
- Check.new(:warn, "cost status", "legacy rows without cost_status remain; new rows will populate it")
17
- end
18
- end
19
- end
20
- end
@@ -1,85 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bigdecimal"
4
-
5
- require_relative "check"
6
- require_relative "probe"
7
-
8
- module LlmCostTracker
9
- class Doctor
10
- class PricingSnapshotDriftCheck
11
- SAMPLE_SIZE = 200
12
- EPSILON = BigDecimal("0.00000001")
13
-
14
- def call
15
- return unless Probe.table_exists?("llm_cost_tracker_calls")
16
- return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
17
-
18
- sampled_ids = LlmCostTracker::Call
19
- .where.not(pricing_snapshot: nil)
20
- .where(cost_status: %w[complete free])
21
- .order(id: :desc)
22
- .limit(SAMPLE_SIZE)
23
- .pluck(:id)
24
- return Check.new(:ok, "pricing snapshot drift", "no snapshotted calls to inspect") if sampled_ids.empty?
25
-
26
- calls_by_id = LlmCostTracker::Call.where(id: sampled_ids).index_by(&:id)
27
- line_items_by_call = LlmCostTracker::CallLineItem
28
- .where(llm_cost_tracker_call_id: sampled_ids, unit: "token")
29
- .group_by(&:llm_cost_tracker_call_id)
30
-
31
- drifted = sampled_ids.flat_map do |id|
32
- call = calls_by_id[id]
33
- rates = rates_for(call.pricing_snapshot)
34
- next [] if rates.nil? || rates.empty?
35
-
36
- (line_items_by_call[id] || []).filter_map { |item| drift_message_for(item, rates, call_id: id) }
37
- end
38
-
39
- return ok_check(sampled_ids.size) if drifted.empty?
40
-
41
- Check.new(
42
- :warn,
43
- "pricing snapshot drift",
44
- "line item cost diverges from pricing_snapshot rate in #{drifted.size} cases across " \
45
- "#{sampled_ids.size} sampled calls: #{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
46
- )
47
- end
48
-
49
- private
50
-
51
- def ok_check(sample_size)
52
- Check.new(:ok, "pricing snapshot drift",
53
- "line item costs match pricing_snapshot rates in #{sample_size} sampled calls")
54
- end
55
-
56
- def rates_for(snapshot)
57
- rates = snapshot.is_a?(Hash) ? (snapshot["rates"] || snapshot[:rates]) : nil
58
- rates.is_a?(Hash) ? rates : nil
59
- end
60
-
61
- def drift_message_for(line_item, rates, call_id:)
62
- return nil unless line_item.price_key
63
-
64
- rate = rates[line_item.price_key.to_s] || rates[line_item.price_key.to_sym]
65
- return nil unless rate.is_a?(Hash)
66
-
67
- rate_amount = decimal(rate["amount"] || rate[:amount])
68
- rate_quantity = decimal(rate["quantity"] || rate[:quantity])
69
- return nil if rate_amount.nil? || rate_quantity.nil? || rate_quantity.zero?
70
-
71
- expected = (decimal(line_item.quantity) * rate_amount) / rate_quantity
72
- actual = decimal(line_item.cost) || BigDecimal("0")
73
- return nil if (expected - actual).abs <= EPSILON
74
-
75
- "##{call_id}.#{line_item.price_key}: expected=#{expected.round(8).to_s('F')} stored=#{actual.to_s('F')}"
76
- end
77
-
78
- def decimal(value)
79
- return nil if value.nil?
80
-
81
- BigDecimal(value.to_s)
82
- end
83
- end
84
- end
85
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module LlmCostTracker
7
- module Generators
8
- class ReconciliationGenerator < Rails::Generators::Base
9
- include ActiveRecord::Generators::Migration
10
-
11
- source_root File.expand_path("templates", __dir__)
12
-
13
- desc "Creates the optional invoice reconciliation tables. Requires provider admin/org-level API keys."
14
-
15
- def create_migration_file
16
- migration_template(
17
- "create_llm_cost_tracker_reconciliation.rb.erb",
18
- "db/migrate/create_llm_cost_tracker_reconciliation.rb"
19
- )
20
- end
21
-
22
- def warn_about_admin_keys
23
- say "Reconciliation requires admin/org-level API keys (OpenAI sk-admin-..., Anthropic admin keys, " \
24
- "GCP billing.viewer service accounts). Do NOT use the runtime inference key.", :yellow
25
- end
26
-
27
- private
28
-
29
- def migration_version
30
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
31
- end
32
- end
33
- end
34
- end
@@ -1,60 +0,0 @@
1
- require "llm_cost_tracker/ledger/schema/adapter"
2
-
3
- class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_version %>
4
- def change
5
- create_table :llm_cost_tracker_provider_invoices, if_not_exists: true do |t|
6
- t.string :source, null: false
7
- t.date :period_start, null: false
8
- t.date :period_end, null: false
9
- t.string :external_id, null: false
10
- t.decimal :billed_amount, precision: 20, scale: 8
11
- t.string :currency, null: false, default: "USD"
12
- if postgresql?
13
- t.jsonb :metadata, null: false, default: {}
14
- elsif mysql?
15
- t.json :metadata, null: false
16
- else
17
- raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
18
- end
19
- t.datetime :imported_at, null: false
20
-
21
- t.timestamps
22
- end
23
-
24
- create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
25
- t.string :source, null: false
26
- t.string :provider, null: false, default: ""
27
- t.string :cursor
28
- t.date :window_start
29
- t.date :window_end
30
- t.string :state, null: false
31
- t.text :last_error
32
- t.integer :rows_imported, null: false, default: 0
33
- t.datetime :started_at, null: false
34
- t.datetime :finished_at
35
-
36
- t.timestamps
37
- end
38
-
39
- add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true,
40
- if_not_exists: true
41
- add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
42
- if_not_exists: true
43
- if postgresql?
44
- add_index :llm_cost_tracker_provider_invoices, :metadata, using: :gin,
45
- if_not_exists: true
46
- end
47
- add_index :llm_cost_tracker_provider_invoice_imports, %i[source provider started_at],
48
- if_not_exists: true
49
- end
50
-
51
- private
52
-
53
- def postgresql?
54
- LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
55
- end
56
-
57
- def mysql?
58
- LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
59
- end
60
- end
@@ -1,36 +0,0 @@
1
- require "llm_cost_tracker/ledger/schema/adapter"
2
-
3
- class UpgradeLlmCostTrackerProviderInvoiceImportsProvider < ActiveRecord::Migration<%= migration_version %>
4
- disable_ddl_transaction!
5
-
6
- TABLE = :llm_cost_tracker_provider_invoice_imports
7
- OLD_INDEX = %i[source started_at].freeze
8
- NEW_INDEX = %i[source provider started_at].freeze
9
-
10
- def up
11
- return unless table_exists?(TABLE)
12
-
13
- add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
14
- if postgresql?
15
- remove_index TABLE, column: OLD_INDEX, algorithm: :concurrently, if_exists: true
16
- add_index TABLE, NEW_INDEX, algorithm: :concurrently, if_not_exists: true
17
- else
18
- remove_index TABLE, column: OLD_INDEX, if_exists: true
19
- add_index TABLE, NEW_INDEX, if_not_exists: true
20
- end
21
- end
22
-
23
- def down
24
- return unless table_exists?(TABLE)
25
-
26
- remove_index TABLE, column: NEW_INDEX, if_exists: true
27
- add_index TABLE, OLD_INDEX, if_not_exists: true
28
- remove_column TABLE, :provider if column_exists?(TABLE, :provider)
29
- end
30
-
31
- private
32
-
33
- def postgresql?
34
- LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
35
- end
36
- end