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
@@ -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
@@ -22,31 +22,50 @@ module LlmCostTracker
22
22
  rows.size
23
23
  rescue StandardError => e
24
24
  rows_to_mark = valid_rows&.any? ? valid_rows : rows
25
- mark_failed(rows_to_mark, e) if rows_to_mark&.any?
25
+ mark_failed_with_message(rows_to_mark, error_message_for(e)) if rows_to_mark&.any?
26
26
  raise
27
27
  end
28
28
 
29
29
  def pending?
30
- Ingestion::InboxEntry.where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE).exists?
30
+ Ingestion::InboxEntry.pending.exists?
31
31
  end
32
32
 
33
33
  def claimable?
34
34
  claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
35
35
  end
36
36
 
37
- def mark_failed(rows, error)
38
- message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
37
+ def mark_failed_with_message(rows, message)
39
38
  now = Time.now.utc
40
39
  Ingestion::InboxEntry
41
40
  .where(id: rows.map(&:id), locked_by: identity)
42
41
  .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
42
+ warn_on_quarantine(rows)
43
43
  rescue StandardError => e
44
44
  LlmCostTracker::Logging.warn(
45
- "Inbox mark_failed failed for #{rows.size} rows: #{e.class}: #{e.message} (original error: #{error.class})"
45
+ "Inbox mark_failed_with_message failed for #{rows.size} rows: #{e.class}: #{e.message} " \
46
+ "(attempted message: #{message.to_s.byteslice(0, 200)})"
46
47
  )
47
48
  nil
48
49
  end
49
50
 
51
+ def error_message_for(error)
52
+ "#{error.class}: #{error.message}".byteslice(0, 1_000)
53
+ end
54
+
55
+ def warn_on_quarantine(rows)
56
+ threshold = Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE
57
+ quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
58
+ return if quarantined.empty?
59
+
60
+ sample = quarantined.first(10).map(&:id).join(", ")
61
+ sample += "..." if quarantined.size > 10
62
+ LlmCostTracker::Logging.warn(
63
+ "Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
64
+ "MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
65
+ "on the next claim cycle (ids: #{sample})"
66
+ )
67
+ end
68
+
50
69
  private
51
70
 
52
71
  attr_reader :identity
@@ -69,25 +88,37 @@ module LlmCostTracker
69
88
  def decode(rows)
70
89
  valid_rows = []
71
90
  events = []
91
+ failures = Hash.new { |h, k| h[k] = [] }
72
92
  rows.each do |row|
73
93
  events << Ingestion::Inbox.event_from_row(row)
74
94
  valid_rows << row
75
95
  rescue StandardError => e
76
- mark_failed([row], e)
96
+ failures[error_message_for(e)] << row
77
97
  end
98
+ failures.each { |message, failed_rows| mark_failed_with_message(failed_rows, message) }
78
99
  [valid_rows, events]
79
100
  end
80
101
 
81
- def persist(rows, events)
102
+ def persist(rows, events, retry_on_conflict: true)
82
103
  LlmCostTracker::Call.transaction do
83
104
  Ledger::Store.insert(events)
84
105
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
85
106
  end
107
+ rescue ActiveRecord::RecordNotUnique
108
+ raise unless retry_on_conflict
109
+
110
+ already_persisted = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id)
111
+ fresh_events = events.reject { |event| already_persisted.include?(event.event_id) }
112
+ LlmCostTracker::Logging.warn(
113
+ "Ingestion::Batch#persist: #{already_persisted.size} event_id(s) already in ledger; " \
114
+ "skipped duplicates and persisted #{fresh_events.size} fresh event(s)"
115
+ )
116
+ persist(rows, fresh_events, retry_on_conflict: false)
86
117
  end
87
118
 
88
119
  def claimable_scope(cutoff)
89
120
  Ingestion::InboxEntry
90
- .where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE)
121
+ .pending
91
122
  .where("locked_at IS NULL OR locked_at < ?", cutoff)
92
123
  end
93
124
  end
@@ -2,14 +2,14 @@
2
2
 
3
3
  require "json"
4
4
  require "time"
5
+ require "active_support/core_ext/hash/keys"
5
6
 
6
7
  require_relative "../event"
7
8
  require_relative "../pricing"
8
- require_relative "../billing/line_item"
9
9
 
10
10
  module LlmCostTracker
11
11
  module Ingestion
12
- class Inbox
12
+ module Inbox
13
13
  PAYLOAD_SCHEMA_VERSION = 2
14
14
 
15
15
  class << self
@@ -30,8 +30,8 @@ module LlmCostTracker
30
30
  private
31
31
 
32
32
  def event_attributes_from(payload)
33
- cost = payload[:cost] && Pricing.stored_cost_attributes(payload[:cost])
34
- token_usage = TokenUsage.build(**payload.fetch(:token_usage).slice(*TokenUsage.members))
33
+ cost = payload[:cost] && Charges::Cost.from_h(payload[:cost])
34
+ token_usage = Usage::TokenUsage.build(**payload.fetch(:token_usage).slice(*Usage::TokenUsage.members))
35
35
 
36
36
  {
37
37
  event_id: payload.fetch(:event_id),
@@ -43,16 +43,15 @@ module LlmCostTracker
43
43
  tags: payload.fetch(:tags),
44
44
  latency_ms: payload[:latency_ms],
45
45
  stream: payload.fetch(:stream),
46
- usage_source: payload[:usage_source]&.to_sym,
46
+ usage_source: payload[:usage_source],
47
47
  provider_response_id: payload[:provider_response_id],
48
48
  provider_project_id: payload[:provider_project_id],
49
49
  provider_api_key_id: payload[:provider_api_key_id],
50
50
  provider_workspace_id: payload[:provider_workspace_id],
51
- batch: payload.fetch(:batch),
52
51
  tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
53
52
  cost_status: payload.fetch(:cost_status),
54
- pricing_snapshot: payload[:pricing_snapshot],
55
- line_items: (payload[:line_items] || []).map { |attrs| Billing::LineItem.build(attrs) }
53
+ pricing_snapshot: payload[:pricing_snapshot]&.deep_stringify_keys,
54
+ line_items: (payload[:line_items] || []).map { |attrs| Charges::LineItem.build(attrs) }
56
55
  }
57
56
  end
58
57
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/module/delegation"
4
+
3
5
  module LlmCostTracker
4
6
  module Ingestion
5
7
  module Pool
@@ -7,22 +9,12 @@ module LlmCostTracker
7
9
  MUTEX = Mutex.new
8
10
 
9
11
  class << self
10
- def with_connection(&)
11
- pool.with_connection(&)
12
- end
12
+ delegate :with_connection, to: :pool
13
13
 
14
14
  def pool
15
15
  @pool || MUTEX.synchronize { @pool ||= connect! }
16
16
  end
17
17
 
18
- def reset!
19
- MUTEX.synchronize do
20
- @pool&.disconnect!
21
- @pool = nil
22
- @handler = nil
23
- end
24
- end
25
-
26
18
  private
27
19
 
28
20
  def connect!
@@ -6,11 +6,10 @@ require "securerandom"
6
6
  require_relative "inbox"
7
7
  require_relative "batch"
8
8
  require_relative "lease_claim"
9
- require_relative "../logging"
10
9
 
11
10
  module LlmCostTracker
12
11
  module Ingestion
13
- class Worker
12
+ module Worker
14
13
  INTERVAL_SECONDS = 0.25
15
14
  IDLE_INTERVAL_SECONDS = 1.0
16
15
  MAX_IDLE_INTERVAL_SECONDS = 5.0
@@ -66,8 +65,12 @@ module LlmCostTracker
66
65
  @generation = @generation.to_i + 1
67
66
  @thread
68
67
  end
69
- wake_thread(thread)
70
- thread&.join(timeout)
68
+ begin
69
+ wake_thread(thread)
70
+ thread&.join(timeout)
71
+ rescue StandardError => e
72
+ handle_error(e)
73
+ end
71
74
  drain ? flush!(timeout: timeout, require_lease: true) : true
72
75
  rescue StandardError => e
73
76
  handle_error(e)
@@ -78,19 +81,6 @@ module LlmCostTracker
78
81
  end
79
82
  end
80
83
 
81
- def reset!
82
- thread = MUTEX.synchronize do
83
- @stop_requested = false
84
- @generation = @generation.to_i + 1
85
- thread = @thread
86
- @thread = nil
87
- @pid = nil
88
- @identity = nil
89
- thread
90
- end
91
- wake_thread(thread)
92
- end
93
-
94
84
  def flush_timeout_seconds(timeout)
95
85
  numeric = Float(timeout, exception: false)
96
86
  return FLUSH_TIMEOUT_SECONDS unless numeric&.finite? && numeric.positive?
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/notifications"
3
4
  require "securerandom"
4
5
 
5
6
  require_relative "errors"
7
+ require_relative "check"
6
8
  require_relative "ledger"
7
- require_relative "ingestion/lease_claim"
8
- require_relative "ingestion/pool"
9
- require_relative "ingestion/inbox"
10
- require_relative "ingestion/batch"
11
- require_relative "ingestion/worker"
12
9
 
13
10
  module LlmCostTracker
14
11
  module Ingestion
12
+ autoload :LeaseClaim, "llm_cost_tracker/ingestion/lease_claim"
13
+ autoload :Pool, "llm_cost_tracker/ingestion/pool"
14
+ autoload :Inbox, "llm_cost_tracker/ingestion/inbox"
15
+ autoload :Batch, "llm_cost_tracker/ingestion/batch"
16
+ autoload :Worker, "llm_cost_tracker/ingestion/worker"
17
+
15
18
  VERIFY_TAG = "llm_cost_tracker_verify"
16
19
 
17
20
  class << self
@@ -19,25 +22,12 @@ module LlmCostTracker
19
22
  "llm_cost_tracker_ingestion_"
20
23
  end
21
24
 
22
- CORE_SCHEMA_GUARDS = [
23
- ["llm_cost_tracker_calls", Ledger::Schema::Calls],
24
- ["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
25
- ["llm_cost_tracker_call_tags", Ledger::Schema::CallTags]
26
- ].freeze
27
-
28
- ROLLUPS_SCHEMA_GUARD = ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups].freeze
29
-
30
- ASYNC_SCHEMA_GUARDS = [
31
- ["llm_cost_tracker_ingestion_inbox_entries", Ledger::Schema::IngestionInboxEntries],
32
- ["llm_cost_tracker_ingestion_leases", Ledger::Schema::IngestionLeases]
33
- ].freeze
34
-
35
25
  def ensure_current_schema!
36
26
  unless LlmCostTracker::Call.table_exists?
37
27
  raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
38
28
  end
39
29
 
40
- guards_for_current_config.each do |table_name, schema_module|
30
+ guards_for_current_config.each do |schema_module, table_name|
41
31
  errors = schema_module.current_schema_errors
42
32
  next if errors.empty?
43
33
 
@@ -55,16 +45,16 @@ module LlmCostTracker
55
45
  end
56
46
 
57
47
  def guards_for_current_config
58
- guards = CORE_SCHEMA_GUARDS.dup
59
- guards << ROLLUPS_SCHEMA_GUARD if cache_rollups?
60
- guards += ASYNC_SCHEMA_GUARDS if async?
48
+ guards = Ledger::Schema::CORE_SCHEMAS.dup
49
+ guards << Ledger::Schema::CACHE_ROLLUPS_SCHEMA if cache_rollups?
50
+ guards += Ledger::Schema::ASYNC_SCHEMAS if async?
61
51
  guards
62
52
  end
63
53
 
64
54
  def verify
65
55
  unless LlmCostTracker::Call.table_exists?
66
56
  return [
67
- LlmCostTracker::Doctor::Check.new(
57
+ LlmCostTracker::Check.new(
68
58
  :error,
69
59
  "active_record",
70
60
  "llm_cost_tracker_calls table is missing; run install generator and migrate"
@@ -74,7 +64,7 @@ module LlmCostTracker
74
64
 
75
65
  [capture_check]
76
66
  rescue StandardError => e
77
- [LlmCostTracker::Doctor::Check.new(:error, "active_record", "#{e.class}: #{e.message}")]
67
+ [LlmCostTracker::Check.new(:error, "active_record", "#{e.class}: #{e.message}")]
78
68
  end
79
69
 
80
70
  private
@@ -88,7 +78,7 @@ module LlmCostTracker
88
78
  event = LlmCostTracker.track(
89
79
  provider: provider,
90
80
  model: model,
91
- tokens: { input: 1, output: 1 },
81
+ tokens: { input_tokens: 1, output_tokens: 1 },
92
82
  provider_response_id: response_id,
93
83
  tags: { feature: VERIFY_TAG }
94
84
  )
@@ -97,17 +87,17 @@ module LlmCostTracker
97
87
 
98
88
  return capture_success if persisted && notifications.any?
99
89
 
100
- LlmCostTracker::Doctor::Check.new(
90
+ LlmCostTracker::Check.new(
101
91
  :error,
102
92
  "active_record capture",
103
93
  capture_failure_message(persisted, notifications)
104
94
  )
105
95
  rescue LlmCostTracker::BudgetExceededError => e
106
- LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
96
+ LlmCostTracker::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
107
97
  rescue LlmCostTracker::Error => e
108
- LlmCostTracker::Doctor::Check.new(:error, "active_record capture", e.message)
98
+ LlmCostTracker::Check.new(:error, "active_record capture", e.message)
109
99
  rescue StandardError => e
110
- LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
100
+ LlmCostTracker::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
111
101
  ensure
112
102
  cleanup_verification_call(response_id) if response_id
113
103
  cleanup_verification_inbox(event: event, response_id: response_id)
@@ -122,7 +112,7 @@ module LlmCostTracker
122
112
 
123
113
  def capture_success
124
114
  path = async? ? "async inbox" : "inline writer"
125
- LlmCostTracker::Doctor::Check.new(
115
+ LlmCostTracker::Check.new(
126
116
  :ok,
127
117
  "active_record capture",
128
118
  "manual event emitted and persisted through #{path}"
@@ -138,13 +128,11 @@ module LlmCostTracker
138
128
 
139
129
  def cleanup_verification_call(response_id)
140
130
  relation = LlmCostTracker::Call.where(provider_response_id: response_id)
141
- rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
142
- return if rows.empty?
131
+ records = relation.select(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider).to_a
132
+ return if records.empty?
143
133
 
144
134
  relation.delete_all
145
- return unless cache_rollups?
146
-
147
- LlmCostTracker::Ledger::Rollups.decrement!(rows)
135
+ LlmCostTracker::Ledger::Rollups.decrement!(records) if cache_rollups?
148
136
  end
149
137
 
150
138
  def cleanup_verification_inbox(event:, response_id:)
@@ -162,7 +150,7 @@ module LlmCostTracker
162
150
 
163
151
  def sample_priced_identity
164
152
  key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
165
- model_id.include?("/") && prices[:input] && prices[:output]
153
+ model_id.include?("/") && prices["input"] && prices["output"]
166
154
  end&.first
167
155
  provider, model = key.to_s.split("/", 2)
168
156
  [provider || "openai", model || "gpt-4o-mini"]