llm_cost_tracker 0.9.0 → 0.10.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/README.md +2 -1
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
  6. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  7. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  8. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  9. data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
  10. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  11. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  12. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  13. data/lib/llm_cost_tracker/budget.rb +28 -6
  14. data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
  15. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
  16. data/lib/llm_cost_tracker/configuration.rb +31 -28
  17. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  18. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  19. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  20. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  21. data/lib/llm_cost_tracker/doctor.rb +6 -17
  22. data/lib/llm_cost_tracker/engine.rb +1 -2
  23. data/lib/llm_cost_tracker/errors.rb +3 -2
  24. data/lib/llm_cost_tracker/event.rb +47 -0
  25. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  26. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  27. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  28. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  29. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  37. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  38. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
  39. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  40. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  41. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
  43. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  44. data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
  45. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
  46. data/lib/llm_cost_tracker/integrations.rb +14 -13
  47. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  48. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  49. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  50. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  51. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  52. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  53. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  54. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  55. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  56. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  57. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  58. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  59. data/lib/llm_cost_tracker/logging.rb +0 -4
  60. data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
  61. data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
  62. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  63. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  64. data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
  65. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  66. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
  67. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
  68. data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
  69. data/lib/llm_cost_tracker/parsers.rb +31 -4
  70. data/lib/llm_cost_tracker/prices.json +567 -579
  71. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  72. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  73. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  74. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  75. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  76. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  77. data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
  78. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  79. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  80. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  81. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  82. data/lib/llm_cost_tracker/pricing.rb +72 -27
  83. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  84. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  85. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  86. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  87. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  88. data/lib/llm_cost_tracker/railtie.rb +3 -1
  89. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  90. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  91. data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
  92. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
  93. data/lib/llm_cost_tracker/report.rb +0 -4
  94. data/lib/llm_cost_tracker/retention.rb +20 -8
  95. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  96. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  97. data/lib/llm_cost_tracker/tracker.rb +33 -74
  98. data/lib/llm_cost_tracker/version.rb +1 -1
  99. data/lib/llm_cost_tracker.rb +11 -15
  100. data/lib/tasks/llm_cost_tracker.rake +16 -2
  101. metadata +18 -7
  102. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  103. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  104. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,14 +1,14 @@
1
- class CreateLlmCostTrackerDurableIngestion < ActiveRecord::Migration<%= migration_version %>
1
+ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
4
4
  t.string :event_id, null: false
5
5
  t.decimal :total_cost, precision: 20, scale: 8
6
6
  t.datetime :tracked_at, null: false
7
- t.text :payload, null: false
7
+ t.text :payload, null: false, limit: 16.megabytes
8
8
  t.datetime :locked_at
9
9
  t.string :locked_by
10
10
  t.integer :attempts, null: false, default: 0
11
- t.text :last_error
11
+ t.text :last_error, limit: 16.megabytes
12
12
 
13
13
  t.timestamps
14
14
  end
@@ -23,6 +23,7 @@ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_
23
23
 
24
24
  create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
25
25
  t.string :source, null: false
26
+ t.string :provider, null: false, default: ""
26
27
  t.string :cursor
27
28
  t.date :window_start
28
29
  t.date :window_end
@@ -39,7 +40,11 @@ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_
39
40
  if_not_exists: true
40
41
  add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
41
42
  if_not_exists: true
42
- add_index :llm_cost_tracker_provider_invoice_imports, %i[source started_at],
43
+ if postgresql?
44
+ add_index :llm_cost_tracker_provider_invoices, :metadata, using: :gin,
45
+ if_not_exists: true
46
+ end
47
+ add_index :llm_cost_tracker_provider_invoice_imports, %i[source provider started_at],
43
48
  if_not_exists: true
44
49
  end
45
50
 
@@ -22,7 +22,9 @@ LlmCostTracker.configure do |config|
22
22
  # config.instrument :ruby_llm
23
23
 
24
24
  # Pricing — local file refreshed via bin/rails llm_cost_tracker:prices:refresh
25
- # plus inline overrides. Prices are USD per 1M tokens.
25
+ # plus inline overrides. Rates are per 1M tokens; the snapshot's currency
26
+ # is read from your prices_file's `metadata.currency` (USD in the bundled
27
+ # snapshot — set a different code per file if you maintain non-USD prices).
26
28
  <% if options[:prices] -%>
27
29
  config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
28
30
  <% else -%>
@@ -37,8 +39,9 @@ LlmCostTracker.configure do |config|
37
39
 
38
40
  # Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
39
41
  # plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
40
- # raises after recording; :block_requests preflights supported requests) and
41
- # an optional callback. Cap evaluation reads from llm_cost_tracker_calls live;
42
+ # raises after recording; :block_requests preflights supported requests, also
43
+ # estimating the current call's input cost via chars/4 so it can block before
44
+ # send) and an optional callback. Cap evaluation reads from llm_cost_tracker_calls live;
42
45
  # flip cache_rollups to true at high volume so reads hit the rollups table
43
46
  # instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
44
47
  # config.monthly_budget = 100.00
@@ -50,11 +53,15 @@ LlmCostTracker.configure do |config|
50
53
  # }
51
54
  # config.cache_rollups = true
52
55
 
53
- # Ingestion path — false (default) writes events synchronously from the request
54
- # thread. Flip to true for a write-ahead inbox + background worker that batches
56
+ # Ingestion path — :inline (default) writes events synchronously from the request
57
+ # thread. Set to :async for a write-ahead inbox + background worker that batches
55
58
  # inserts and survives caller transaction rollbacks. Requires the optional
56
- # inbox/leases tables created by `bin/rails generate llm_cost_tracker:durable_ingestion`.
57
- # config.durable_ingestion = true
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
62
+ # worker count outgrows that.
63
+ # config.ingestion = :async
64
+ # config.ingestion_pool_size = 5
58
65
 
59
66
  # Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
60
67
  # for bin/rails llm_cost_tracker:report.
@@ -1,20 +1,35 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
1
3
  class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
2
6
  TABLE = :llm_cost_tracker_call_rollups
3
7
  OLD_INDEX = %i[period period_start currency].freeze
4
8
  NEW_INDEX = %i[period period_start currency provider].freeze
5
9
 
6
10
  def up
7
- unless column_exists?(TABLE, :provider)
8
- execute "DELETE FROM #{TABLE}"
9
- add_column TABLE, :provider, :string, null: false, default: ""
10
- end
11
- remove_index TABLE, column: OLD_INDEX, unique: true if index_exists?(TABLE, OLD_INDEX, unique: true)
12
- add_index TABLE, NEW_INDEX, unique: true unless index_exists?(TABLE, NEW_INDEX, unique: true)
11
+ add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
12
+ add_unique_index NEW_INDEX
13
+ remove_index TABLE, column: OLD_INDEX, unique: true, if_exists: true
13
14
  end
14
15
 
15
16
  def down
16
- remove_index TABLE, column: NEW_INDEX, unique: true if index_exists?(TABLE, NEW_INDEX, unique: true)
17
- add_index TABLE, OLD_INDEX, unique: true unless index_exists?(TABLE, OLD_INDEX, unique: true)
17
+ add_unique_index OLD_INDEX
18
+ remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
18
19
  remove_column TABLE, :provider if column_exists?(TABLE, :provider)
19
20
  end
21
+
22
+ private
23
+
24
+ def add_unique_index(columns)
25
+ if postgresql?
26
+ add_index TABLE, columns, unique: true, algorithm: :concurrently, if_not_exists: true
27
+ else
28
+ add_index TABLE, columns, unique: true, if_not_exists: true
29
+ end
30
+ end
31
+
32
+ def postgresql?
33
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
34
+ end
20
35
  end
@@ -1,23 +1,23 @@
1
1
  require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
3
  class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
4
6
  TABLE = :llm_cost_tracker_call_tags
5
7
  INDEX_COLUMNS = %i[key value].freeze
6
8
 
7
9
  def up
8
- return if index_exists?(TABLE, INDEX_COLUMNS)
9
-
10
10
  if postgresql?
11
- add_index TABLE, INDEX_COLUMNS
11
+ add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
12
12
  elsif mysql?
13
- add_index TABLE, INDEX_COLUMNS, length: { value: 191 }
13
+ add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
14
14
  else
15
15
  raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
16
16
  end
17
17
  end
18
18
 
19
19
  def down
20
- remove_index TABLE, column: INDEX_COLUMNS if index_exists?(TABLE, INDEX_COLUMNS)
20
+ remove_index TABLE, column: INDEX_COLUMNS, if_exists: true
21
21
  end
22
22
 
23
23
  private
@@ -0,0 +1,32 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class UpgradeLlmCostTrackerProviderInvoiceImportsProvider < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ TABLE = :llm_cost_tracker_provider_invoice_imports
7
+ OLD_INDEX = %i[source started_at].freeze
8
+ NEW_INDEX = %i[source provider started_at].freeze
9
+
10
+ def up
11
+ add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
12
+ if postgresql?
13
+ remove_index TABLE, column: OLD_INDEX, algorithm: :concurrently, if_exists: true
14
+ add_index TABLE, NEW_INDEX, algorithm: :concurrently, if_not_exists: true
15
+ else
16
+ remove_index TABLE, column: OLD_INDEX, if_exists: true
17
+ add_index TABLE, NEW_INDEX, if_not_exists: true
18
+ end
19
+ end
20
+
21
+ def down
22
+ remove_index TABLE, column: NEW_INDEX, if_exists: true
23
+ add_index TABLE, OLD_INDEX, if_not_exists: true
24
+ remove_column TABLE, :provider if column_exists?(TABLE, :provider)
25
+ end
26
+
27
+ private
28
+
29
+ def postgresql?
30
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class UpgradeLlmCostTrackerProviderInvoicesMetadataIndex < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ TABLE = :llm_cost_tracker_provider_invoices
7
+
8
+ def up
9
+ return unless postgresql?
10
+
11
+ add_index TABLE, :metadata, using: :gin, algorithm: :concurrently, if_not_exists: true
12
+ end
13
+
14
+ def down
15
+ return unless postgresql?
16
+
17
+ remove_index TABLE, column: :metadata, if_exists: true
18
+ end
19
+
20
+ private
21
+
22
+ def postgresql?
23
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
24
+ end
25
+ end
@@ -19,15 +19,6 @@ module LlmCostTracker
19
19
  )
20
20
  end
21
21
 
22
- def warn_about_rollups_truncation
23
- say(<<~MSG, :yellow)
24
- The migration clears existing llm_cost_tracker_call_rollups rows before adding the
25
- provider column. Budget reads fall back to live aggregation from
26
- llm_cost_tracker_calls until new events repopulate the rollups under their provider
27
- keys. See docs/upgrading.md for details.
28
- MSG
29
- end
30
-
31
22
  private
32
23
 
33
24
  def migration_version
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeProviderInvoiceImportsProviderGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds a provider column to llm_cost_tracker_provider_invoice_imports and a " \
14
+ "(source, provider, started_at) index so resume_cursor_for and " \
15
+ "last_completed_window_for can isolate per-provider state on shared sources (e.g. csv)."
16
+
17
+ def create_migration_file
18
+ migration_template(
19
+ "upgrade_provider_invoice_imports_provider.rb.erb",
20
+ "db/migrate/upgrade_llm_cost_tracker_provider_invoice_imports_provider.rb"
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def migration_version
27
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module LlmCostTracker
7
+ module Generators
8
+ class UpgradeProviderInvoicesMetadataIndexGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds a GIN index on llm_cost_tracker_provider_invoices.metadata for PostgreSQL " \
14
+ "so Reconciliation::Diff queries that filter on metadata->>'provider' / 'row_type' / " \
15
+ "'match_basis' hit an index instead of a sequential scan. No-op on MySQL."
16
+
17
+ def create_migration_file
18
+ migration_template(
19
+ "upgrade_provider_invoices_metadata_index.rb.erb",
20
+ "db/migrate/upgrade_llm_cost_tracker_provider_invoices_metadata_index.rb"
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def migration_version
27
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -40,7 +40,10 @@ module LlmCostTracker
40
40
  Ingestion::InboxEntry
41
41
  .where(id: rows.map(&:id), locked_by: identity)
42
42
  .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
43
- rescue StandardError
43
+ rescue StandardError => e
44
+ LlmCostTracker::Logging.warn(
45
+ "Inbox mark_failed failed for #{rows.size} rows: #{e.class}: #{e.message} (original error: #{error.class})"
46
+ )
44
47
  nil
45
48
  end
46
49
 
@@ -77,7 +80,7 @@ module LlmCostTracker
77
80
 
78
81
  def persist(rows, events)
79
82
  LlmCostTracker::Call.transaction do
80
- Ledger::Store.insert_many(events)
83
+ Ledger::Store.insert(events)
81
84
  Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
82
85
  end
83
86
  end
@@ -15,8 +15,6 @@ module LlmCostTracker
15
15
  class << self
16
16
  def save(event)
17
17
  insert_row(row_for(event))
18
- Ingestion::Worker.ensure_started
19
- event
20
18
  end
21
19
 
22
20
  def event_from_row(row)
@@ -54,14 +52,10 @@ module LlmCostTracker
54
52
  tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
55
53
  cost_status: payload.fetch(:cost_status),
56
54
  pricing_snapshot: payload[:pricing_snapshot],
57
- line_items: line_items_from(payload)
55
+ line_items: (payload[:line_items] || []).map { |attrs| Billing::LineItem.build(attrs) }
58
56
  }
59
57
  end
60
58
 
61
- def line_items_from(payload)
62
- (payload[:line_items] || []).map { |attributes| Billing::LineItem.build(attributes) }
63
- end
64
-
65
59
  def row_for(event)
66
60
  now = Time.now.utc
67
61
  {
@@ -86,25 +80,10 @@ module LlmCostTracker
86
80
  end
87
81
 
88
82
  def insert_row(row)
89
- connection = LlmCostTracker::Call.connection
90
- if connection.transaction_open?
91
- insert_with_separate_connection(row)
92
- else
93
- execute_insert(connection, row)
94
- end
83
+ Pool.with_connection { |connection| execute_insert(connection, row) }
95
84
  rescue ActiveRecord::ConnectionTimeoutError => e
96
85
  raise LlmCostTracker::Error,
97
- "ledger inbox could not checkout a separate database connection: #{e.message}"
98
- end
99
-
100
- def insert_with_separate_connection(row)
101
- pool = LlmCostTracker::Call.connection_pool
102
- connection = pool.checkout
103
- begin
104
- connection.transaction(requires_new: true) { execute_insert(connection, row) }
105
- ensure
106
- pool.checkin(connection)
107
- end
86
+ "ledger inbox could not checkout a database connection: #{e.message}"
108
87
  end
109
88
 
110
89
  def execute_insert(connection, row)
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Ingestion
5
+ module Pool
6
+ DEFAULT_POOL_SIZE = 2
7
+ MUTEX = Mutex.new
8
+
9
+ class << self
10
+ def with_connection(&)
11
+ pool.with_connection(&)
12
+ end
13
+
14
+ def pool
15
+ @pool || MUTEX.synchronize { @pool ||= connect! }
16
+ end
17
+
18
+ def reset!
19
+ MUTEX.synchronize do
20
+ @pool&.disconnect!
21
+ @pool = nil
22
+ @handler = nil
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def connect!
29
+ @handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
30
+ @handler.establish_connection(connection_config)
31
+ end
32
+
33
+ def connection_config
34
+ LlmCostTracker::Call.connection_db_config.configuration_hash.merge(pool: pool_size)
35
+ end
36
+
37
+ def pool_size
38
+ configured = LlmCostTracker.configuration.ingestion_pool_size.to_i
39
+ configured.positive? ? configured : DEFAULT_POOL_SIZE
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -16,29 +16,31 @@ module LlmCostTracker
16
16
  MAX_IDLE_INTERVAL_SECONDS = 5.0
17
17
  LEASE_SECONDS = 10
18
18
  FLUSH_TIMEOUT_SECONDS = 10
19
+ MUTEX = Mutex.new
20
+
19
21
  class << self
20
22
  def ensure_started
21
- return unless Ingestion.durable?
23
+ return unless Ingestion.async?
22
24
 
23
- thread = mutex.synchronize do
25
+ thread = MUTEX.synchronize do
24
26
  reset_after_fork!
25
- unless @thread&.alive?
26
- @stop_requested = false
27
- @generation = @generation.to_i + 1
28
- generation = @generation
29
- @thread = Thread.new { run(generation) }
30
- @thread.name = "llm_cost_tracker_ingestor"
31
- @thread.report_on_exception = false
32
- end
27
+ break @thread if @stop_requested || @thread&.alive?
28
+
29
+ @generation = @generation.to_i + 1
30
+ generation = @generation
31
+ @thread = Thread.new { run(generation) }
32
+ @thread.name = "llm_cost_tracker_ingestor"
33
+ @thread.report_on_exception = false
33
34
  @thread
34
35
  end
35
36
  wake_thread(thread)
36
37
  end
37
38
 
38
39
  def flush!(timeout: nil, require_lease: false)
39
- return true unless Ingestion.durable?
40
+ return true unless Ingestion.async?
40
41
 
41
42
  Ingestion.ensure_current_schema!
43
+ MUTEX.synchronize { reset_after_fork! }
42
44
 
43
45
  deadline = Time.now.utc + flush_timeout_seconds(timeout)
44
46
  loop do
@@ -56,10 +58,10 @@ module LlmCostTracker
56
58
  end
57
59
 
58
60
  def shutdown!(timeout: nil, drain: true)
59
- return true unless Ingestion.durable?
61
+ return true unless Ingestion.async?
60
62
 
61
63
  timeout ||= FLUSH_TIMEOUT_SECONDS
62
- thread = mutex.synchronize do
64
+ thread = MUTEX.synchronize do
63
65
  @stop_requested = true
64
66
  @generation = @generation.to_i + 1
65
67
  @thread
@@ -71,14 +73,14 @@ module LlmCostTracker
71
73
  handle_error(e)
72
74
  false
73
75
  ensure
74
- mutex.synchronize do
76
+ MUTEX.synchronize do
75
77
  @thread = nil if @thread.equal?(thread) && !thread&.alive?
76
78
  end
77
79
  end
78
80
 
79
81
  def reset!
80
- thread = mutex.synchronize do
81
- @stop_requested = true
82
+ thread = MUTEX.synchronize do
83
+ @stop_requested = false
82
84
  @generation = @generation.to_i + 1
83
85
  thread = @thread
84
86
  @thread = nil
@@ -98,6 +100,7 @@ module LlmCostTracker
98
100
 
99
101
  def ingest_once(require_lease: true)
100
102
  Ingestion.ensure_current_schema!
103
+ MUTEX.synchronize { reset_after_fork! }
101
104
  batch = Ingestion::Batch.new(identity: identity)
102
105
  return 0 unless batch.claimable?
103
106
  return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
@@ -110,16 +113,12 @@ module LlmCostTracker
110
113
 
111
114
  private
112
115
 
113
- def mutex
114
- @mutex ||= Mutex.new
115
- end
116
-
117
116
  def run(generation)
118
117
  idle_interval = IDLE_INTERVAL_SECONDS
119
118
  loop do
120
- break if mutex.synchronize { @stop_requested || generation != @generation }
119
+ break if MUTEX.synchronize { @stop_requested || generation != @generation }
121
120
 
122
- processed = executor_wrap { ingest_once }
121
+ processed = Rails.application.executor.wrap { ingest_once }
123
122
  release_connection!
124
123
  if processed.zero?
125
124
  sleep(idle_interval)
@@ -134,7 +133,7 @@ module LlmCostTracker
134
133
  end
135
134
  ensure
136
135
  release_connection!
137
- mutex.synchronize { @thread = nil if @thread.equal?(Thread.current) }
136
+ MUTEX.synchronize { @thread = nil if @thread.equal?(Thread.current) }
138
137
  end
139
138
 
140
139
  def reset_after_fork!
@@ -151,19 +150,6 @@ module LlmCostTracker
151
150
  nil
152
151
  end
153
152
 
154
- def executor_wrap(&)
155
- executor = rails_executor
156
- return yield unless executor
157
-
158
- executor.wrap(&)
159
- end
160
-
161
- def rails_executor
162
- Rails.application.try(:executor)
163
- rescue StandardError
164
- nil
165
- end
166
-
167
153
  def identity
168
154
  @identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
169
155
  end
@@ -2,11 +2,10 @@
2
2
 
3
3
  require "securerandom"
4
4
 
5
- require_relative "doctor/check"
6
5
  require_relative "errors"
7
6
  require_relative "ledger"
8
- require_relative "ingestion/inline"
9
7
  require_relative "ingestion/lease_claim"
8
+ require_relative "ingestion/pool"
10
9
  require_relative "ingestion/inbox"
11
10
  require_relative "ingestion/batch"
12
11
  require_relative "ingestion/worker"
@@ -28,7 +27,7 @@ module LlmCostTracker
28
27
 
29
28
  ROLLUPS_SCHEMA_GUARD = ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups].freeze
30
29
 
31
- DURABLE_SCHEMA_GUARDS = [
30
+ ASYNC_SCHEMA_GUARDS = [
32
31
  ["llm_cost_tracker_ingestion_inbox_entries", Ledger::Schema::IngestionInboxEntries],
33
32
  ["llm_cost_tracker_ingestion_leases", Ledger::Schema::IngestionLeases]
34
33
  ].freeze
@@ -47,8 +46,8 @@ module LlmCostTracker
47
46
  end
48
47
  end
49
48
 
50
- def durable?
51
- LlmCostTracker.configuration.durable_ingestion
49
+ def async?
50
+ LlmCostTracker.configuration.ingestion == :async
52
51
  end
53
52
 
54
53
  def cache_rollups?
@@ -58,7 +57,7 @@ module LlmCostTracker
58
57
  def guards_for_current_config
59
58
  guards = CORE_SCHEMA_GUARDS.dup
60
59
  guards << ROLLUPS_SCHEMA_GUARD if cache_rollups?
61
- guards += DURABLE_SCHEMA_GUARDS if durable?
60
+ guards += ASYNC_SCHEMA_GUARDS if async?
62
61
  guards
63
62
  end
64
63
 
@@ -93,7 +92,7 @@ module LlmCostTracker
93
92
  provider_response_id: response_id,
94
93
  tags: { feature: VERIFY_TAG }
95
94
  )
96
- LlmCostTracker::Ingestion::Worker.flush! if durable?
95
+ LlmCostTracker::Ingestion::Worker.flush! if async?
97
96
  persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
98
97
 
99
98
  return capture_success if persisted && notifications.any?
@@ -122,7 +121,7 @@ module LlmCostTracker
122
121
  end
123
122
 
124
123
  def capture_success
125
- path = durable? ? "durable inbox" : "inline writer"
124
+ path = async? ? "async inbox" : "inline writer"
126
125
  LlmCostTracker::Doctor::Check.new(
127
126
  :ok,
128
127
  "active_record capture",
@@ -149,7 +148,7 @@ module LlmCostTracker
149
148
  end
150
149
 
151
150
  def cleanup_verification_inbox(event:, response_id:)
152
- return unless durable? && LlmCostTracker::Ingestion::InboxEntry.table_exists?
151
+ return unless async? && LlmCostTracker::Ingestion::InboxEntry.table_exists?
153
152
 
154
153
  if event
155
154
  LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all