llm_cost_tracker 0.5.2 → 0.6.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +8 -3
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
  5. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
  6. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
  7. data/docs/architecture.md +28 -0
  8. data/docs/budgets.md +45 -0
  9. data/docs/configuration.md +65 -0
  10. data/docs/cookbook.md +185 -0
  11. data/docs/dashboard-overview.png +0 -0
  12. data/docs/dashboard.md +38 -0
  13. data/docs/extending.md +32 -0
  14. data/docs/operations.md +44 -0
  15. data/docs/pricing.md +94 -0
  16. data/docs/querying.md +36 -0
  17. data/docs/streaming.md +70 -0
  18. data/docs/technical/README.md +10 -0
  19. data/docs/technical/data-flow.md +70 -0
  20. data/docs/technical/extension-points.md +111 -0
  21. data/docs/technical/module-map.md +197 -0
  22. data/docs/technical/operational-notes.md +97 -0
  23. data/docs/upgrading.md +47 -0
  24. data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  25. data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
  26. data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
  27. data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
  28. data/lib/llm_cost_tracker/configuration.rb +2 -1
  29. data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
  30. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  31. data/lib/llm_cost_tracker/doctor.rb +8 -1
  32. data/lib/llm_cost_tracker/event.rb +1 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  40. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  41. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
  43. data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
  44. data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
  45. data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
  46. data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
  47. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
  48. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  49. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  50. data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
  51. data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
  52. data/lib/llm_cost_tracker/pricing/lookup.rb +143 -0
  53. data/lib/llm_cost_tracker/pricing.rb +25 -108
  54. data/lib/llm_cost_tracker/railtie.rb +1 -0
  55. data/lib/llm_cost_tracker/retention.rb +3 -9
  56. data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
  57. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  58. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  59. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  60. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  61. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  62. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  63. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  64. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  65. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  66. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +59 -55
  67. data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
  68. data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
  69. data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
  70. data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
  71. data/lib/llm_cost_tracker/storage/registry.rb +63 -0
  72. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  73. data/lib/llm_cost_tracker/tag_sql.rb +34 -0
  74. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  75. data/lib/llm_cost_tracker/tracker.rb +3 -0
  76. data/lib/llm_cost_tracker/version.rb +1 -1
  77. data/lib/llm_cost_tracker.rb +39 -1
  78. data/lib/tasks/llm_cost_tracker.rake +49 -0
  79. metadata +47 -2
data/docs/upgrading.md ADDED
@@ -0,0 +1,47 @@
1
+ # Upgrading
2
+
3
+ LLM Cost Tracker is still moving quickly, so upgrades should be explicit:
4
+ inspect the changelog, run doctor, and apply only the generators your schema is
5
+ missing.
6
+
7
+ The version-by-version upgrade guide is moving here from the README.
8
+
9
+ ## Canonical Sources
10
+
11
+ Until this page is expanded, use:
12
+
13
+ - [Changelog](../CHANGELOG.md)
14
+ - [Quickstart](../README.md#quickstart)
15
+ - [Operations](operations.md)
16
+
17
+ ## Schema Generators
18
+
19
+ Existing installs can add newer optional columns through focused generators:
20
+
21
+ ```bash
22
+ bin/rails generate llm_cost_tracker:add_period_totals
23
+ bin/rails generate llm_cost_tracker:add_ingestion
24
+ bin/rails generate llm_cost_tracker:add_streaming
25
+ bin/rails generate llm_cost_tracker:add_provider_response_id
26
+ bin/rails generate llm_cost_tracker:add_usage_breakdown
27
+ bin/rails generate llm_cost_tracker:upgrade_tags_to_jsonb
28
+ bin/rails generate llm_cost_tracker:upgrade_cost_precision
29
+ bin/rails generate llm_cost_tracker:add_latency_ms
30
+ bin/rails db:migrate
31
+ bin/rails llm_cost_tracker:doctor
32
+ ```
33
+
34
+ On PostgreSQL, `upgrade_tags_to_jsonb` rewrites `llm_api_calls`. For large
35
+ tables, run it during a maintenance window or replace it with a two-phase
36
+ backfill.
37
+
38
+ ## Upgrade Habit
39
+
40
+ Run:
41
+
42
+ ```bash
43
+ bin/rails llm_cost_tracker:doctor
44
+ ```
45
+
46
+ Doctor tells you which optional columns and production-hardening pieces are still
47
+ missing.
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module ActiveRecordAdapter
5
+ MYSQL_ADAPTERS = %w[
6
+ ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
7
+ ActiveRecord::ConnectionAdapters::Mysql2Adapter
8
+ ActiveRecord::ConnectionAdapters::TrilogyAdapter
9
+ ].freeze
10
+ POSTGRESQL_ADAPTERS = %w[
11
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
12
+ ].freeze
13
+ SQLITE_ADAPTERS = %w[
14
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter
15
+ ].freeze
16
+ MYSQL_PATTERN = /mysql|trilogy|mariadb/i
17
+ POSTGRESQL_PATTERN = /postgres/i
18
+ SQLITE_PATTERN = /sqlite/i
19
+
20
+ class << self
21
+ def mysql?(value) = adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
22
+
23
+ def postgresql?(value)
24
+ adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
25
+ end
26
+
27
+ def sqlite?(value) = adapter_instance?(value, SQLITE_ADAPTERS) || adapter_name(value).match?(SQLITE_PATTERN)
28
+
29
+ private
30
+
31
+ def adapter_instance?(value, class_names)
32
+ class_names.any? do |class_name|
33
+ adapter_class = constantize(class_name)
34
+ adapter_class && value.is_a?(adapter_class)
35
+ end
36
+ end
37
+
38
+ def constantize(name)
39
+ name.split("::").reduce(Object) { |namespace, part| namespace.const_get(part, false) }
40
+ rescue NameError
41
+ nil
42
+ end
43
+
44
+ def adapter_name(value)
45
+ value.respond_to?(:adapter_name) ? value.adapter_name.to_s : value.to_s
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storage/dispatcher"
4
+
5
+ module LlmCostTracker
6
+ class CaptureVerifier
7
+ Check = Data.define(:status, :name, :message)
8
+
9
+ class << self
10
+ def call = new.checks
11
+
12
+ def report(checks = call)
13
+ (["LLM Cost Tracker capture verification"] + checks.map { |check| format_check(check) }).join("\n")
14
+ end
15
+
16
+ def healthy?(checks = call)
17
+ checks.none? { |check| check.status == :error }
18
+ end
19
+
20
+ private
21
+
22
+ def format_check(check)
23
+ "[#{check.status}] #{check.name}: #{check.message}"
24
+ end
25
+ end
26
+
27
+ def checks
28
+ [
29
+ enabled_check,
30
+ *integration_checks,
31
+ *storage_checks
32
+ ].compact
33
+ end
34
+
35
+ private
36
+
37
+ def enabled_check
38
+ return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
39
+
40
+ Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
41
+ end
42
+
43
+ def integration_checks
44
+ enabled = LlmCostTracker.configuration.instrumented_integrations
45
+ if enabled.empty?
46
+ return [
47
+ Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
48
+ ]
49
+ end
50
+
51
+ LlmCostTracker::Integrations.checks.map do |check|
52
+ Check.new(check.status, "sdk integration #{check.name}", check.message)
53
+ end
54
+ end
55
+
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|
65
+ Check.new(check.status, check.name, check.message)
66
+ end
67
+ rescue LlmCostTracker::Error => e
68
+ [Check.new(:error, "storage", e.message)]
69
+ end
70
+ end
71
+ end
@@ -31,7 +31,7 @@ module LlmCostTracker
31
31
  end
32
32
 
33
33
  def available_instrumentation_names
34
- Integrations::Registry::INTEGRATIONS.keys
34
+ Integrations::Registry.names
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module ConfigurationStorageBackend
5
+ def storage_backend=(value)
6
+ ensure_shared_configuration_mutable!
7
+ @storage_backend = normalize_storage_backend(value)
8
+ end
9
+
10
+ private
11
+
12
+ def normalize_storage_backend(value)
13
+ value = :log if value.nil?
14
+ value = value.to_sym
15
+ return value if self.class::STORAGE_BACKENDS.include?(value)
16
+ return value if defined?(Storage::Registry) && Storage::Registry.registered?(value)
17
+
18
+ names = if defined?(Storage::Registry)
19
+ Storage::Registry.names
20
+ else
21
+ self.class::STORAGE_BACKENDS
22
+ end
23
+ raise Error, "Unknown storage_backend: #{value.inspect}. Use one of: #{names.join(', ')}"
24
+ end
25
+ end
26
+ end
@@ -4,10 +4,12 @@ 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"
7
8
 
8
9
  module LlmCostTracker
9
10
  class Configuration
10
11
  include ConfigurationInstrumentation
12
+ include ConfigurationStorageBackend
11
13
 
12
14
  OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
13
15
 
@@ -18,7 +20,6 @@ module LlmCostTracker
18
20
  SHARED_SCALAR_ATTRIBUTES = %i[enabled custom_storage on_budget_exceeded monthly_budget daily_budget per_call_budget
19
21
  log_level prices_file max_tag_count max_tag_value_bytesize].freeze
20
22
  SHARED_ENUM_ATTRIBUTES = {
21
- storage_backend: [STORAGE_BACKENDS, :log],
22
23
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
23
24
  storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
24
25
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Doctor
5
+ class CaptureCheck
6
+ def self.call(check_class)
7
+ new(check_class).call
8
+ end
9
+
10
+ def initialize(check_class)
11
+ @check_class = check_class
12
+ end
13
+
14
+ def call
15
+ config = LlmCostTracker.configuration
16
+ return disabled_check unless config.enabled
17
+ return integrations_check(config.instrumented_integrations) if config.instrumented_integrations.any?
18
+
19
+ check(:ok, "no SDK integrations enabled; Faraday middleware and manual capture remain available")
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :check_class
25
+
26
+ def disabled_check
27
+ check(:warn, "tracking is disabled; set config.enabled = true to record calls")
28
+ end
29
+
30
+ def integrations_check(integrations)
31
+ check(:ok, "SDK integrations enabled: #{integrations.join(', ')}")
32
+ end
33
+
34
+ def check(status, message)
35
+ check_class.new(status, "capture", message)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module LlmCostTracker
6
+ class Doctor
7
+ class IngestionCheck
8
+ PENDING_AGE_WARNING_SECONDS = 60
9
+
10
+ def self.call(check_class)
11
+ new(check_class).call
12
+ end
13
+
14
+ def initialize(check_class)
15
+ @check_class = check_class
16
+ end
17
+
18
+ def call
19
+ return unless active_record_storage? && llm_api_calls_table?
20
+
21
+ missing = missing_parts
22
+ if missing.empty?
23
+ quarantined = quarantined_count
24
+ if quarantined.positive?
25
+ return check_class.new(:warn, "durable ingestion", "#{quarantined} inbox events quarantined after retries")
26
+ end
27
+
28
+ pending = pending_snapshot
29
+ if stale_pending?(pending)
30
+ return check_class.new(
31
+ :warn,
32
+ "durable ingestion",
33
+ "#{pending.fetch(:count)} inbox events pending; oldest pending age #{pending_age(pending).round}s"
34
+ )
35
+ end
36
+
37
+ return check_class.new(:ok, "durable ingestion", "inbox and ingestor lease tables available")
38
+ end
39
+
40
+ check_class.new(
41
+ :warn,
42
+ "durable ingestion",
43
+ "missing #{missing.join(', ')}; run bin/rails generate llm_cost_tracker:add_ingestion && bin/rails db:migrate"
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :check_class
50
+
51
+ def missing_parts
52
+ [
53
+ column_names("llm_api_calls").include?("event_id") ? nil : "llm_api_calls.event_id",
54
+ table_exists?("llm_cost_tracker_inbox_events") ? nil : "llm_cost_tracker_inbox_events",
55
+ table_exists?("llm_cost_tracker_ingestor_leases") ? nil : "llm_cost_tracker_ingestor_leases"
56
+ ].compact
57
+ end
58
+
59
+ def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
60
+
61
+ def llm_api_calls_table? = table_exists?("llm_api_calls")
62
+
63
+ def table_exists?(name)
64
+ LlmCostTracker::LlmApiCall.connection.data_source_exists?(name)
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ def column_names(table) = LlmCostTracker::LlmApiCall.connection.columns(table).map(&:name)
70
+
71
+ def quarantined_count
72
+ return 0 unless table_exists?("llm_cost_tracker_inbox_events")
73
+
74
+ LlmCostTracker::LlmApiCall.connection.select_value(quarantined_sql).to_i
75
+ rescue StandardError
76
+ 0
77
+ end
78
+
79
+ def quarantined_sql
80
+ table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
81
+ "SELECT COUNT(*) FROM #{table} WHERE attempts >= #{max_attempts}"
82
+ end
83
+
84
+ def pending_snapshot
85
+ row = LlmCostTracker::LlmApiCall.connection.select_one(pending_sql) || {}
86
+ {
87
+ count: row.fetch("pending_count").to_i,
88
+ oldest_at: row["oldest_created_at"] && Time.parse(row.fetch("oldest_created_at").to_s).utc
89
+ }
90
+ rescue StandardError
91
+ { count: 0, oldest_at: nil }
92
+ end
93
+
94
+ def pending_sql
95
+ table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
96
+ "SELECT COUNT(*) AS pending_count, MIN(created_at) AS oldest_created_at " \
97
+ "FROM #{table} WHERE attempts < #{max_attempts}"
98
+ end
99
+
100
+ def stale_pending?(pending)
101
+ pending.fetch(:count).positive? &&
102
+ pending.fetch(:oldest_at) &&
103
+ pending_age(pending) >= PENDING_AGE_WARNING_SECONDS
104
+ end
105
+
106
+ def pending_age(pending) = Time.now.utc - pending.fetch(:oldest_at)
107
+
108
+ def max_attempts
109
+ if defined?(LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS)
110
+ LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS
111
+ else
112
+ 5
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "price_freshness"
4
+ require_relative "doctor/capture_check"
5
+ require_relative "doctor/ingestion_check"
4
6
 
5
7
  module LlmCostTracker
6
8
  class Doctor
@@ -38,11 +40,13 @@ module LlmCostTracker
38
40
  def checks
39
41
  [
40
42
  configuration_check,
43
+ capture_check,
41
44
  *integration_checks,
42
45
  active_record_check,
43
46
  table_check,
44
47
  column_check,
45
48
  period_totals_check,
49
+ IngestionCheck.call(Check),
46
50
  prices_check,
47
51
  calls_check
48
52
  ].compact
@@ -51,9 +55,12 @@ module LlmCostTracker
51
55
  private
52
56
 
53
57
  def configuration_check
54
- Check.new(:ok, "configuration", "storage_backend=#{LlmCostTracker.configuration.storage_backend.inspect}")
58
+ config = LlmCostTracker.configuration
59
+ Check.new(:ok, "configuration", "storage_backend=#{config.storage_backend.inspect}, enabled=#{config.enabled}")
55
60
  end
56
61
 
62
+ def capture_check = CaptureCheck.call(Check)
63
+
57
64
  def integration_checks
58
65
  LlmCostTracker::Integrations.checks.map do |check|
59
66
  Check.new(check.status, check.name.to_s, check.message)
@@ -2,6 +2,7 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  Event = Data.define(
5
+ :event_id,
5
6
  :provider,
6
7
  :model,
7
8
  :input_tokens,
@@ -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 AddIngestionGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Creates a migration to add durable ActiveRecord ingestion"
14
+
15
+ def create_migration_file
16
+ migration_template(
17
+ "add_ingestion_to_llm_cost_tracker.rb.erb",
18
+ "db/migrate/add_ingestion_to_llm_cost_tracker.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,33 @@
1
+ class AddIngestionToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :llm_api_calls, :event_id, :string unless column_exists?(:llm_api_calls, :event_id)
4
+ add_index :llm_api_calls, :event_id, unique: true if column_exists?(:llm_api_calls, :event_id) &&
5
+ !index_exists?(:llm_api_calls, :event_id)
6
+
7
+ create_table :llm_cost_tracker_inbox_events do |t|
8
+ t.string :event_id, null: false
9
+ t.decimal :total_cost, precision: 20, scale: 8
10
+ t.datetime :tracked_at, null: false
11
+ t.text :payload, null: false
12
+ t.datetime :locked_at
13
+ t.string :locked_by
14
+ t.integer :attempts, null: false, default: 0
15
+ t.text :last_error
16
+
17
+ t.timestamps
18
+ end unless table_exists?(:llm_cost_tracker_inbox_events)
19
+
20
+ create_table :llm_cost_tracker_ingestor_leases do |t|
21
+ t.string :name, null: false
22
+ t.string :locked_by
23
+ t.datetime :locked_until
24
+
25
+ t.timestamps
26
+ end unless table_exists?(:llm_cost_tracker_ingestor_leases)
27
+
28
+ add_index :llm_cost_tracker_inbox_events, :event_id, unique: true unless index_exists?(:llm_cost_tracker_inbox_events, :event_id)
29
+ add_index :llm_cost_tracker_inbox_events, :tracked_at unless index_exists?(:llm_cost_tracker_inbox_events, :tracked_at)
30
+ add_index :llm_cost_tracker_inbox_events, [:locked_at, :id] unless index_exists?(:llm_cost_tracker_inbox_events, [:locked_at, :id])
31
+ add_index :llm_cost_tracker_ingestor_leases, :name, unique: true unless index_exists?(:llm_cost_tracker_ingestor_leases, :name)
32
+ end
33
+ end
@@ -1,3 +1,5 @@
1
+ require "llm_cost_tracker/active_record_adapter"
2
+
1
3
  class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
2
4
  def up
3
5
  create_table :llm_cost_tracker_period_totals do |t|
@@ -73,10 +75,9 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
73
75
  end
74
76
 
75
77
  def day_bucket_sql
76
- case connection.adapter_name
77
- when /postgres/i
78
+ if postgresql?
78
79
  "DATE_TRUNC('day', tracked_at)::date"
79
- when /mysql/i
80
+ elsif mysql?
80
81
  "DATE(tracked_at)"
81
82
  else
82
83
  "date(tracked_at)"
@@ -84,13 +85,20 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
84
85
  end
85
86
 
86
87
  def month_bucket_sql
87
- case connection.adapter_name
88
- when /postgres/i
88
+ if postgresql?
89
89
  "DATE_TRUNC('month', tracked_at)::date"
90
- when /mysql/i
90
+ elsif mysql?
91
91
  "DATE_FORMAT(tracked_at, '%Y-%m-01')"
92
92
  else
93
93
  "strftime('%Y-%m-01', tracked_at)"
94
94
  end
95
95
  end
96
+
97
+ def postgresql?
98
+ LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
99
+ end
100
+
101
+ def mysql?
102
+ LlmCostTracker::ActiveRecordAdapter.mysql?(connection)
103
+ end
96
104
  end
@@ -2,23 +2,19 @@ class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %
2
2
  def up
3
3
  unless column_exists?(:llm_api_calls, :stream)
4
4
  add_column :llm_api_calls, :stream, :boolean, null: false, default: false
5
- add_index :llm_api_calls, :stream
6
5
  end
7
6
 
8
7
  unless column_exists?(:llm_api_calls, :usage_source)
9
8
  add_column :llm_api_calls, :usage_source, :string
10
- add_index :llm_api_calls, :usage_source
11
9
  end
12
10
  end
13
11
 
14
12
  def down
15
13
  if column_exists?(:llm_api_calls, :usage_source)
16
- remove_index :llm_api_calls, :usage_source if index_exists?(:llm_api_calls, :usage_source)
17
14
  remove_column :llm_api_calls, :usage_source
18
15
  end
19
16
 
20
17
  if column_exists?(:llm_api_calls, :stream)
21
- remove_index :llm_api_calls, :stream if index_exists?(:llm_api_calls, :stream)
22
18
  remove_column :llm_api_calls, :stream
23
19
  end
24
20
  end
@@ -1,6 +1,9 @@
1
+ require "llm_cost_tracker/active_record_adapter"
2
+
1
3
  class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
2
4
  def change
3
5
  create_table :llm_api_calls do |t|
6
+ t.string :event_id, null: false
4
7
  t.string :provider, null: false
5
8
  t.string :model, null: false
6
9
  t.integer :input_tokens, null: false, default: 0
@@ -37,19 +40,43 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
37
40
  t.timestamps
38
41
  end
39
42
 
43
+ create_table :llm_cost_tracker_inbox_events do |t|
44
+ t.string :event_id, null: false
45
+ t.decimal :total_cost, precision: 20, scale: 8
46
+ t.datetime :tracked_at, null: false
47
+ t.text :payload, null: false
48
+ t.datetime :locked_at
49
+ t.string :locked_by
50
+ t.integer :attempts, null: false, default: 0
51
+ t.text :last_error
52
+
53
+ t.timestamps
54
+ end
55
+
56
+ create_table :llm_cost_tracker_ingestor_leases do |t|
57
+ t.string :name, null: false
58
+ t.string :locked_by
59
+ t.datetime :locked_until
60
+
61
+ t.timestamps
62
+ end
63
+
64
+ add_index :llm_api_calls, :event_id, unique: true
40
65
  add_index :llm_api_calls, :tracked_at
41
66
  add_index :llm_api_calls, [:provider, :tracked_at]
42
67
  add_index :llm_api_calls, [:model, :tracked_at]
43
- add_index :llm_api_calls, :stream
44
- add_index :llm_api_calls, :usage_source
45
68
  add_index :llm_api_calls, :provider_response_id
46
69
  add_index :llm_api_calls, :tags, using: :gin if postgresql?
47
70
  add_index :llm_cost_tracker_period_totals, [:period, :period_start], unique: true
71
+ add_index :llm_cost_tracker_inbox_events, :event_id, unique: true
72
+ add_index :llm_cost_tracker_inbox_events, :tracked_at
73
+ add_index :llm_cost_tracker_inbox_events, [:locked_at, :id]
74
+ add_index :llm_cost_tracker_ingestor_leases, :name, unique: true
48
75
  end
49
76
 
50
77
  private
51
78
 
52
79
  def postgresql?
53
- connection.adapter_name.downcase.include?("postgres")
80
+ LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
54
81
  end
55
82
  end
@@ -4,7 +4,7 @@ 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 stores events in llm_api_calls for dashboards, reports, and shared budgets.
7
+ # :active_record captures events into a durable inbox and ingests them into llm_api_calls.
8
8
  # Other options: :log for local logging, :custom for your own storage callable.
9
9
  config.storage_backend = :active_record
10
10
 
@@ -1,3 +1,5 @@
1
+ require "llm_cost_tracker/active_record_adapter"
2
+
1
3
  class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
2
4
  def up
3
5
  unless postgresql?
@@ -32,7 +34,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
32
34
  private
33
35
 
34
36
  def postgresql?
35
- connection.adapter_name.downcase.include?("postgres")
37
+ LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
36
38
  end
37
39
 
38
40
  def tags_jsonb?
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class InboxEvent < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_inbox_events"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class IngestorLease < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_ingestor_leases"
8
+ end
9
+ end