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,54 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bigdecimal"
3
+ require "bigdecimal/util"
4
4
 
5
5
  require_relative "period"
6
- require_relative "rollups/upsert_sql"
7
6
 
8
7
  module LlmCostTracker
9
8
  module Ledger
10
- class Rollups
11
- DEFAULT_CURRENCY = "USD"
12
-
9
+ module Rollups
13
10
  class << self
14
- def increment!(event)
15
- return unless event.total_cost
11
+ def increment!(events)
12
+ events = Array(events).select(&:total_cost)
13
+ return if events.empty?
16
14
 
17
- upsert_call_rollups(period_rows(event))
15
+ LlmCostTracker::CallRollup.increment_all(period_rows_for_events(events))
18
16
  end
19
17
 
20
- def increment_many!(events)
21
- events = Array(events).select(&:total_cost)
22
- return if events.empty?
18
+ ROLLUP_INCREMENT_ATTEMPTS = 3
19
+ ROLLUP_INCREMENT_BASE_DELAY_SECONDS = 0.05
20
+ REBUILD_INSERT_SLICE = 1_000
21
+ private_constant :ROLLUP_INCREMENT_ATTEMPTS, :ROLLUP_INCREMENT_BASE_DELAY_SECONDS, :REBUILD_INSERT_SLICE
22
+
23
+ def increment_safely!(events)
24
+ attempt = 0
25
+ begin
26
+ attempt += 1
27
+ increment!(events)
28
+ rescue StandardError => e
29
+ raise if LlmCostTracker::Call.connection.open_transactions.positive?
30
+
31
+ if attempt < ROLLUP_INCREMENT_ATTEMPTS
32
+ sleep(ROLLUP_INCREMENT_BASE_DELAY_SECONDS * (2**(attempt - 1)))
33
+ retry
34
+ end
23
35
 
24
- upsert_call_rollups(period_rows_for_events(events))
36
+ LlmCostTracker::Logging.warn(
37
+ "Rollup increment failed for #{events.size} events after #{attempt} attempts: " \
38
+ "#{e.class}: #{e.message}"
39
+ )
40
+ end
25
41
  end
26
42
 
27
- def decrement!(call_rows)
28
- totals = period_decrement_totals(call_rows)
29
- return if totals.empty?
43
+ def decrement!(records)
44
+ buckets = bucket_totals(records)
45
+ return if buckets.empty?
46
+
47
+ LlmCostTracker::CallRollup.decrement(buckets)
48
+ end
30
49
 
31
- apply_decrements(totals)
50
+ def rebuild!
51
+ accumulated = accumulate_priced_calls
52
+ LlmCostTracker::CallRollup.transaction do
53
+ LlmCostTracker::CallRollup.delete_all
54
+ rows_from_buckets(accumulated).each_slice(REBUILD_INSERT_SLICE) do |rows|
55
+ LlmCostTracker::CallRollup.increment_all(rows)
56
+ end
57
+ end
58
+ accumulated.size
32
59
  end
33
60
 
34
61
  private
35
62
 
36
- def period_rows(event)
37
- currency = currency_from_snapshot(event.pricing_snapshot)
38
- provider = event.provider.to_s
39
- Period::PERIODS.map do |period, name|
40
- {
41
- period: name,
42
- period_start: Period.bucket(period, event.tracked_at),
43
- currency: currency,
44
- provider: provider,
45
- total_cost: event.total_cost
46
- }
63
+ def accumulate_priced_calls
64
+ accumulated = Hash.new { |totals, key| totals[key] = BigDecimal("0") }
65
+ priced_calls.in_batches do |batch|
66
+ bucket_totals(batch).each { |key, total| accumulated[key] += total }
47
67
  end
68
+ accumulated
69
+ end
70
+
71
+ def priced_calls
72
+ LlmCostTracker::Call.where.not(total_cost: nil)
73
+ .select(:id, :total_cost, :pricing_snapshot, :provider, :tracked_at)
48
74
  end
49
75
 
50
76
  def period_rows_for_events(events)
51
- call_rollups(events).map do |(period, period_start, currency, provider), total_cost|
77
+ rows_from_buckets(bucket_totals(events))
78
+ end
79
+
80
+ def rows_from_buckets(buckets)
81
+ buckets.map do |(period, period_start, currency, provider), total_cost|
52
82
  {
53
83
  period: period,
54
84
  period_start: period_start,
@@ -59,84 +89,23 @@ module LlmCostTracker
59
89
  end
60
90
  end
61
91
 
62
- def call_rollups(events)
63
- events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
64
- currency = currency_from_snapshot(event.pricing_snapshot)
65
- provider = event.provider.to_s
66
- Period::PERIODS.each do |period, name|
67
- key = [name, Period.bucket(period, event.tracked_at), currency, provider]
68
- totals[key] += BigDecimal(event.total_cost.to_s)
69
- end
70
- end
71
- end
92
+ def bucket_totals(entries)
93
+ entries.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |entry, totals|
94
+ next unless entry.total_cost
72
95
 
73
- def period_decrement_totals(call_rows)
74
- call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
75
- _id, tracked_at, total_cost, pricing_snapshot, provider = row
76
- next unless total_cost
77
-
78
- currency = currency_from_snapshot(pricing_snapshot)
79
- provider_key = provider.to_s
80
- Period::PERIODS.each_key do |period|
81
- totals[[period, Period.bucket(period, tracked_at), currency, provider_key]] += total_cost
96
+ currency = currency_from_snapshot(entry.pricing_snapshot)
97
+ provider = entry.provider.to_s
98
+ Period::PERIODS.each do |period|
99
+ key = [period.to_s, Period.bucket(period, entry.tracked_at), currency, provider]
100
+ totals[key] += entry.total_cost.to_d
82
101
  end
83
102
  end
84
103
  end
85
104
 
86
- def apply_decrements(totals)
87
- now = Time.now.utc
88
- buckets_by_period = totals.each_with_object({}) do |(key, amount), grouped|
89
- period, period_start, currency, provider = key
90
- grouped[[period, currency, provider]] ||= {}
91
- grouped[[period, currency, provider]][period_start] = amount
92
- end
93
-
94
- conn = LlmCostTracker::CallRollup.connection
95
- table = LlmCostTracker::CallRollup.quoted_table_name
96
- period_col = conn.quote_column_name("period")
97
- start_col = conn.quote_column_name("period_start")
98
- currency_col = conn.quote_column_name("currency")
99
- provider_col = conn.quote_column_name("provider")
100
- total_col = conn.quote_column_name("total_cost")
101
- updated_col = conn.quote_column_name("updated_at")
102
-
103
- buckets_by_period.each do |(period, currency, provider), by_start|
104
- case_clauses = by_start.map do |period_start, amount|
105
- "WHEN #{start_col} = #{conn.quote(period_start)} THEN #{conn.quote(amount)}"
106
- end.join(" ")
107
- starts = by_start.keys.map { |period_start| conn.quote(period_start) }.join(", ")
108
-
109
- conn.execute(
110
- "UPDATE #{table} " \
111
- "SET #{total_col} = GREATEST(0, #{total_col} - CASE #{case_clauses} ELSE 0 END), " \
112
- "#{updated_col} = #{conn.quote(now)} " \
113
- "WHERE #{period_col} = #{conn.quote(Period::PERIODS.fetch(period))} " \
114
- "AND #{currency_col} = #{conn.quote(currency)} " \
115
- "AND #{provider_col} = #{conn.quote(provider)} " \
116
- "AND #{start_col} IN (#{starts})"
117
- )
118
- end
119
- end
120
-
121
105
  def currency_from_snapshot(snapshot)
122
- value = (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
106
+ value = (snapshot.is_a?(Hash) && snapshot["currency"]) || LlmCostTracker::DEFAULT_CURRENCY
123
107
  value.to_s.upcase
124
108
  end
125
-
126
- def upsert_call_rollups(rows)
127
- LlmCostTracker::CallRollup.upsert_all(
128
- rows,
129
- on_duplicate: Ledger::Rollups::UpsertSql.call,
130
- record_timestamps: true,
131
- unique_by: call_rollups_unique_by
132
- )
133
- end
134
-
135
- def call_rollups_unique_by
136
- return unless LlmCostTracker::CallRollup.connection.supports_insert_conflict_target?
137
-
138
- %i[period period_start currency provider]
139
- end
140
109
  end
141
110
  end
142
111
  end
@@ -32,22 +32,21 @@ module LlmCostTracker
32
32
  raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
33
33
  end
34
34
 
35
- def json_column_errors(column, adapter_value, column_name)
36
- return [] unless column
35
+ PG_PERIOD_FORMATS = { day: "YYYY-MM-DD", month: "YYYY-MM" }.freeze
36
+ MYSQL_PERIOD_FORMATS = { day: "%Y-%m-%d", month: "%Y-%m" }.freeze
37
+ private_constant :PG_PERIOD_FORMATS, :MYSQL_PERIOD_FORMATS
37
38
 
38
- expected_type = postgresql?(adapter_value) ? "jsonb" : "json"
39
- return [] if json_column_type?(column, adapter_value)
40
-
41
- ["#{column_name} column must use #{expected_type} (got #{column.sql_type})"]
42
- end
43
-
44
- def json_column_type?(column, adapter_value)
45
- sql_type = column.sql_type.to_s.downcase
46
- if postgresql?(adapter_value)
47
- column.type == :jsonb || sql_type == "jsonb"
39
+ def period_bucket_sql(connection, period, column)
40
+ period = period.to_sym
41
+ if postgresql?(connection)
42
+ "TO_CHAR(DATE_TRUNC('#{period}', #{column}), '#{PG_PERIOD_FORMATS.fetch(period)}')"
43
+ elsif mysql?(connection)
44
+ "DATE_FORMAT(#{column}, '#{MYSQL_PERIOD_FORMATS.fetch(period)}')"
48
45
  else
49
- column.type == :json || sql_type == "json" || sql_type == "longtext"
46
+ ensure_supported!(connection)
50
47
  end
48
+ rescue KeyError
49
+ raise ArgumentError, "invalid period: #{period.inspect}"
51
50
  end
52
51
 
53
52
  private
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ require_relative "adapter"
6
+
7
+ module LlmCostTracker
8
+ module Ledger
9
+ module Schema
10
+ module Base
11
+ def model
12
+ @model ||= LlmCostTracker.const_get(detect_model_name)
13
+ end
14
+
15
+ def columns(*names)
16
+ @required_columns = names.map(&:to_s).freeze
17
+ end
18
+
19
+ def required_columns
20
+ @required_columns || [].freeze
21
+ end
22
+
23
+ def current_schema_errors
24
+ connection = model.connection
25
+ Adapter.ensure_supported!(connection)
26
+ table_name = model.table_name
27
+ return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
28
+
29
+ columns_hash = model.columns_hash
30
+ cache = @schema_capabilities
31
+ return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns_hash)
32
+
33
+ errors = column_errors(columns_hash)
34
+ @schema_capabilities = { columns: columns_hash, errors: errors }
35
+ errors
36
+ end
37
+
38
+ private
39
+
40
+ def column_errors(columns_hash)
41
+ missing = required_columns - columns_hash.keys
42
+ missing.any? ? ["missing columns: #{missing.join(', ')}"] : []
43
+ end
44
+
45
+ def detect_model_name
46
+ name.delete_prefix("#{Schema.name}::").singularize
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,89 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "adapter"
3
+ require_relative "base"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Ledger
7
7
  module Schema
8
8
  module CallLineItems
9
- REQUIRED_COLUMNS = %w[
10
- llm_cost_tracker_call_id
11
- position
12
- kind
13
- direction
14
- modality
15
- cache_state
16
- quantity
17
- unit
18
- rate_amount
19
- rate_quantity
20
- cost
21
- currency
22
- cost_status
23
- pricing_basis
24
- price_key
25
- price_source
26
- price_source_version
27
- provider_field
28
- provider_item_id
29
- details
30
- created_at
31
- ].freeze
32
-
33
- REQUIRED_INDEX_COLUMNS = [
34
- %w[llm_cost_tracker_call_id position]
35
- ].freeze
36
-
37
- class << self
38
- def current_schema_errors
39
- connection = LlmCostTracker::Call.connection
40
- Adapter.ensure_supported!(connection)
41
- table_name = LlmCostTracker::CallLineItem.table_name
42
- return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
43
-
44
- columns = LlmCostTracker::CallLineItem.columns_hash
45
- cache = @schema_capabilities
46
- return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
47
-
48
- errors = compute_errors(connection, table_name, columns)
49
- @schema_capabilities = { columns: columns, errors: errors }
50
- errors
51
- end
52
-
53
- private
54
-
55
- def compute_errors(connection, table_name, columns)
56
- errors = []
57
- missing = REQUIRED_COLUMNS - columns.keys
58
- errors << "missing columns: #{missing.join(', ')}" if missing.any?
59
- errors.concat(Adapter.json_column_errors(columns["details"], connection, "details"))
60
- errors.concat(missing_index_errors(connection, table_name))
61
- errors << missing_fk_error(connection, table_name) if missing_fk?(connection, table_name)
62
- errors.compact
63
- end
64
-
65
- def missing_index_errors(connection, table_name)
66
- existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
67
- REQUIRED_INDEX_COLUMNS.filter_map do |required|
68
- next if existing.any? { |columns| columns == required }
69
-
70
- "missing index on (#{required.join(', ')})"
71
- end
72
- end
73
-
74
- def missing_fk?(connection, table_name)
75
- connection.foreign_keys(table_name).none? do |fk|
76
- fk.column.to_s == "llm_cost_tracker_call_id" &&
77
- fk.to_table.to_s == "llm_cost_tracker_calls"
78
- end
79
- rescue NotImplementedError, NoMethodError
80
- false
81
- end
82
-
83
- def missing_fk_error(_connection, _table_name)
84
- "missing foreign key on llm_cost_tracker_call_id referencing llm_cost_tracker_calls"
85
- end
86
- end
9
+ extend Base
10
+
11
+ columns :llm_cost_tracker_call_id,
12
+ :position,
13
+ :kind,
14
+ :direction,
15
+ :modality,
16
+ :cache_state,
17
+ :quantity,
18
+ :unit,
19
+ :rate_amount,
20
+ :rate_quantity,
21
+ :cost,
22
+ :currency,
23
+ :cost_status,
24
+ :pricing_basis,
25
+ :price_key,
26
+ :price_source,
27
+ :price_source_version,
28
+ :provider_field,
29
+ :provider_item_id,
30
+ :details,
31
+ :created_at
87
32
  end
88
33
  end
89
34
  end
@@ -1,46 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "adapter"
3
+ require_relative "base"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Ledger
7
7
  module Schema
8
8
  module CallRollups
9
- REQUIRED_COLUMNS = %w[period period_start currency provider total_cost created_at updated_at].freeze
10
- UNIQUE_COLUMNS = %i[period period_start currency provider].freeze
9
+ extend Base
11
10
 
12
- class << self
13
- def current_schema_errors
14
- connection = LlmCostTracker::CallRollup.connection
15
- Adapter.ensure_supported!(connection)
16
- table_name = LlmCostTracker::CallRollup.table_name
17
- return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
18
-
19
- columns = LlmCostTracker::CallRollup.columns_hash
20
- cache = @schema_capabilities
21
- return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
22
-
23
- errors = compute_errors(connection, table_name, columns)
24
- @schema_capabilities = { columns: columns, errors: errors }
25
- errors
26
- end
27
-
28
- private
29
-
30
- def compute_errors(connection, table_name, columns)
31
- errors = []
32
- missing = REQUIRED_COLUMNS - columns.keys
33
- errors << "missing columns: #{missing.join(', ')}" if missing.any?
34
- unless unique_period_index?(connection, table_name)
35
- errors << "missing unique index: period, period_start, currency, provider"
36
- end
37
- errors
38
- end
39
-
40
- def unique_period_index?(connection, table_name)
41
- connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
42
- end
43
- end
11
+ columns :period, :period_start, :currency, :provider, :total_cost, :created_at, :updated_at
44
12
  end
45
13
  end
46
14
  end
@@ -1,51 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "base"
4
+
3
5
  module LlmCostTracker
4
6
  module Ledger
5
7
  module Schema
6
8
  module CallTags
7
- REQUIRED_COLUMNS = %w[llm_cost_tracker_call_id key value].freeze
8
-
9
- REQUIRED_INDEX_COLUMNS = [
10
- %w[key value],
11
- %w[llm_cost_tracker_call_id]
12
- ].freeze
13
-
14
- class << self
15
- def current_schema_errors
16
- connection = LlmCostTracker::Call.connection
17
- Ledger::Schema::Adapter.ensure_supported!(connection)
18
- table_name = LlmCostTracker::CallTag.table_name
19
- return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
20
-
21
- columns = LlmCostTracker::CallTag.columns_hash
22
- cache = @schema_capabilities
23
- return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
24
-
25
- errors = compute_errors(connection, table_name, columns)
26
- @schema_capabilities = { columns: columns, errors: errors }
27
- errors
28
- end
29
-
30
- private
31
-
32
- def compute_errors(connection, table_name, columns)
33
- errors = []
34
- missing = REQUIRED_COLUMNS - columns.keys
35
- errors << "missing columns: #{missing.join(', ')}" if missing.any?
36
- errors.concat(missing_index_errors(connection, table_name))
37
- errors
38
- end
39
-
40
- def missing_index_errors(connection, table_name)
41
- existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
42
- REQUIRED_INDEX_COLUMNS.filter_map do |required|
43
- next if existing.any? { |columns| (required - columns).empty? }
9
+ extend Base
44
10
 
45
- "missing index on (#{required.join(', ')})"
46
- end
47
- end
48
- end
11
+ columns :llm_cost_tracker_call_id, :key, :value
49
12
  end
50
13
  end
51
14
  end
@@ -1,109 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "llm_cost_tracker/ledger/schema/adapter"
3
+ require_relative "base"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Ledger
7
7
  module Schema
8
8
  module Calls
9
- CURRENT_SCHEMA_COLUMNS = %w[
10
- event_id
11
- provider
12
- model
13
- input_tokens
14
- output_tokens
15
- total_tokens
16
- cache_read_input_tokens
17
- cache_write_input_tokens
18
- cache_write_extended_input_tokens
19
- audio_input_tokens
20
- audio_output_tokens
21
- image_input_tokens
22
- image_output_tokens
23
- hidden_output_tokens
24
- total_cost
25
- latency_ms
26
- stream
27
- usage_source
28
- provider_response_id
29
- provider_project_id
30
- provider_api_key_id
31
- provider_workspace_id
32
- batch
33
- pricing_mode
34
- cost_status
35
- pricing_snapshot
36
- tracked_at
37
- ].freeze
38
-
39
- REQUIRED_INDEXES = [
40
- { columns: :event_id, unique: true },
41
- { columns: :tracked_at },
42
- { columns: %i[provider tracked_at] },
43
- { columns: %i[model tracked_at] },
44
- { columns: :cost_status },
45
- { columns: :provider_response_id }
46
- ].freeze
47
- private_constant :REQUIRED_INDEXES
48
-
49
- class << self
50
- def current_schema_errors
51
- schema_capabilities.fetch(:current_schema_errors)
52
- end
53
-
54
- def missing_current_schema_columns
55
- schema_capabilities.fetch(:missing_current_schema_columns)
56
- end
57
-
58
- private
59
-
60
- def schema_capabilities
61
- columns = LlmCostTracker::Call.columns_hash
62
- adapter_name = LlmCostTracker::Call.connection.adapter_name
63
- cache = @schema_capabilities
64
-
65
- return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
66
- cache.fetch(:adapter_name) == adapter_name
67
-
68
- values = build_schema_capabilities(columns, adapter_name)
69
- @schema_capabilities = { columns: columns, adapter_name: adapter_name, values: values }
70
- values
71
- end
72
-
73
- def build_schema_capabilities(columns, adapter_name)
74
- Ledger::Schema::Adapter.ensure_supported!(adapter_name)
75
-
76
- {
77
- missing_current_schema_columns: missing_columns_for(columns),
78
- current_schema_errors: schema_errors_for(columns, adapter_name)
79
- }
80
- end
81
-
82
- def schema_errors_for(columns, adapter_name)
83
- errors = []
84
- missing = missing_columns_for(columns)
85
- errors << "missing columns: #{missing.join(', ')}" if missing.any?
86
- errors.concat(Adapter.json_column_errors(columns["pricing_snapshot"], adapter_name, "pricing_snapshot"))
87
- errors.concat(missing_index_errors)
88
- errors
89
- end
90
-
91
- def missing_index_errors
92
- connection = LlmCostTracker::Call.connection
93
- REQUIRED_INDEXES.filter_map do |spec|
94
- next if connection.index_exists?(LlmCostTracker::Call.table_name, spec[:columns], **spec.except(:columns))
95
-
96
- prefix = spec[:unique] ? "unique " : ""
97
- "missing #{prefix}index: #{Array(spec[:columns]).join(', ')}"
98
- end
99
- rescue StandardError
100
- []
101
- end
102
-
103
- def missing_columns_for(columns)
104
- CURRENT_SCHEMA_COLUMNS - columns.keys
105
- end
106
- end
9
+ extend Base
10
+
11
+ columns :event_id,
12
+ :provider,
13
+ :model,
14
+ :input_tokens,
15
+ :output_tokens,
16
+ :total_tokens,
17
+ :cache_read_input_tokens,
18
+ :cache_write_input_tokens,
19
+ :cache_write_extended_input_tokens,
20
+ :audio_input_tokens,
21
+ :audio_output_tokens,
22
+ :image_input_tokens,
23
+ :image_output_tokens,
24
+ :hidden_output_tokens,
25
+ :total_cost,
26
+ :latency_ms,
27
+ :stream,
28
+ :usage_source,
29
+ :provider_response_id,
30
+ :provider_project_id,
31
+ :provider_api_key_id,
32
+ :provider_workspace_id,
33
+ :batch,
34
+ :pricing_mode,
35
+ :cost_status,
36
+ :pricing_snapshot,
37
+ :tracked_at
107
38
  end
108
39
  end
109
40
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../base"
4
+
5
+ module LlmCostTracker
6
+ module Ledger
7
+ module Schema
8
+ module Ingestion
9
+ module InboxEntries
10
+ extend Base
11
+
12
+ columns :event_id,
13
+ :total_cost,
14
+ :tracked_at,
15
+ :payload,
16
+ :locked_at,
17
+ :locked_by,
18
+ :attempts,
19
+ :last_error,
20
+ :created_at,
21
+ :updated_at
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end