llm_cost_tracker 0.11.0 → 0.13.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -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/data_quality_controller.rb +1 -0
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +12 -19
  10. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  11. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  12. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  13. data/app/models/llm_cost_tracker/call.rb +28 -63
  14. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  15. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  16. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  17. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +3 -0
  18. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +76 -43
  19. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  20. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  21. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  22. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  23. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  24. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  26. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  27. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  28. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  29. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  30. data/app/views/llm_cost_tracker/calls/show.html.erb +33 -25
  31. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  32. data/app/views/llm_cost_tracker/data_quality/index.html.erb +73 -117
  33. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  34. data/app/views/llm_cost_tracker/pricing/index.html.erb +3 -3
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  38. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  39. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  40. data/config/routes.rb +2 -3
  41. data/lib/llm_cost_tracker/budget.rb +24 -26
  42. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  43. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +32 -39
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  46. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  47. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  48. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  49. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +41 -44
  50. data/lib/llm_cost_tracker/check.rb +5 -0
  51. data/lib/llm_cost_tracker/configuration.rb +13 -44
  52. data/lib/llm_cost_tracker/currency.rb +5 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  54. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  55. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  56. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  57. data/lib/llm_cost_tracker/doctor.rb +5 -69
  58. data/lib/llm_cost_tracker/engine.rb +4 -4
  59. data/lib/llm_cost_tracker/event.rb +12 -18
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +4 -3
  64. data/lib/llm_cost_tracker/ingestion/batch.rb +61 -12
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  66. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  68. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +93 -106
  70. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  71. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  72. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  73. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  74. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  75. data/lib/llm_cost_tracker/integrations.rb +32 -25
  76. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  77. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  80. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  82. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  83. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  84. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  86. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  87. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  88. data/lib/llm_cost_tracker/ledger/store.rb +24 -41
  89. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  90. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  91. data/lib/llm_cost_tracker/ledger.rb +8 -18
  92. data/lib/llm_cost_tracker/logging.rb +4 -21
  93. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  94. data/lib/llm_cost_tracker/parsers.rb +139 -26
  95. data/lib/llm_cost_tracker/prices.json +1821 -13
  96. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  97. data/lib/llm_cost_tracker/pricing/calculation.rb +279 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  99. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  100. data/lib/llm_cost_tracker/pricing/matcher.rb +98 -0
  101. data/lib/llm_cost_tracker/pricing/mode.rb +44 -56
  102. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  103. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  104. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  105. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  106. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  107. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  108. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  109. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  110. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  111. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  112. data/lib/llm_cost_tracker/pricing.rb +10 -278
  113. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  117. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  118. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  119. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  120. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  121. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  122. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  123. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  124. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  125. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  126. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  127. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  128. data/lib/llm_cost_tracker/providers.rb +35 -0
  129. data/lib/llm_cost_tracker/railtie.rb +0 -3
  130. data/lib/llm_cost_tracker/report/data.rb +3 -4
  131. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  132. data/lib/llm_cost_tracker/report.rb +1 -1
  133. data/lib/llm_cost_tracker/retention.rb +6 -19
  134. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  135. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  136. data/lib/llm_cost_tracker/timing.rb +2 -4
  137. data/lib/llm_cost_tracker/tracker.rb +23 -36
  138. data/lib/llm_cost_tracker/usage/catalog.rb +69 -0
  139. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  140. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +6 -52
  141. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  142. data/lib/llm_cost_tracker/usage/token_usage.rb +45 -0
  143. data/lib/llm_cost_tracker/version.rb +1 -1
  144. data/lib/llm_cost_tracker.rb +43 -52
  145. data/lib/tasks/llm_cost_tracker.rake +14 -73
  146. metadata +81 -55
  147. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  148. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  149. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  151. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  152. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  153. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  154. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  155. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  156. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  157. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  158. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  159. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  160. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  161. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  168. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  170. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  172. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  173. data/lib/llm_cost_tracker/masking.rb +0 -39
  174. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  175. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  176. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  177. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  178. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  179. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  180. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  181. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  182. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  183. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  184. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  185. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  186. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  187. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  188. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  189. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  190. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  191. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  192. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  193. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  194. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  195. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  196. 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
@@ -13,7 +13,7 @@ LlmCostTracker.configure do |config|
13
13
  # Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
14
14
  # config.max_tag_count = 50
15
15
  # config.max_tag_value_bytesize = 1024
16
- # config.redacted_tag_keys = %w[api_key access_token authorization credential password refresh_token secret]
16
+ # config.redacted_tag_keys = <%= LlmCostTracker::Configuration::DEFAULT_REDACTED_TAG_KEYS.inspect %>
17
17
 
18
18
  # Optional SDK integrations. Provider SDK gems are not installed by LLM Cost Tracker.
19
19
  # Enabled integrations are checked at boot, so enable only clients your app loads.
@@ -57,8 +57,9 @@ LlmCostTracker.configure do |config|
57
57
  # thread. Set to :async for a write-ahead inbox + background worker that batches
58
58
  # inserts and survives caller transaction rollbacks. Requires the optional
59
59
  # inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
60
- # The worker uses a dedicated ActiveRecord pool (defaults to 2 connections) so it
61
- # doesn't compete with request threads bump ingestion_pool_size if your Puma
60
+ # Synchronous inbox writes use a dedicated ActiveRecord pool (defaults to 2 connections)
61
+ # so they don't compete with request threads for the default pool when a tracked call
62
+ # happens inside an open caller transaction. Bump ingestion_pool_size if your Puma
62
63
  # worker count outgrows that.
63
64
  # config.ingestion = :async
64
65
  # config.ingestion_pool_size = 5
@@ -8,6 +8,12 @@ module LlmCostTracker
8
8
  class Batch
9
9
  BATCH_SIZE = 100
10
10
  LOCK_TIMEOUT_SECONDS = 30
11
+ TRANSIENT_PERSIST_ERRORS = [
12
+ ActiveRecord::Deadlocked,
13
+ ActiveRecord::LockWaitTimeout,
14
+ ActiveRecord::StatementTimeout,
15
+ ActiveRecord::ConnectionNotEstablished
16
+ ].freeze
11
17
 
12
18
  def initialize(identity:)
13
19
  @identity = identity
@@ -22,31 +28,61 @@ module LlmCostTracker
22
28
  rows.size
23
29
  rescue StandardError => e
24
30
  rows_to_mark = valid_rows&.any? ? valid_rows : rows
25
- mark_failed(rows_to_mark, e) if rows_to_mark&.any?
31
+ if rows_to_mark&.any?
32
+ transient = valid_rows&.any? && TRANSIENT_PERSIST_ERRORS.any? { |klass| e.is_a?(klass) }
33
+ mark_failed_with_message(rows_to_mark, error_message_for(e), decrement_attempts: transient)
34
+ end
26
35
  raise
27
36
  end
28
37
 
29
38
  def pending?
30
- Ingestion::InboxEntry.where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE).exists?
39
+ Ingestion::InboxEntry.pending.exists?
31
40
  end
32
41
 
33
42
  def claimable?
34
43
  claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
35
44
  end
36
45
 
37
- def mark_failed(rows, error)
38
- message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
46
+ def mark_failed_with_message(rows, message, decrement_attempts: false)
39
47
  now = Time.now.utc
40
- Ingestion::InboxEntry
41
- .where(id: rows.map(&:id), locked_by: identity)
42
- .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
48
+ scope = Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity)
49
+ if decrement_attempts
50
+ scope.update_all(
51
+ Ingestion::InboxEntry.sanitize_sql_array(
52
+ ["last_error = ?, locked_at = ?, locked_by = NULL, " \
53
+ "attempts = GREATEST(attempts - 1, 0), updated_at = ?", message, now, now]
54
+ )
55
+ )
56
+ else
57
+ scope.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
58
+ warn_on_quarantine(rows)
59
+ end
43
60
  rescue StandardError => e
44
61
  LlmCostTracker::Logging.warn(
45
- "Inbox mark_failed failed for #{rows.size} rows: #{e.class}: #{e.message} (original error: #{error.class})"
62
+ "Inbox mark_failed_with_message failed for #{rows.size} rows: #{e.class}: #{e.message} " \
63
+ "(attempted message: #{message.to_s.byteslice(0, 200)})"
46
64
  )
47
65
  nil
48
66
  end
49
67
 
68
+ def error_message_for(error)
69
+ "#{error.class}: #{error.message}".byteslice(0, 1_000)
70
+ end
71
+
72
+ def warn_on_quarantine(rows)
73
+ threshold = Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE
74
+ quarantined = rows.select { |row| row.attempts.to_i + 1 >= threshold }
75
+ return if quarantined.empty?
76
+
77
+ sample = quarantined.first(10).map(&:id).join(", ")
78
+ sample += "..." if quarantined.size > 10
79
+ LlmCostTracker::Logging.warn(
80
+ "Ingestion::Batch: #{quarantined.size} inbox row(s) reached " \
81
+ "MAX_ATTEMPTS_BEFORE_QUARANTINE=#{threshold} and will be skipped " \
82
+ "on the next claim cycle (ids: #{sample})"
83
+ )
84
+ end
85
+
50
86
  private
51
87
 
52
88
  attr_reader :identity
@@ -69,25 +105,38 @@ module LlmCostTracker
69
105
  def decode(rows)
70
106
  valid_rows = []
71
107
  events = []
108
+ failures = Hash.new { |h, k| h[k] = [] }
72
109
  rows.each do |row|
73
110
  events << Ingestion::Inbox.event_from_row(row)
74
111
  valid_rows << row
75
112
  rescue StandardError => e
76
- mark_failed([row], e)
113
+ failures[error_message_for(e)] << row
77
114
  end
115
+ failures.each { |message, failed_rows| mark_failed_with_message(failed_rows, message) }
78
116
  [valid_rows, events]
79
117
  end
80
118
 
81
- def persist(rows, events)
119
+ def persist(rows, events, retry_on_conflict: true)
82
120
  LlmCostTracker::Call.transaction do
83
- Ledger::Store.insert(events)
121
+ Ledger::Store.persist_records(events)
84
122
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
85
123
  end
124
+ Ledger::Rollups.increment_safely!(events) if Ingestion.cache_rollups?
125
+ rescue ActiveRecord::RecordNotUnique
126
+ raise unless retry_on_conflict
127
+
128
+ already_persisted = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id)
129
+ fresh_events = events.reject { |event| already_persisted.include?(event.event_id) }
130
+ LlmCostTracker::Logging.warn(
131
+ "Ingestion::Batch#persist: #{already_persisted.size} event_id(s) already in ledger; " \
132
+ "skipped duplicates and persisted #{fresh_events.size} fresh event(s)"
133
+ )
134
+ persist(rows, fresh_events, retry_on_conflict: false)
86
135
  end
87
136
 
88
137
  def claimable_scope(cutoff)
89
138
  Ingestion::InboxEntry
90
- .where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE)
139
+ .pending
91
140
  .where("locked_at IS NULL OR locked_at < ?", cutoff)
92
141
  end
93
142
  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"]