llm_cost_tracker 0.8.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -4,7 +4,10 @@ LlmCostTracker.configure do |config|
4
4
  # Set to false to temporarily disable tracking without removing middleware.
5
5
  config.enabled = true
6
6
 
7
- # Tags are merged into every event. Use a callable for request/job-time context.
7
+ # LLM Cost Tracker logs warnings through Rails.logger when available.
8
+ config.log_level = :info
9
+
10
+ # Tags merged into every event. Use a callable for request/job-time context.
8
11
  config.default_tags = -> { { environment: Rails.env } }
9
12
 
10
13
  # Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
@@ -18,40 +21,47 @@ LlmCostTracker.configure do |config|
18
21
  # config.instrument :anthropic
19
22
  # config.instrument :ruby_llm
20
23
 
21
- # Budget behavior: :notify calls on_budget_exceeded, :raise raises after recording,
22
- # :block_requests preflights monthly/daily budgets before supported requests.
23
- config.budget_exceeded_behavior = :notify
24
-
25
- # Unknown pricing records token usage with nil cost by default. Use :raise if
26
- # every model must have known pricing before it can be used.
27
- config.unknown_pricing_behavior = :warn
28
-
29
- # LLM Cost Tracker logs warnings through Rails.logger when available.
30
- config.log_level = :info
24
+ # Pricing local file refreshed via bin/rails llm_cost_tracker:prices:refresh
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).
31
28
  <% if options[:prices] -%>
32
-
33
- # Local JSON/YAML pricing file generated by --prices. Keep it in source control
34
- # and refresh it with bin/rails llm_cost_tracker:prices:refresh.
35
29
  config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
30
+ <% else -%>
31
+ # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
36
32
  <% end -%>
33
+ # config.pricing_overrides = {
34
+ # "my-custom-model" => { input: 1.00, output: 2.00 }
35
+ # }
36
+ # :warn (default) records token usage with nil cost when a model has no rate.
37
+ # Use :raise to require known pricing for every model.
38
+ config.unknown_pricing_behavior = :warn
37
39
 
38
- # Cumulative monthly/daily budgets and a single-call ceiling, in USD.
40
+ # Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
41
+ # plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
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;
45
+ # flip cache_rollups to true at high volume so reads hit the rollups table
46
+ # instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
39
47
  # config.monthly_budget = 100.00
40
48
  # config.daily_budget = 10.00
41
49
  # config.per_call_budget = 1.00
42
-
43
- # Called when :notify is selected and a monthly, daily, or per-call budget is exceeded.
50
+ config.budget_exceeded_behavior = :notify
44
51
  # config.on_budget_exceeded = ->(data) {
45
- # Rails.logger.warn(
46
- # "LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}"
47
- # )
52
+ # Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
48
53
  # }
54
+ # config.cache_rollups = true
49
55
 
50
- # Local pricing table and small Ruby-side overrides. Prices are USD per 1M tokens.
51
- # config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
52
- # config.pricing_overrides = {
53
- # "my-custom-model" => { input: 1.00, output: 2.00 }
54
- # }
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
58
+ # inserts and survives caller transaction rollbacks. Requires the optional
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
55
65
 
56
66
  # Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
57
67
  # for bin/rails llm_cost_tracker:report.
@@ -0,0 +1,35 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ TABLE = :llm_cost_tracker_call_rollups
7
+ OLD_INDEX = %i[period period_start currency].freeze
8
+ NEW_INDEX = %i[period period_start currency provider].freeze
9
+
10
+ def up
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
14
+ end
15
+
16
+ def down
17
+ add_unique_index OLD_INDEX
18
+ remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
19
+ remove_column TABLE, :provider if column_exists?(TABLE, :provider)
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
35
+ end
@@ -0,0 +1,32 @@
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
+
3
+ class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
4
+ disable_ddl_transaction!
5
+
6
+ TABLE = :llm_cost_tracker_call_tags
7
+ INDEX_COLUMNS = %i[key value].freeze
8
+
9
+ def up
10
+ if postgresql?
11
+ add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
12
+ elsif mysql?
13
+ add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
14
+ else
15
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
16
+ end
17
+ end
18
+
19
+ def down
20
+ remove_index TABLE, column: INDEX_COLUMNS, if_exists: true
21
+ end
22
+
23
+ private
24
+
25
+ def postgresql?
26
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
27
+ end
28
+
29
+ def mysql?
30
+ LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration<%= migration_version %>
2
+ TABLE = :llm_cost_tracker_calls
3
+ COLUMNS = %i[image_input_tokens image_output_tokens].freeze
4
+
5
+ def up
6
+ COLUMNS.each do |column|
7
+ next if column_exists?(TABLE, column)
8
+
9
+ add_column TABLE, column, :integer, null: false, default: 0
10
+ end
11
+ end
12
+
13
+ def down
14
+ COLUMNS.each do |column|
15
+ remove_column TABLE, column if column_exists?(TABLE, column)
16
+ end
17
+ end
18
+ end
@@ -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
@@ -0,0 +1,29 @@
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 UpgradeCallRollupsProviderGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds the v0.9 provider column and unique index to llm_cost_tracker_call_rollups."
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_call_rollups_provider.rb.erb",
18
+ "db/migrate/upgrade_llm_cost_tracker_call_rollups_provider.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
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 UpgradeCallTagsKeyValueIndexGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds a (key, value) composite index on llm_cost_tracker_call_tags " \
14
+ "so high-cardinality tag filters use an index lookup instead of a key-only scan."
15
+
16
+ def create_migration_file
17
+ migration_template(
18
+ "upgrade_call_tags_key_value_index.rb.erb",
19
+ "db/migrate/upgrade_llm_cost_tracker_call_tags_key_value_index.rb"
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def migration_version
26
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
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 UpgradeImageTokensGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Adds image_input_tokens and image_output_tokens columns to llm_cost_tracker_calls."
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "upgrade_image_tokens.rb.erb",
18
+ "db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ 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 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)
@@ -112,7 +91,6 @@ module LlmCostTracker
112
91
  quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
113
92
  quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
114
93
  table = connection.quote_table_name(InboxEntry.table_name)
115
-
116
94
  connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
117
95
  end
118
96
  end
@@ -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,25 +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
- thread = mutex.synchronize do
23
+ return unless Ingestion.async?
24
+
25
+ thread = MUTEX.synchronize do
22
26
  reset_after_fork!
23
- unless @thread&.alive?
24
- @stop_requested = false
25
- @generation = @generation.to_i + 1
26
- generation = @generation
27
- @thread = Thread.new { run(generation) }
28
- @thread.name = "llm_cost_tracker_ingestor"
29
- @thread.report_on_exception = false
30
- 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
31
34
  @thread
32
35
  end
33
36
  wake_thread(thread)
34
37
  end
35
38
 
36
39
  def flush!(timeout: nil, require_lease: false)
40
+ return true unless Ingestion.async?
41
+
37
42
  Ingestion.ensure_current_schema!
43
+ MUTEX.synchronize { reset_after_fork! }
38
44
 
39
45
  deadline = Time.now.utc + flush_timeout_seconds(timeout)
40
46
  loop do
@@ -52,25 +58,29 @@ module LlmCostTracker
52
58
  end
53
59
 
54
60
  def shutdown!(timeout: nil, drain: true)
61
+ return true unless Ingestion.async?
62
+
55
63
  timeout ||= FLUSH_TIMEOUT_SECONDS
56
- thread = mutex.synchronize do
64
+ thread = MUTEX.synchronize do
57
65
  @stop_requested = true
58
66
  @generation = @generation.to_i + 1
59
67
  @thread
60
68
  end
61
69
  wake_thread(thread)
62
- thread&.join([timeout, 1].min)
70
+ thread&.join(timeout)
63
71
  drain ? flush!(timeout: timeout, require_lease: true) : true
64
72
  rescue StandardError => e
65
73
  handle_error(e)
66
74
  false
67
75
  ensure
68
- mutex.synchronize { @thread = nil if @thread.equal?(thread) }
76
+ MUTEX.synchronize do
77
+ @thread = nil if @thread.equal?(thread) && !thread&.alive?
78
+ end
69
79
  end
70
80
 
71
81
  def reset!
72
- thread = mutex.synchronize do
73
- @stop_requested = true
82
+ thread = MUTEX.synchronize do
83
+ @stop_requested = false
74
84
  @generation = @generation.to_i + 1
75
85
  thread = @thread
76
86
  @thread = nil
@@ -90,6 +100,7 @@ module LlmCostTracker
90
100
 
91
101
  def ingest_once(require_lease: true)
92
102
  Ingestion.ensure_current_schema!
103
+ MUTEX.synchronize { reset_after_fork! }
93
104
  batch = Ingestion::Batch.new(identity: identity)
94
105
  return 0 unless batch.claimable?
95
106
  return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
@@ -102,16 +113,12 @@ module LlmCostTracker
102
113
 
103
114
  private
104
115
 
105
- def mutex
106
- @mutex ||= Mutex.new
107
- end
108
-
109
116
  def run(generation)
110
117
  idle_interval = IDLE_INTERVAL_SECONDS
111
118
  loop do
112
- break if mutex.synchronize { @stop_requested || generation != @generation }
119
+ break if MUTEX.synchronize { @stop_requested || generation != @generation }
113
120
 
114
- processed = executor_wrap { ingest_once }
121
+ processed = Rails.application.executor.wrap { ingest_once }
115
122
  release_connection!
116
123
  if processed.zero?
117
124
  sleep(idle_interval)
@@ -126,7 +133,7 @@ module LlmCostTracker
126
133
  end
127
134
  ensure
128
135
  release_connection!
129
- mutex.synchronize { @thread = nil if @thread.equal?(Thread.current) }
136
+ MUTEX.synchronize { @thread = nil if @thread.equal?(Thread.current) }
130
137
  end
131
138
 
132
139
  def reset_after_fork!
@@ -143,19 +150,6 @@ module LlmCostTracker
143
150
  nil
144
151
  end
145
152
 
146
- def executor_wrap(&)
147
- executor = rails_executor
148
- return yield unless executor
149
-
150
- executor.wrap(&)
151
- end
152
-
153
- def rails_executor
154
- Rails.application.try(:executor)
155
- rescue StandardError
156
- nil
157
- end
158
-
159
153
  def identity
160
154
  @identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
161
155
  end