llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. 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
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "pricing/mode"
4
+ require_relative "billing/line_item"
5
+
3
6
  module LlmCostTracker
4
7
  Event = Data.define(
5
8
  :event_id,
@@ -22,6 +25,46 @@ module LlmCostTracker
22
25
  :pricing_snapshot,
23
26
  :line_items
24
27
  ) do
28
+ def self.batch_from_pricing_mode?(pricing_mode)
29
+ pricing_mode.to_s.split("_").include?("batch")
30
+ end
31
+
32
+ def self.build(**attributes)
33
+ pricing_mode = Pricing::Mode.normalize(attributes[:pricing_mode])
34
+ token_usage = attributes.fetch(:token_usage)
35
+ batch = attributes[:batch].nil? ? batch_from_pricing_mode?(pricing_mode) : attributes[:batch]
36
+ line_items = attributes[:line_items] || resolve_line_items(attributes[:service_line_items], token_usage)
37
+
38
+ new(
39
+ event_id: attributes[:event_id],
40
+ provider: attributes.fetch(:provider).to_s,
41
+ model: attributes.fetch(:model).to_s.strip.presence || Event::UNKNOWN_MODEL,
42
+ token_usage: token_usage,
43
+ pricing_mode: pricing_mode,
44
+ cost: attributes[:cost],
45
+ tags: attributes[:tags],
46
+ latency_ms: attributes[:latency_ms],
47
+ stream: attributes[:stream] || false,
48
+ usage_source: attributes[:usage_source],
49
+ provider_response_id: attributes[:provider_response_id].to_s.strip.presence,
50
+ provider_project_id: attributes[:provider_project_id].to_s.strip.presence,
51
+ provider_api_key_id: attributes[:provider_api_key_id].to_s.strip.presence,
52
+ provider_workspace_id: attributes[:provider_workspace_id].to_s.strip.presence,
53
+ batch: batch,
54
+ tracked_at: attributes[:tracked_at],
55
+ cost_status: attributes[:cost_status],
56
+ pricing_snapshot: attributes[:pricing_snapshot],
57
+ line_items: line_items
58
+ )
59
+ end
60
+
61
+ def self.resolve_line_items(service_items, token_usage)
62
+ service_line_items = Array(service_items).map do |item|
63
+ item.is_a?(Billing::LineItem) ? item : Billing::LineItem.build(item)
64
+ end
65
+ Billing::LineItem.from_token_usage(token_usage) + service_line_items
66
+ end
67
+
25
68
  def total_cost
26
69
  cost&.fetch(:total_cost, nil)
27
70
  end
@@ -35,4 +78,8 @@ module LlmCostTracker
35
78
  )
36
79
  end
37
80
  end
81
+
82
+ class Event
83
+ UNKNOWN_MODEL = "unknown"
84
+ end
38
85
  end
@@ -5,18 +5,18 @@ require "rails/generators/active_record"
5
5
 
6
6
  module LlmCostTracker
7
7
  module Generators
8
- class DurableIngestionGenerator < Rails::Generators::Base
8
+ class AsyncIngestionGenerator < Rails::Generators::Base
9
9
  include ActiveRecord::Generators::Migration
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Creates the durable ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
14
- "Required when config.durable_ingestion = true."
13
+ desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
14
+ "Required when config.ingestion = :async."
15
15
 
16
16
  def create_migration_file
17
17
  migration_template(
18
- "create_llm_cost_tracker_durable_ingestion.rb.erb",
19
- "db/migrate/create_llm_cost_tracker_durable_ingestion.rb"
18
+ "create_llm_cost_tracker_async_ingestion.rb.erb",
19
+ "db/migrate/create_llm_cost_tracker_async_ingestion.rb"
20
20
  )
21
21
  end
22
22
 
@@ -25,11 +25,11 @@ module LlmCostTracker
25
25
  After migrating, set the following in config/initializers/llm_cost_tracker.rb:
26
26
 
27
27
  LlmCostTracker.configure do |config|
28
- config.durable_ingestion = true
28
+ config.ingestion = :async
29
29
  end
30
30
 
31
- Without it the durable inbox tables stay unused and Tracker keeps writing
32
- inline. The doctor check warns about unused durable tables.
31
+ Without it the async inbox tables stay unused and Tracker keeps writing
32
+ inline. The doctor check warns about unused async ingestion tables.
33
33
  MSG
34
34
  end
35
35
 
@@ -26,10 +26,10 @@ module LlmCostTracker
26
26
  end
27
27
 
28
28
  def create_initializer
29
- template(
30
- "initializer.rb.erb",
31
- "config/initializers/llm_cost_tracker.rb"
32
- )
29
+ destination = "config/initializers/llm_cost_tracker.rb"
30
+ return if File.exist?(File.join(destination_root, destination))
31
+
32
+ template("initializer.rb.erb", destination)
33
33
  end
34
34
 
35
35
  def create_prices_file
@@ -42,7 +42,6 @@ module LlmCostTracker
42
42
  def mount_engine
43
43
  return unless options[:dashboard]
44
44
 
45
- add_engine_require
46
45
  say(<<~MSG, :yellow)
47
46
  The LLM Cost Tracker dashboard ships without authentication.
48
47
  Mount it in config/routes.rb behind your app's admin auth, e.g.:
@@ -61,24 +60,6 @@ module LlmCostTracker
61
60
  def migration_version
62
61
  "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
63
62
  end
64
-
65
- def add_engine_require
66
- return unless File.exist?("config/application.rb")
67
-
68
- contents = File.read("config/application.rb")
69
- return if contents.include?(%(require "llm_cost_tracker/engine"))
70
-
71
- unless contents.include?(%(require "rails/all"\n))
72
- prepend_to_file("config/application.rb", %(require "llm_cost_tracker/engine"\n))
73
- return
74
- end
75
-
76
- inject_into_file(
77
- "config/application.rb",
78
- %(require "llm_cost_tracker/engine"\n),
79
- after: %(require "rails/all"\n)
80
- )
81
- end
82
63
  end
83
64
  end
84
65
  end
@@ -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,39 @@
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
+ return unless table_exists?(TABLE)
12
+
13
+ add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
14
+ add_unique_index NEW_INDEX
15
+ remove_index TABLE, column: OLD_INDEX, unique: true, if_exists: true
13
16
  end
14
17
 
15
18
  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)
19
+ return unless table_exists?(TABLE)
20
+
21
+ add_unique_index OLD_INDEX
22
+ remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
18
23
  remove_column TABLE, :provider if column_exists?(TABLE, :provider)
19
24
  end
25
+
26
+ private
27
+
28
+ def add_unique_index(columns)
29
+ if postgresql?
30
+ add_index TABLE, columns, unique: true, algorithm: :concurrently, if_not_exists: true
31
+ else
32
+ add_index TABLE, columns, unique: true, if_not_exists: true
33
+ end
34
+ end
35
+
36
+ def postgresql?
37
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
38
+ end
20
39
  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,36 @@
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
+ return unless table_exists?(TABLE)
12
+
13
+ add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
14
+ if postgresql?
15
+ remove_index TABLE, column: OLD_INDEX, algorithm: :concurrently, if_exists: true
16
+ add_index TABLE, NEW_INDEX, algorithm: :concurrently, if_not_exists: true
17
+ else
18
+ remove_index TABLE, column: OLD_INDEX, if_exists: true
19
+ add_index TABLE, NEW_INDEX, if_not_exists: true
20
+ end
21
+ end
22
+
23
+ def down
24
+ return unless table_exists?(TABLE)
25
+
26
+ remove_index TABLE, column: NEW_INDEX, if_exists: true
27
+ add_index TABLE, OLD_INDEX, if_not_exists: true
28
+ remove_column TABLE, :provider if column_exists?(TABLE, :provider)
29
+ end
30
+
31
+ private
32
+
33
+ def postgresql?
34
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
35
+ end
36
+ end
@@ -0,0 +1,27 @@
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
+ return unless table_exists?(TABLE)
11
+
12
+ add_index TABLE, :metadata, using: :gin, algorithm: :concurrently, if_not_exists: true
13
+ end
14
+
15
+ def down
16
+ return unless postgresql?
17
+ return unless table_exists?(TABLE)
18
+
19
+ remove_index TABLE, column: :metadata, if_exists: true
20
+ end
21
+
22
+ private
23
+
24
+ def postgresql?
25
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
26
+ end
27
+ 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)
@@ -40,7 +38,7 @@ module LlmCostTracker
40
38
  provider: payload.fetch(:provider),
41
39
  model: payload.fetch(:model),
42
40
  token_usage: token_usage,
43
- pricing_mode: Pricing.normalize_mode(payload[:pricing_mode]),
41
+ pricing_mode: Pricing::Mode.normalize(payload[:pricing_mode]),
44
42
  cost: cost,
45
43
  tags: payload.fetch(:tags),
46
44
  latency_ms: payload[:latency_ms],
@@ -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