llm_cost_tracker 0.12.0 → 0.14.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -16
  3. data/README.md +16 -28
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +3 -2
  5. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +2 -0
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -4
  8. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +11 -0
  9. data/app/models/llm_cost_tracker/call.rb +9 -3
  10. data/app/models/llm_cost_tracker/call_rollup.rb +19 -3
  11. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +1 -0
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +25 -0
  13. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +1 -1
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -1
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +3 -3
  16. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +5 -3
  17. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -10
  18. data/app/views/llm_cost_tracker/data_quality/index.html.erb +22 -0
  19. data/app/views/llm_cost_tracker/pricing/index.html.erb +1 -1
  20. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -3
  21. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -3
  22. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -3
  23. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -3
  24. data/lib/llm_cost_tracker/budget/per_tag.rb +159 -0
  25. data/lib/llm_cost_tracker/budget.rb +120 -32
  26. data/lib/llm_cost_tracker/capture/stream_collector.rb +5 -4
  27. data/lib/llm_cost_tracker/charges/cost_status.rb +4 -3
  28. data/lib/llm_cost_tracker/charges/line_item.rb +3 -2
  29. data/lib/llm_cost_tracker/configuration/budgets.rb +93 -0
  30. data/lib/llm_cost_tracker/configuration/capture.rb +42 -0
  31. data/lib/llm_cost_tracker/configuration/ingestion.rb +20 -0
  32. data/lib/llm_cost_tracker/configuration/mutability.rb +33 -0
  33. data/lib/llm_cost_tracker/configuration/pricing.rb +36 -0
  34. data/lib/llm_cost_tracker/configuration/section.rb +59 -0
  35. data/lib/llm_cost_tracker/configuration/tags.rb +52 -0
  36. data/lib/llm_cost_tracker/configuration.rb +69 -125
  37. data/lib/llm_cost_tracker/deprecator.rb +9 -0
  38. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +17 -8
  39. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  40. data/lib/llm_cost_tracker/doctor.rb +4 -4
  41. data/lib/llm_cost_tracker/engine.rb +4 -0
  42. data/lib/llm_cost_tracker/errors.rb +12 -3
  43. data/lib/llm_cost_tracker/event.rb +3 -1
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +2 -2
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +2 -2
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +0 -1
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +8 -4
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +50 -30
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +40 -0
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +41 -0
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +30 -0
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +30 -0
  53. data/lib/llm_cost_tracker/ingestion/batch.rb +27 -7
  54. data/lib/llm_cost_tracker/ingestion/pool.rb +9 -2
  55. data/lib/llm_cost_tracker/ingestion.rb +3 -7
  56. data/lib/llm_cost_tracker/integrations/anthropic.rb +8 -7
  57. data/lib/llm_cost_tracker/integrations/base.rb +1 -1
  58. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +10 -12
  59. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  60. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -4
  61. data/lib/llm_cost_tracker/ledger/store.rb +16 -2
  62. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +15 -5
  63. data/lib/llm_cost_tracker/logging.rb +3 -5
  64. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -1
  65. data/lib/llm_cost_tracker/prices.json +1384 -304
  66. data/lib/llm_cost_tracker/pricing/backfill.rb +11 -1
  67. data/lib/llm_cost_tracker/pricing/calculation.rb +44 -25
  68. data/lib/llm_cost_tracker/pricing/effective_prices.rb +26 -15
  69. data/lib/llm_cost_tracker/pricing/matcher.rb +24 -3
  70. data/lib/llm_cost_tracker/pricing/mode.rb +7 -7
  71. data/lib/llm_cost_tracker/pricing/rate.rb +1 -2
  72. data/lib/llm_cost_tracker/pricing/registry.rb +5 -5
  73. data/lib/llm_cost_tracker/pricing/sync.rb +1 -1
  74. data/lib/llm_cost_tracker/pricing/unknown.rb +11 -8
  75. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +3 -2
  76. data/lib/llm_cost_tracker/providers/openai/model_families.rb +0 -7
  77. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +5 -2
  78. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +5 -4
  79. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +2 -2
  80. data/lib/llm_cost_tracker/railtie.rb +3 -7
  81. data/lib/llm_cost_tracker/report/data.rb +2 -2
  82. data/lib/llm_cost_tracker/retention.rb +22 -10
  83. data/lib/llm_cost_tracker/tags/context.rb +3 -3
  84. data/lib/llm_cost_tracker/tags/sanitizer.rb +4 -4
  85. data/lib/llm_cost_tracker/tracker.rb +16 -26
  86. data/lib/llm_cost_tracker/usage/catalog.rb +12 -2
  87. data/lib/llm_cost_tracker/usage/dimensions.yml +0 -24
  88. data/lib/llm_cost_tracker/usage/token_usage.rb +20 -75
  89. data/lib/llm_cost_tracker/version.rb +1 -1
  90. data/lib/llm_cost_tracker.rb +7 -2
  91. data/lib/tasks/llm_cost_tracker.rake +23 -10
  92. metadata +22 -9
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+
5
+ module LlmCostTracker
6
+ class Configuration
7
+ class Pricing < Section
8
+ UNKNOWN_BEHAVIORS = %i[ignore warn raise].freeze
9
+
10
+ attributes :file
11
+ enum_attribute :unknown_model_behavior, allowed: UNKNOWN_BEHAVIORS, default: :warn
12
+
13
+ attr_reader :overrides
14
+
15
+ def initialize(owner)
16
+ super
17
+ @file = nil
18
+ self.overrides = {}
19
+ self.unknown_model_behavior = :warn
20
+ end
21
+
22
+ def overrides=(value)
23
+ ensure_mutable!
24
+ @overrides = LlmCostTracker::Pricing::Registry.normalize_price_entries(
25
+ value || {}, context: "pricing_overrides"
26
+ )
27
+ rescue ArgumentError, TypeError => e
28
+ raise Error, "invalid pricing.overrides: #{e.message}"
29
+ end
30
+
31
+ def finalize!
32
+ @overrides = deep_freeze(@overrides || {})
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ require_relative "mutability"
7
+
8
+ module LlmCostTracker
9
+ class Configuration
10
+ class Section
11
+ include Mutability
12
+
13
+ class << self
14
+ def attributes(*names)
15
+ names.each do |name|
16
+ attr_reader name
17
+
18
+ define_method(:"#{name}=") do |value|
19
+ ensure_mutable!
20
+ instance_variable_set(:"@#{name}", value)
21
+ end
22
+ end
23
+ end
24
+
25
+ def enum_attribute(name, allowed:, default:)
26
+ attr_reader name
27
+
28
+ define_method(:"#{name}=") do |value|
29
+ ensure_mutable!
30
+ instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default))
31
+ end
32
+ end
33
+ end
34
+
35
+ private attr_reader :owner
36
+
37
+ delegate :finalized?, to: :owner
38
+
39
+ def initialize(owner)
40
+ @owner = owner
41
+ end
42
+
43
+ def finalize!; end
44
+
45
+ private
46
+
47
+ def section_name
48
+ self.class.name.split("::").last.underscore
49
+ end
50
+
51
+ def normalize_enum(name, value, allowed, default)
52
+ value = default if value.nil?
53
+ return value if allowed.include?(value)
54
+
55
+ raise Error, "Unknown #{section_name}.#{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "section"
4
+
5
+ module LlmCostTracker
6
+ class Configuration
7
+ class Tags < Section
8
+ DEFAULT_REDACTED_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
9
+
10
+ attributes :default, :max_count, :max_value_bytesize
11
+
12
+ attr_reader :redacted_keys, :report_breakdown_keys
13
+
14
+ def initialize(owner)
15
+ super
16
+ @default = {}
17
+ @max_count = 50
18
+ @max_value_bytesize = 1024
19
+ @redacted_keys = DEFAULT_REDACTED_KEYS.dup
20
+ @report_breakdown_keys = []
21
+ end
22
+
23
+ def redacted_keys=(value)
24
+ ensure_mutable!
25
+ @redacted_keys = Array(value).map(&:to_s)
26
+ end
27
+
28
+ def report_breakdown_keys=(value)
29
+ ensure_mutable!
30
+ @report_breakdown_keys = Array(value).map { |key| LlmCostTracker::Tags::Key.validate!(key, error_class: Error) }
31
+ end
32
+
33
+ def normalized_redacted_keys
34
+ @normalized_redacted_keys ||=
35
+ Array(@redacted_keys).map { |key| LlmCostTracker::Tags::Sanitizer.normalized_key(key) }.freeze
36
+ end
37
+
38
+ def static_sanitized_default
39
+ return nil if @default.respond_to?(:call)
40
+
41
+ @static_sanitized_default ||=
42
+ LlmCostTracker::Tags::Sanitizer.call((@default || {}).to_h, config: owner).freeze
43
+ end
44
+
45
+ def finalize!
46
+ @default = deep_freeze(@default || {})
47
+ @redacted_keys = deep_freeze(Array(@redacted_keys))
48
+ @report_breakdown_keys = deep_freeze(Array(@report_breakdown_keys))
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,89 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
+ require_relative "deprecator"
4
5
  require_relative "pricing/registry"
5
6
  require_relative "tags/key"
7
+ require_relative "configuration/budgets"
8
+ require_relative "configuration/capture"
9
+ require_relative "configuration/ingestion"
10
+ require_relative "configuration/pricing"
11
+ require_relative "configuration/tags"
6
12
 
7
13
  module LlmCostTracker
8
14
  class Configuration
9
- OPENAI_COMPATIBLE_PROVIDERS = {
10
- "openrouter.ai" => "openrouter",
11
- "api.deepseek.com" => "deepseek",
12
- "api.groq.com" => "groq"
15
+ include Mutability
16
+
17
+ SECTIONS = {
18
+ budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
13
19
  }.freeze
14
20
 
15
- BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
16
- UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
17
- INGESTION_MODES = %i[inline async].freeze
18
- SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
19
- log_level prices_file max_tag_count max_tag_value_bytesize
20
- ingestion_pool_size auto_enable_stream_usage cache_rollups].freeze
21
- ENUM_ATTRIBUTES = {
22
- budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
23
- unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn],
24
- ingestion: [INGESTION_MODES, :inline]
21
+ SCALAR_ATTRIBUTES = %i[enabled].freeze
22
+
23
+ DEPRECATED_OPTIONS = {
24
+ monthly_budget: { to: %i[budgets monthly] },
25
+ daily_budget: { to: %i[budgets daily] },
26
+ per_call_budget: { to: %i[budgets per_call] },
27
+ budget_exceeded_behavior: { to: %i[budgets exceeded_behavior] },
28
+ on_budget_exceeded: { to: %i[budgets on_exceeded] },
29
+ default_tags: { to: %i[tags default] },
30
+ max_tag_count: { to: %i[tags max_count] },
31
+ max_tag_value_bytesize: { to: %i[tags max_value_bytesize] },
32
+ redacted_tag_keys: { to: %i[tags redacted_keys] },
33
+ report_tag_breakdowns: { to: %i[tags report_breakdown_keys] },
34
+ prices_file: { to: %i[pricing file] },
35
+ pricing_overrides: { to: %i[pricing overrides] },
36
+ unknown_pricing_behavior: { to: %i[pricing unknown_model_behavior] },
37
+ ingestion_pool_size: { to: %i[ingestion pool_size] },
38
+ auto_enable_stream_usage: { to: %i[capture request_stream_usage] },
39
+ openai_compatible_providers: { to: %i[capture openai_compatible_providers] },
40
+ cache_rollups: {
41
+ to: %i[budgets totals_source],
42
+ cast: ->(value) { value ? :cache : :ledger },
43
+ uncast: ->(value) { value == :cache }
44
+ },
45
+ ingestion: { to: %i[ingestion mode], writer_only: true },
46
+ log_level: { to: nil, note: "LlmCostTracker logs through Rails.logger, which owns the level" }
25
47
  }.freeze
26
- DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
27
-
28
- attr_reader(
29
- *SCALAR_ATTRIBUTES,
30
- :budget_exceeded_behavior,
31
- :ingestion,
32
- :instrumented_integrations,
33
- :pricing_overrides,
34
- :report_tag_breakdowns,
35
- :redacted_tag_keys,
36
- :unknown_pricing_behavior,
37
- :openai_compatible_providers
38
- )
48
+
49
+ attr_reader(*SCALAR_ATTRIBUTES, *SECTIONS.keys, :instrumented_integrations)
39
50
 
40
51
  def initialize
52
+ SECTIONS.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(self)) }
41
53
  @enabled = true
42
- @default_tags = {}
43
- @on_budget_exceeded = nil
44
- @monthly_budget = nil
45
- @daily_budget = nil
46
- @per_call_budget = nil
47
- self.budget_exceeded_behavior = :notify
48
- self.unknown_pricing_behavior = :warn
49
- @log_level = :info
50
- @prices_file = nil
51
- @max_tag_count = 50
52
- @max_tag_value_bytesize = 1024
53
- @ingestion_pool_size = nil
54
- self.pricing_overrides = {}
54
+ @log_level = :info
55
55
  @instrumented_integrations = Set.new
56
- @report_tag_breakdowns = []
57
- @redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
58
- self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
59
- @auto_enable_stream_usage = true
60
- self.ingestion = :inline
61
- @cache_rollups = false
62
56
  @finalized = false
63
57
  end
64
58
 
65
- def openai_compatible_providers=(providers)
66
- ensure_mutable!
67
- @openai_compatible_providers = normalize_openai_compatible_providers(providers)
68
- end
69
-
70
- def pricing_overrides=(value)
71
- ensure_mutable!
72
- @pricing_overrides = Pricing::Registry.normalize_price_entries(value || {}, context: "pricing_overrides")
73
- rescue ArgumentError, TypeError => e
74
- raise Error, "invalid pricing_overrides: #{e.message}"
75
- end
76
-
77
- def report_tag_breakdowns=(value)
78
- ensure_mutable!
79
- @report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
80
- end
81
-
82
- def redacted_tag_keys=(value)
83
- ensure_mutable!
84
- @redacted_tag_keys = Array(value).map(&:to_s)
85
- end
86
-
87
59
  def instrument(*names)
88
60
  ensure_mutable!
89
61
  names = names.flatten
@@ -96,83 +68,55 @@ module LlmCostTracker
96
68
  end
97
69
 
98
70
  SCALAR_ATTRIBUTES.each do |name|
99
- define_method("#{name}=") do |value|
71
+ define_method(:"#{name}=") do |value|
100
72
  ensure_mutable!
101
73
  instance_variable_set(:"@#{name}", value)
102
74
  end
103
75
  end
104
76
 
105
- ENUM_ATTRIBUTES.each do |name, (allowed, default)|
106
- define_method("#{name}=") do |value|
107
- ensure_mutable!
108
- instance_variable_set(:"@#{name}", normalize_enum(name, value, allowed, default: default))
77
+ DEPRECATED_OPTIONS.each do |old_name, spec|
78
+ path = spec[:to]
79
+ replacement = path ? "config.#{path.join('.')}" : nil
80
+
81
+ define_method(:"#{old_name}=") do |value|
82
+ LlmCostTracker.deprecator.warn(deprecation_message(old_name, replacement, spec[:note], writer: true))
83
+ ensure_mutable! unless path
84
+ next instance_variable_set(:"@#{old_name}", value) unless path
85
+
86
+ value = spec[:cast].call(value) if spec[:cast]
87
+ target = path[0..-2].inject(self) { |object, step| object.public_send(step) }
88
+ target.public_send(:"#{path.last}=", value)
89
+ end
90
+
91
+ next if spec[:writer_only]
92
+
93
+ define_method(old_name) do
94
+ LlmCostTracker.deprecator.warn(deprecation_message(old_name, replacement, spec[:note], writer: false))
95
+ next instance_variable_get(:"@#{old_name}") unless path
96
+
97
+ current = path.inject(self) { |object, step| object.public_send(step) }
98
+ spec[:uncast] ? spec[:uncast].call(current) : current
109
99
  end
110
100
  end
111
101
 
112
102
  def finalize!
113
- @default_tags = deep_freeze(@default_tags || {})
114
- @pricing_overrides = deep_freeze(@pricing_overrides || {})
103
+ SECTIONS.each_key { |name| public_send(name).finalize! }
115
104
  @instrumented_integrations = deep_freeze(@instrumented_integrations || Set.new)
116
- @report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
117
- @redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
118
- @openai_compatible_providers = deep_freeze(
119
- normalize_openai_compatible_providers(@openai_compatible_providers)
120
- )
121
105
  @finalized = true
122
106
  end
123
107
 
124
- def normalized_redacted_tag_keys
125
- @normalized_redacted_tag_keys ||=
126
- Array(@redacted_tag_keys).map { |key| Tags::Sanitizer.normalized_key(key) }.freeze
127
- end
128
-
129
- def static_sanitized_default_tags
130
- return nil if @default_tags.respond_to?(:call)
131
-
132
- @static_sanitized_default_tags ||= Tags::Sanitizer.call((@default_tags || {}).to_h).freeze
133
- end
134
-
135
108
  def finalized?
136
109
  @finalized
137
110
  end
138
111
 
139
112
  private
140
113
 
141
- def normalize_enum(name, value, allowed, default:)
142
- value = default if value.nil?
143
- return value if allowed.include?(value)
114
+ def deprecation_message(old_name, replacement, note, writer:)
115
+ suffix = writer ? "=" : ""
116
+ name = "config.#{old_name}#{suffix}"
117
+ return "#{name} is deprecated; use #{replacement}#{suffix}" if replacement
144
118
 
145
- raise Error, "Unknown #{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
146
- end
147
-
148
- def normalize_openai_compatible_providers(providers)
149
- (providers || {}).each_with_object({}) do |(host, provider), normalized|
150
- normalized[host.to_s.downcase] = provider.to_s
151
- end
152
- end
153
-
154
- def ensure_mutable!
155
- return unless finalized?
156
-
157
- raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
158
- end
159
-
160
- def deep_freeze(value)
161
- case value
162
- when Hash
163
- value.each do |key, nested_value|
164
- deep_freeze(key)
165
- deep_freeze(nested_value)
166
- end
167
- value.frozen? ? value : value.freeze
168
- when Array, Set
169
- value.each { |nested_value| deep_freeze(nested_value) }
170
- value.frozen? ? value : value.freeze
171
- when String
172
- value.frozen? ? value : value.freeze
173
- else
174
- value
175
- end
119
+ "#{name} is deprecated and has no effect; #{note}"
176
120
  end
177
121
  end
178
122
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/deprecation"
4
+
5
+ module LlmCostTracker
6
+ def self.deprecator
7
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "LlmCostTracker")
8
+ end
9
+ end
@@ -11,20 +11,20 @@ module LlmCostTracker
11
11
  return unless Probe.table_exists?("llm_cost_tracker_calls")
12
12
  return inline_check unless LlmCostTracker::Ingestion.async?
13
13
 
14
- missing = missing_parts
15
- return async_ok if missing.empty?
14
+ problems = missing_parts + drifted_parts
15
+ return async_ok if problems.empty?
16
16
 
17
17
  Check.new(
18
18
  :error,
19
19
  "async ingestion",
20
- "missing #{missing.join(', ')}; see docs/upgrading.md for the recovery steps"
20
+ "#{problems.join('; ')}; see docs/upgrading.md for the recovery steps"
21
21
  )
22
22
  end
23
23
 
24
24
  private
25
25
 
26
26
  def async_ok
27
- Check.new(:ok, "async ingestion", "inbox and ingestion lease tables available")
27
+ Check.new(:ok, "async ingestion", "inbox and ingestion lease tables are on the current schema")
28
28
  end
29
29
 
30
30
  def inline_check
@@ -32,14 +32,14 @@ module LlmCostTracker
32
32
  if leftovers.empty?
33
33
  return Check.new(:ok,
34
34
  "inline ingestion",
35
- "config.ingestion = :inline; events write directly to the ledger")
35
+ "config.ingestion.mode = :inline; events write directly to the ledger")
36
36
  end
37
37
 
38
38
  Check.new(
39
39
  :warn,
40
40
  "inline ingestion",
41
- "config.ingestion = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
42
- "Set config.ingestion = :async to keep the inbox path or drop the tables."
41
+ "config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
42
+ "Set config.ingestion.mode = :async to keep the inbox path or drop the tables."
43
43
  )
44
44
  end
45
45
 
@@ -48,7 +48,16 @@ module LlmCostTracker
48
48
  end
49
49
 
50
50
  def missing_parts
51
- async_tables.reject { |table| Probe.table_exists?(table) }
51
+ async_tables.reject { |table| Probe.table_exists?(table) }.map { |table| "missing #{table}" }
52
+ end
53
+
54
+ def drifted_parts
55
+ LlmCostTracker::Ledger::Schema::ASYNC_SCHEMAS.filter_map do |schema, table|
56
+ next unless Probe.table_exists?(table)
57
+
58
+ errors = schema.current_schema_errors
59
+ "#{table} #{errors.join(', ')}" unless errors.empty?
60
+ end
52
61
  end
53
62
 
54
63
  def async_tables
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
  REFRESH_COMMAND = "refresh the source-controlled prices file with bin/rails llm_cost_tracker:prices:refresh"
12
12
 
13
13
  def call
14
- path = LlmCostTracker.configuration.prices_file
14
+ path = LlmCostTracker.configuration.pricing.file
15
15
  return bundled_check unless path
16
16
 
17
17
  count = LlmCostTracker::Pricing::Registry.file_prices(path).size
@@ -164,7 +164,7 @@ module LlmCostTracker
164
164
 
165
165
  def call_rollups_check
166
166
  return unless llm_cost_tracker_calls_table?
167
- return live_rollups_check unless LlmCostTracker.configuration.cache_rollups
167
+ return live_rollups_check unless LlmCostTracker.configuration.budgets.totals_source == :cache
168
168
 
169
169
  errors = LlmCostTracker::Ledger::Schema::CallRollups.current_schema_errors
170
170
  return Check.new(:ok, "call rollups", "llm_cost_tracker_call_rollups exists") if errors.empty?
@@ -181,14 +181,14 @@ module LlmCostTracker
181
181
  Check.new(
182
182
  :warn,
183
183
  "call rollups",
184
- "cache_rollups=false but llm_cost_tracker_call_rollups exists. " \
185
- "Set config.cache_rollups = true to keep budget reads on the rollups fast path or drop the table."
184
+ "budgets.totals_source=:ledger but llm_cost_tracker_call_rollups exists. " \
185
+ "Set config.budgets.totals_source = :cache to keep budget reads on the rollups fast path or drop the table."
186
186
  )
187
187
  else
188
188
  Check.new(
189
189
  :ok,
190
190
  "call rollups",
191
- "cache_rollups=false; budget reads aggregate from llm_cost_tracker_calls directly"
191
+ "budgets.totals_source=:ledger; budget reads aggregate from llm_cost_tracker_calls directly"
192
192
  )
193
193
  end
194
194
  end
@@ -9,6 +9,10 @@ module LlmCostTracker
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace LlmCostTracker
11
11
 
12
+ initializer "llm_cost_tracker.deprecator" do |app|
13
+ app.deprecators[:llm_cost_tracker] = LlmCostTracker.deprecator
14
+ end
15
+
12
16
  initializer "llm_cost_tracker.dashboard_setup_state" do |app|
13
17
  app.reloader.to_prepare { LlmCostTracker::Dashboard::SetupState.reset! }
14
18
  end
@@ -6,20 +6,29 @@ module LlmCostTracker
6
6
  class InvalidFilterError < Error; end
7
7
 
8
8
  class BudgetExceededError < Error
9
- attr_reader :total, :budget, :budget_type, :last_event, :stage
9
+ attr_reader :total, :budget, :budget_type, :last_event, :stage, :scope
10
10
 
11
- def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend)
11
+ def initialize(budget:, budget_type:, total:, last_event: nil, stage: :post_spend, scope: nil)
12
12
  @total = total
13
13
  @budget = budget
14
14
  @budget_type = budget_type
15
15
  @last_event = last_event
16
16
  @stage = stage
17
+ @scope = scope
17
18
 
18
19
  super(
19
- "LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded: " \
20
+ "LLM #{@budget_type.to_s.tr('_', '-')} budget exceeded#{scope_suffix}: " \
20
21
  "$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
21
22
  )
22
23
  end
24
+
25
+ private
26
+
27
+ def scope_suffix
28
+ return "" unless @scope
29
+
30
+ " for #{@scope[:key]}=#{@scope[:value]}"
31
+ end
23
32
  end
24
33
 
25
34
  class UnknownPricingError < Error
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "pricing/mode"
4
+
3
5
  module LlmCostTracker
4
6
  Event = Data.define(
5
7
  :event_id,
@@ -48,7 +50,7 @@ module LlmCostTracker
48
50
  end
49
51
 
50
52
  def batch?
51
- pricing_mode.to_s.split("_").include?("batch")
53
+ Pricing::Mode.tokenize(pricing_mode.to_s).include?("batch")
52
54
  end
53
55
 
54
56
  def self.resolve_line_items(service_items)
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
13
  desc "Creates the async ingestion tables (llm_cost_tracker_ingestion_inbox_entries + _leases). " \
14
- "Required when config.ingestion = :async."
14
+ "Required when config.ingestion.mode = :async."
15
15
 
16
16
  def create_migration_file
17
17
  migration_template(
@@ -25,7 +25,7 @@ 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.ingestion = :async
28
+ config.ingestion.mode = :async
29
29
  end
30
30
 
31
31
  Without it the async inbox tables stay unused and Tracker keeps writing
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
13
  desc "Creates the optional llm_cost_tracker_call_rollups table for fast budget reads. " \
14
- "Required when config.cache_rollups = true."
14
+ "Required when config.budgets.totals_source = :cache."
15
15
 
16
16
  def create_migration_file
17
17
  migration_template(
@@ -25,7 +25,7 @@ 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.cache_rollups = true
28
+ config.budgets.totals_source = :cache
29
29
  end
30
30
 
31
31
  Without it Tracker keeps reading budget totals as live SUM aggregates over
@@ -23,7 +23,6 @@ class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_
23
23
 
24
24
  add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
25
25
  add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
26
- add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
27
26
  add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
28
27
  end
29
28
  end
@@ -74,20 +74,24 @@ class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %
74
74
  foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
75
75
  t.string :key, null: false
76
76
  t.text :value, null: false
77
+ t.decimal :total_cost, precision: 20, scale: 8
78
+ t.datetime :tracked_at
77
79
  end
78
80
 
79
81
  add_index :llm_cost_tracker_calls, :event_id, unique: true
80
82
  add_index :llm_cost_tracker_calls, :tracked_at
81
- add_index :llm_cost_tracker_calls, [:provider, :tracked_at]
82
- add_index :llm_cost_tracker_calls, [:model, :tracked_at]
83
+ add_index :llm_cost_tracker_calls, %i[provider tracked_at]
84
+ add_index :llm_cost_tracker_calls, %i[model tracked_at]
83
85
  add_index :llm_cost_tracker_calls, :cost_status
84
86
  add_index :llm_cost_tracker_calls, :provider_response_id
87
+ add_index :llm_cost_tracker_calls, :id, name: :index_llm_cost_tracker_calls_on_unpriced,
88
+ where: "total_cost IS NULL"
85
89
  add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
86
90
  add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
87
91
  if postgresql?
88
- add_index :llm_cost_tracker_call_tags, [:key, :value]
92
+ add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at]
89
93
  elsif mysql?
90
- add_index :llm_cost_tracker_call_tags, [:key, :value], length: { value: 191 }
94
+ add_index :llm_cost_tracker_call_tags, [:key, :value, :tracked_at], length: { value: 191 }
91
95
  end
92
96
  end
93
97