llm_cost_tracker 0.6.0 → 0.7.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +2 -3
  4. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -17
  5. data/config/routes.rb +1 -1
  6. data/lib/llm_cost_tracker/active_record_adapter.rb +9 -5
  7. data/lib/llm_cost_tracker/assets.rb +0 -6
  8. data/lib/llm_cost_tracker/budget.rb +3 -5
  9. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  10. data/lib/llm_cost_tracker/configuration.rb +2 -10
  11. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +1 -3
  12. data/lib/llm_cost_tracker/doctor.rb +8 -13
  13. data/lib/llm_cost_tracker/engine.rb +0 -3
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +2 -2
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +7 -1
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -17
  17. data/lib/llm_cost_tracker/integrations/registry.rb +0 -2
  18. data/lib/llm_cost_tracker/period_grouping.rb +3 -5
  19. data/lib/llm_cost_tracker/railtie.rb +2 -4
  20. data/lib/llm_cost_tracker/report.rb +2 -4
  21. data/lib/llm_cost_tracker/storage/active_record_backend.rb +2 -1
  22. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +1 -6
  23. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +1 -1
  24. data/lib/llm_cost_tracker/storage/{dispatcher.rb → writer.rb} +4 -14
  25. data/lib/llm_cost_tracker/tag_sql.rb +1 -1
  26. data/lib/llm_cost_tracker/tags_column.rb +2 -0
  27. data/lib/llm_cost_tracker/tracker.rb +2 -2
  28. data/lib/llm_cost_tracker/version.rb +1 -1
  29. data/lib/llm_cost_tracker.rb +2 -14
  30. data/lib/tasks/llm_cost_tracker.rake +1 -1
  31. metadata +33 -60
  32. data/docs/architecture.md +0 -28
  33. data/docs/budgets.md +0 -45
  34. data/docs/configuration.md +0 -65
  35. data/docs/cookbook.md +0 -185
  36. data/docs/dashboard-overview.png +0 -0
  37. data/docs/dashboard.md +0 -38
  38. data/docs/extending.md +0 -32
  39. data/docs/operations.md +0 -44
  40. data/docs/pricing.md +0 -94
  41. data/docs/querying.md +0 -36
  42. data/docs/streaming.md +0 -70
  43. data/docs/technical/README.md +0 -10
  44. data/docs/technical/data-flow.md +0 -70
  45. data/docs/technical/extension-points.md +0 -111
  46. data/docs/technical/module-map.md +0 -197
  47. data/docs/technical/operational-notes.md +0 -97
  48. data/docs/upgrading.md +0 -47
  49. data/lib/llm_cost_tracker/configuration/storage_backend.rb +0 -26
  50. data/lib/llm_cost_tracker/engine_compatibility.rb +0 -15
  51. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -32
  52. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -38
  53. data/lib/llm_cost_tracker/storage/registry.rb +0 -63
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa3f705baf280c2c2239b2dab3522fe7db9b60e26060b00fc08dcc039117da83
4
- data.tar.gz: ea34bdad7cb0d7c9fb3233b40b609cea1361ded833ad130c4a7a7ce559b34758
3
+ metadata.gz: 9de844d3302fda39c5a8b32736d8abff488a5cd6cfd00932f492a1ec701f308d
4
+ data.tar.gz: a8f942605f9cfbcc77f7e998e3e504150fa820e44829156e901cb06bdcc6d0cf
5
5
  SHA512:
6
- metadata.gz: 03c55866b522b36b0728f73fd0ae0075e0a42faa6f80c429865f120d2c597e0b0996665b6a82528c566a0f8554d7636dfd03d9ab600441dafd5a4f0233d3f56b
7
- data.tar.gz: af01c4554912d80276bf54ed97aa379c2eaed791fa357ca135aa008fdcbdd41e365c236ac74c9ceb1982c0fbbf2538bc569df1690acb4b5758895dd48c4497b5
6
+ metadata.gz: 4c7d9869224101f85298dab0b4395b0c2b96634f46f24e76487412f0f8418e4270fb15124f6f238499c3205f4ad2ebae6e8bcba1b265f7496e3509b7da7c5ea9
7
+ data.tar.gz: f07e91cf62de94d7e7c76eff7ec4b798208724df5722374d6a72884445a70a64ef83421b90aa8be51b887e277f3e8bcb7eec3ad14987eec25609483bc11f0667
data/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.7.0] - 2026-04-29
8
+
9
+ ### Changed
10
+
11
+ - BREAKING: ActiveRecord is now the only storage path; removed `storage_backend`, `custom_storage`, `Storage.register`, `:log`, and `:custom`.
12
+ - BREAKING: PostgreSQL and MySQL are now the only supported database adapters; SQLite support was removed.
13
+ - Runtime dependencies now include Rails and ActiveRecord.
14
+
15
+ ## [0.6.1] - 2026-04-29
16
+
17
+ ### Fixed
18
+
19
+ - Exclude repository documentation from the published gem package.
20
+
7
21
  ## [0.6.0] - 2026-04-29
8
22
 
9
23
  ### Added
data/README.md CHANGED
@@ -10,7 +10,7 @@ If you have OpenAI, Anthropic, or Gemini in production and someone keeps asking
10
10
 
11
11
  It is not Langfuse, Helicone, or LiteLLM. It does not capture prompts, score completions, or replay traces. It does one thing: tells you which provider, which model, which feature, and which user burned how much money. That's the entire pitch.
12
12
 
13
- Requires Ruby 3.3+, ActiveSupport 7.1+, Faraday 2.0+. ActiveRecord storage and the dashboard need Rails 7.1+.
13
+ Requires Ruby 3.3+, Rails 7.1+, PostgreSQL or MySQL, and Faraday 2.0+.
14
14
 
15
15
  ![Dashboard overview](docs/dashboard-overview.png)
16
16
 
@@ -35,7 +35,6 @@ Drop this into `config/initializers/llm_cost_tracker.rb`:
35
35
 
36
36
  ```ruby
37
37
  LlmCostTracker.configure do |config|
38
- config.storage_backend = :active_record
39
38
  config.default_tags = -> { { environment: Rails.env } }
40
39
  config.instrument :openai
41
40
  end
@@ -219,7 +218,7 @@ Mount the engine wherever you want — it's plain ERB, no JavaScript bundle, no
219
218
  mount LlmCostTracker::Engine => "/llm-costs"
220
219
  ```
221
220
 
222
- Pages: overview (spend trend, budget status, anomaly banner), models, calls (filterable, paginated, CSV export), tags, data quality. Reads `llm_api_calls`, so use `:active_record` storage if you want to mount it.
221
+ Pages: overview (spend trend, budget status, anomaly banner), models, calls (filterable, paginated, CSV export), tags, data quality. Reads the ActiveRecord ledger in `llm_api_calls`.
223
222
 
224
223
  Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs/dashboard.md).
225
224
 
@@ -45,7 +45,7 @@ module LlmCostTracker
45
45
  return postgresql_sql if ActiveRecordAdapter.postgresql?(connection)
46
46
  return mysql_sql if ActiveRecordAdapter.mysql?(connection)
47
47
 
48
- sqlite_sql
48
+ ActiveRecordAdapter.ensure_supported!(connection)
49
49
  end
50
50
 
51
51
  def mysql_sql
@@ -83,22 +83,6 @@ module LlmCostTracker
83
83
  SQL
84
84
  end
85
85
 
86
- def sqlite_sql
87
- <<~SQL.squish
88
- SELECT je.key AS key,
89
- COUNT(*) AS calls_count,
90
- COUNT(DISTINCT je.value) AS distinct_values
91
- FROM (#{subquery}) AS sub,
92
- json_each(sub.tags) AS je
93
- WHERE sub.tags IS NOT NULL
94
- AND sub.tags != '{}'
95
- AND sub.tags != ''
96
- GROUP BY je.key
97
- ORDER BY calls_count DESC
98
- LIMIT #{limit}
99
- SQL
100
- end
101
-
102
86
  def normalized_limit(value)
103
87
  value = value.to_i
104
88
  value.positive? ? [value, DEFAULT_LIMIT].min : DEFAULT_LIMIT
data/config/routes.rb CHANGED
@@ -8,6 +8,6 @@ LlmCostTracker::Engine.routes.draw do
8
8
  get "tags/:key", to: "tags#show", as: :tag, format: false
9
9
  get "data_quality", to: "data_quality#index", as: :data_quality
10
10
 
11
- get "assets/#{LlmCostTracker::Assets.stylesheet_filename}",
11
+ get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
12
12
  to: "assets#stylesheet", as: :stylesheet
13
13
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "errors"
4
+
3
5
  module LlmCostTracker
4
6
  module ActiveRecordAdapter
5
7
  MYSQL_ADAPTERS = %w[
@@ -10,12 +12,8 @@ module LlmCostTracker
10
12
  POSTGRESQL_ADAPTERS = %w[
11
13
  ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
12
14
  ].freeze
13
- SQLITE_ADAPTERS = %w[
14
- ActiveRecord::ConnectionAdapters::SQLite3Adapter
15
- ].freeze
16
15
  MYSQL_PATTERN = /mysql|trilogy|mariadb/i
17
16
  POSTGRESQL_PATTERN = /postgres/i
18
- SQLITE_PATTERN = /sqlite/i
19
17
 
20
18
  class << self
21
19
  def mysql?(value) = adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
@@ -24,7 +22,13 @@ module LlmCostTracker
24
22
  adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
25
23
  end
26
24
 
27
- def sqlite?(value) = adapter_instance?(value, SQLITE_ADAPTERS) || adapter_name(value).match?(SQLITE_PATTERN)
25
+ def supported?(value) = mysql?(value) || postgresql?(value)
26
+
27
+ def ensure_supported!(value)
28
+ return if supported?(value)
29
+
30
+ raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
31
+ end
28
32
 
29
33
  private
30
34
 
@@ -9,11 +9,5 @@ module LlmCostTracker
9
9
  STYLESHEET_PATH = File.join(ROOT, STYLESHEET).freeze
10
10
  STYLESHEET_FINGERPRINT = Digest::SHA256.file(STYLESHEET_PATH).hexdigest[0, 12].freeze
11
11
  STYLESHEET_FILENAME = "application-#{STYLESHEET_FINGERPRINT}.css".freeze
12
-
13
- class << self
14
- def root = ROOT
15
- def stylesheet_fingerprint = STYLESHEET_FINGERPRINT
16
- def stylesheet_filename = STYLESHEET_FILENAME
17
- end
18
12
  end
19
13
  end
@@ -8,7 +8,6 @@ module LlmCostTracker
8
8
  def enforce!
9
9
  config = LlmCostTracker.configuration
10
10
  return unless config.budget_exceeded_behavior == :block_requests
11
- return unless config.active_record?
12
11
 
13
12
  budgets = enforce_period_budgets(config)
14
13
  return if budgets.empty?
@@ -28,7 +27,7 @@ module LlmCostTracker
28
27
 
29
28
  check_per_call_budget(event, config)
30
29
  budgets = check_period_budgets(config)
31
- totals = totals_for_check(event, config, budgets)
30
+ totals = totals_for_check(event, budgets)
32
31
 
33
32
  budgets.each do |period, budget|
34
33
  total = totals.fetch(period)
@@ -63,11 +62,10 @@ module LlmCostTracker
63
62
  }.compact
64
63
  end
65
64
 
66
- def totals_for_check(event, config, budgets)
65
+ def totals_for_check(event, budgets)
67
66
  return {} if budgets.empty?
68
- return active_record_totals(budgets.keys, time: event.tracked_at) if config.active_record?
69
67
 
70
- budgets.to_h { |period, _budget| [period, event.cost.total_cost] }
68
+ active_record_totals(budgets.keys, time: event.tracked_at)
71
69
  end
72
70
 
73
71
  def active_record_totals(periods, time:)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "storage/dispatcher"
3
+ require_relative "storage/active_record_backend"
4
4
 
5
5
  module LlmCostTracker
6
6
  class CaptureVerifier
@@ -48,20 +48,13 @@ module LlmCostTracker
48
48
  ]
49
49
  end
50
50
 
51
- LlmCostTracker::Integrations.checks.map do |check|
51
+ LlmCostTracker::Integrations::Registry.checks.map do |check|
52
52
  Check.new(check.status, "sdk integration #{check.name}", check.message)
53
53
  end
54
54
  end
55
55
 
56
56
  def storage_checks
57
- backend = LlmCostTracker::Storage::Registry.fetch(LlmCostTracker.configuration.storage_backend)
58
- unless backend.respond_to?(:verify)
59
- return [
60
- Check.new(:warn, "storage", "#{LlmCostTracker.configuration.storage_backend} backend has no verifier")
61
- ]
62
- end
63
-
64
- backend.verify.map do |check|
57
+ LlmCostTracker::Storage::ActiveRecordBackend.verify.map do |check|
65
58
  Check.new(check.status, check.name, check.message)
66
59
  end
67
60
  rescue LlmCostTracker::Error => e
@@ -4,21 +4,18 @@ require_relative "errors"
4
4
  require_relative "tag_key"
5
5
  require_relative "value_helpers"
6
6
  require_relative "configuration/instrumentation"
7
- require_relative "configuration/storage_backend"
8
7
 
9
8
  module LlmCostTracker
10
9
  class Configuration
11
10
  include ConfigurationInstrumentation
12
- include ConfigurationStorageBackend
13
11
 
14
12
  OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
15
13
 
16
14
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
17
15
  STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
18
- STORAGE_BACKENDS = %i[log active_record custom].freeze
19
16
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
20
- SHARED_SCALAR_ATTRIBUTES = %i[enabled custom_storage on_budget_exceeded monthly_budget daily_budget per_call_budget
21
- log_level prices_file max_tag_count max_tag_value_bytesize].freeze
17
+ SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget log_level
18
+ prices_file max_tag_count max_tag_value_bytesize].freeze
22
19
  SHARED_ENUM_ATTRIBUTES = {
23
20
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
24
21
  storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
@@ -34,7 +31,6 @@ module LlmCostTracker
34
31
  :instrumented_integrations,
35
32
  :report_tag_breakdowns,
36
33
  :redacted_tag_keys,
37
- :storage_backend,
38
34
  :storage_error_behavior,
39
35
  :unknown_pricing_behavior,
40
36
  :openai_compatible_providers
@@ -42,8 +38,6 @@ module LlmCostTracker
42
38
 
43
39
  def initialize
44
40
  @enabled = true
45
- self.storage_backend = :log
46
- @custom_storage = nil
47
41
  @default_tags = {}
48
42
  @on_budget_exceeded = nil
49
43
  @monthly_budget = nil
@@ -134,8 +128,6 @@ module LlmCostTracker
134
128
  copy
135
129
  end
136
130
 
137
- def active_record? = storage_backend == :active_record
138
-
139
131
  private
140
132
 
141
133
  def normalize_enum(name, value, allowed, default:)
@@ -16,7 +16,7 @@ module LlmCostTracker
16
16
  end
17
17
 
18
18
  def call
19
- return unless active_record_storage? && llm_api_calls_table?
19
+ return unless llm_api_calls_table?
20
20
 
21
21
  missing = missing_parts
22
22
  if missing.empty?
@@ -56,8 +56,6 @@ module LlmCostTracker
56
56
  ].compact
57
57
  end
58
58
 
59
- def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
60
-
61
59
  def llm_api_calls_table? = table_exists?("llm_api_calls")
62
60
 
63
61
  def table_exists?(name)
@@ -40,7 +40,7 @@ module LlmCostTracker
40
40
  def checks
41
41
  [
42
42
  configuration_check,
43
- capture_check,
43
+ CaptureCheck.call(Check),
44
44
  *integration_checks,
45
45
  active_record_check,
46
46
  table_check,
@@ -56,26 +56,23 @@ module LlmCostTracker
56
56
 
57
57
  def configuration_check
58
58
  config = LlmCostTracker.configuration
59
- Check.new(:ok, "configuration", "storage_backend=#{config.storage_backend.inspect}, enabled=#{config.enabled}")
59
+ Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
60
60
  end
61
61
 
62
- def capture_check = CaptureCheck.call(Check)
63
-
64
62
  def integration_checks
65
- LlmCostTracker::Integrations.checks.map do |check|
63
+ LlmCostTracker::Integrations::Registry.checks.map do |check|
66
64
  Check.new(check.status, check.name.to_s, check.message)
67
65
  end
68
66
  end
69
67
 
70
68
  def active_record_check
71
- return Check.new(:ok, "storage", "ActiveRecord storage is disabled") unless active_record_storage?
72
69
  return Check.new(:ok, "active_record", "available") if active_record_available?
73
70
 
74
- Check.new(:error, "active_record", "unavailable; add ActiveRecord/Rails or change storage_backend")
71
+ Check.new(:error, "active_record", "unavailable")
75
72
  end
76
73
 
77
74
  def table_check
78
- return unless active_record_storage? && active_record_available?
75
+ return unless active_record_available?
79
76
  return Check.new(:ok, "llm_api_calls", "table exists") if llm_api_calls_table?
80
77
 
81
78
  Check.new(
@@ -86,7 +83,7 @@ module LlmCostTracker
86
83
  end
87
84
 
88
85
  def column_check
89
- return unless active_record_storage? && llm_api_calls_table?
86
+ return unless llm_api_calls_table?
90
87
 
91
88
  columns = column_names("llm_api_calls")
92
89
  missing_core = CORE_COLUMNS - columns
@@ -106,7 +103,7 @@ module LlmCostTracker
106
103
  end
107
104
 
108
105
  def period_totals_check
109
- return unless active_record_storage? && llm_api_calls_table?
106
+ return unless llm_api_calls_table?
110
107
  if table_exists?("llm_cost_tracker_period_totals")
111
108
  return Check.new(:ok, "period totals", "llm_cost_tracker_period_totals exists")
112
109
  end
@@ -133,7 +130,7 @@ module LlmCostTracker
133
130
  end
134
131
 
135
132
  def calls_check
136
- return unless active_record_storage? && llm_api_calls_table?
133
+ return unless llm_api_calls_table?
137
134
 
138
135
  count = LlmCostTracker::LlmApiCall.count
139
136
  return Check.new(:warn, "tracked calls", "none recorded yet") if count.zero?
@@ -142,8 +139,6 @@ module LlmCostTracker
142
139
  Check.new(:ok, "tracked calls", "#{count} recorded; latest #{latest}")
143
140
  end
144
141
 
145
- def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
146
-
147
142
  def active_record_available?
148
143
  require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
149
144
  LlmCostTracker::LlmApiCall.connection
@@ -2,12 +2,9 @@
2
2
 
3
3
  require "rails"
4
4
  require_relative "../llm_cost_tracker"
5
- require_relative "engine_compatibility"
6
5
  require_relative "assets"
7
6
  require "rack/files"
8
7
 
9
- LlmCostTracker::EngineCompatibility.check_rails_version!(Rails.version)
10
-
11
8
  module LlmCostTracker
12
9
  class Engine < ::Rails::Engine
13
10
  isolate_namespace LlmCostTracker
@@ -80,7 +80,7 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
80
80
  elsif mysql?
81
81
  "DATE(tracked_at)"
82
82
  else
83
- "date(tracked_at)"
83
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
84
84
  end
85
85
  end
86
86
 
@@ -90,7 +90,7 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
90
90
  elsif mysql?
91
91
  "DATE_FORMAT(tracked_at, '%Y-%m-01')"
92
92
  else
93
- "strftime('%Y-%m-01', tracked_at)"
93
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
94
94
  end
95
95
  end
96
96
 
@@ -24,8 +24,10 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
24
24
  t.string :pricing_mode
25
25
  if postgresql?
26
26
  t.jsonb :tags, null: false, default: {}
27
+ elsif mysql?
28
+ t.json :tags, null: false
27
29
  else
28
- t.text :tags
30
+ raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
29
31
  end
30
32
  t.datetime :tracked_at, null: false
31
33
 
@@ -79,4 +81,8 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
79
81
  def postgresql?
80
82
  LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
81
83
  end
84
+
85
+ def mysql?
86
+ LlmCostTracker::ActiveRecordAdapter.mysql?(connection)
87
+ end
82
88
  end
@@ -4,10 +4,6 @@ LlmCostTracker.configure do |config|
4
4
  # Set to false to temporarily disable tracking without removing middleware.
5
5
  config.enabled = true
6
6
 
7
- # :active_record captures events into a durable inbox and ingests them into llm_api_calls.
8
- # Other options: :log for local logging, :custom for your own storage callable.
9
- config.storage_backend = :active_record
10
-
11
7
  # Tags are merged into every event. Use a callable for request/job-time context.
12
8
  config.default_tags = -> { { environment: Rails.env } }
13
9
 
@@ -34,7 +30,7 @@ LlmCostTracker.configure do |config|
34
30
  # every model must have known pricing before it can be used.
35
31
  config.unknown_pricing_behavior = :warn
36
32
 
37
- # Used only by the :log storage backend.
33
+ # LLM Cost Tracker logs warnings through Rails.logger when available.
38
34
  config.log_level = :info
39
35
  <% if options[:prices] -%>
40
36
 
@@ -65,16 +61,4 @@ LlmCostTracker.configure do |config|
65
61
  # for bin/rails llm_cost_tracker:report.
66
62
  # config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
67
63
  # config.report_tag_breakdowns = %w[feature user_id]
68
-
69
- # Use :custom when you want to send events to your own sink instead of ActiveRecord.
70
- # Return false from custom_storage to skip budget checks for that event.
71
- # config.storage_backend = :custom
72
- # config.custom_storage = ->(event) {
73
- # Rails.logger.info(
74
- # provider: event.provider,
75
- # model: event.model,
76
- # total_cost: event.cost&.total_cost,
77
- # tags: event.tags
78
- # )
79
- # }
80
64
  end
@@ -67,7 +67,5 @@ module LlmCostTracker
67
67
  end
68
68
 
69
69
  def self.register(name, integration) = Registry.register(name, integration)
70
- def self.install! = Registry.install!
71
- def self.checks = Registry.checks
72
70
  end
73
71
  end
@@ -7,13 +7,11 @@ module LlmCostTracker
7
7
  PERIOD_FORMATS = {
8
8
  day: {
9
9
  postgres: "YYYY-MM-DD",
10
- mysql: "%Y-%m-%d",
11
- sqlite: "%Y-%m-%d"
10
+ mysql: "%Y-%m-%d"
12
11
  },
13
12
  month: {
14
13
  postgres: "YYYY-MM",
15
- mysql: "%Y-%m",
16
- sqlite: "%Y-%m"
14
+ mysql: "%Y-%m"
17
15
  }
18
16
  }.freeze
19
17
 
@@ -41,7 +39,7 @@ module LlmCostTracker
41
39
  elsif ActiveRecordAdapter.mysql?(connection)
42
40
  "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
43
41
  else
44
- "strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"
42
+ ActiveRecordAdapter.ensure_supported!(connection)
45
43
  end
46
44
  end
47
45
 
@@ -21,10 +21,8 @@ module LlmCostTracker
21
21
 
22
22
  initializer "llm_cost_tracker.configure" do
23
23
  ActiveSupport.on_load(:active_record) do
24
- if LlmCostTracker.configuration.active_record?
25
- require_relative "llm_api_call"
26
- require_relative "storage/active_record_store"
27
- end
24
+ require_relative "llm_api_call"
25
+ require_relative "storage/active_record_store"
28
26
  end
29
27
  end
30
28
  end
@@ -5,10 +5,8 @@ require_relative "report_formatter"
5
5
 
6
6
  module LlmCostTracker
7
7
  class Report
8
- DEFAULT_DAYS = ReportData::DEFAULT_DAYS
9
-
10
8
  class << self
11
- def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
9
+ def generate(days: ReportData::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
12
10
  report_data = ReportData.build(
13
11
  days: days,
14
12
  now: now,
@@ -23,7 +21,7 @@ module LlmCostTracker
23
21
  "Unable to build LLM cost report: #{e.class}: #{e.message}"
24
22
  end
25
23
 
26
- def data(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
24
+ def data(days: ReportData::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
27
25
  ReportData.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
28
26
  end
29
27
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  require "securerandom"
4
4
 
5
- require_relative "registry"
6
5
  require_relative "active_record_inbox"
7
6
  require_relative "active_record_ingestor"
8
7
  require_relative "active_record_store"
9
8
 
10
9
  module LlmCostTracker
11
10
  module Storage
11
+ VerificationResult = Data.define(:status, :name, :message)
12
+
12
13
  class ActiveRecordBackend
13
14
  VERIFY_TAG = "llm_cost_tracker_verify"
14
15
 
@@ -3,7 +3,6 @@
3
3
  require "json"
4
4
  require "time"
5
5
 
6
- require_relative "../active_record_adapter"
7
6
  require_relative "../cost"
8
7
  require_relative "../event"
9
8
  require_relative "../inbox_event"
@@ -110,7 +109,7 @@ module LlmCostTracker
110
109
 
111
110
  def insert_row(row)
112
111
  connection = LlmCostTracker::LlmApiCall.connection
113
- if connection.transaction_open? && !sqlite_database?(connection)
112
+ if connection.transaction_open?
114
113
  insert_with_separate_connection(row)
115
114
  else
116
115
  execute_insert(connection, row)
@@ -155,10 +154,6 @@ module LlmCostTracker
155
154
  def symbolize_keys(hash)
156
155
  hash.transform_keys(&:to_sym)
157
156
  end
158
-
159
- def sqlite_database?(connection)
160
- ActiveRecordAdapter.sqlite?(connection)
161
- end
162
157
  end
163
158
  end
164
159
  end
@@ -17,7 +17,7 @@ module LlmCostTracker
17
17
  return Arel.sql(mysql_sql) if ActiveRecordAdapter.mysql?(connection)
18
18
  return Arel.sql(postgres_sql) if ActiveRecordAdapter.postgresql?(connection)
19
19
 
20
- Arel.sql("total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at")
20
+ ActiveRecordAdapter.ensure_supported!(connection)
21
21
  end
22
22
 
23
23
  private
@@ -1,17 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
3
4
  require_relative "../logging"
4
- require_relative "registry"
5
5
  require_relative "active_record_backend"
6
- require_relative "custom_backend"
7
- require_relative "log_backend"
8
6
 
9
7
  module LlmCostTracker
10
8
  module Storage
11
- class Dispatcher
9
+ class Writer
12
10
  class << self
13
11
  def save(event)
14
- backend.save(event)
12
+ ActiveRecordBackend.save(event)
15
13
  rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
16
14
  raise
17
15
  rescue StandardError => e
@@ -21,25 +19,17 @@ module LlmCostTracker
21
19
 
22
20
  private
23
21
 
24
- def backend
25
- Registry.fetch(LlmCostTracker.configuration.storage_backend)
26
- end
27
-
28
22
  def handle_error(error)
29
23
  case LlmCostTracker.configuration.storage_error_behavior
30
24
  when :ignore
31
25
  nil
32
26
  when :warn
33
- Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
27
+ Logging.warn("ActiveRecord ledger write failed: #{error.class}: #{error.message}")
34
28
  when :raise
35
29
  raise StorageError, error
36
30
  end
37
31
  end
38
32
  end
39
33
  end
40
-
41
- Registry.register(:log, LogBackend)
42
- Registry.register(:active_record, ActiveRecordBackend)
43
- Registry.register(:custom, CustomBackend)
44
34
  end
45
35
  end
@@ -16,7 +16,7 @@ module LlmCostTracker
16
16
  elsif ActiveRecordAdapter.mysql?(model.connection)
17
17
  "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
18
18
  else
19
- "json_extract(#{column}, #{model.connection.quote(json_path(key))})"
19
+ ActiveRecordAdapter.ensure_supported!(model.connection)
20
20
  end
21
21
  end
22
22
 
@@ -79,6 +79,8 @@ module LlmCostTracker
79
79
  end
80
80
 
81
81
  def build_lct_schema_capabilities(columns, adapter_name)
82
+ ActiveRecordAdapter.ensure_supported!(adapter_name)
83
+
82
84
  tag_column = columns["tags"]
83
85
  tags_jsonb = tag_column && (tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb")
84
86
  tags_mysql_json =
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "securerandom"
4
4
 
5
- require_relative "storage/dispatcher"
5
+ require_relative "storage/writer"
6
6
 
7
7
  module LlmCostTracker
8
8
  class Tracker
@@ -41,7 +41,7 @@ module LlmCostTracker
41
41
 
42
42
  ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
43
43
 
44
- stored = Storage::Dispatcher.save(event)
44
+ stored = Storage::Writer.save(event)
45
45
  Budget.check!(event) unless stored == false
46
46
 
47
47
  event