llm_cost_tracker 0.14.1 → 0.15.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/object/deep_dup"
4
- require "active_support/core_ext/object/try"
5
-
6
- require_relative "sdk_payload"
7
-
8
3
  module LlmCostTracker
9
4
  module Capture
10
5
  class StreamTracker
@@ -21,23 +16,12 @@ module LlmCostTracker
21
16
  def wrap
22
17
  return @stream unless @stream
23
18
 
24
- iterator_wrapped = false
25
- if @stream.instance_variable_defined?(:@iterator)
26
- iterator = @stream.instance_variable_get(:@iterator)
27
- if iterator.respond_to?(:each)
28
- @stream.instance_variable_set(:@iterator,
29
- Enumerator.new do |yielder|
30
- each_from(iterator) { |event| yielder << event }
31
- end)
32
- iterator_wrapped = true
33
- end
34
- end
35
- each_wrapped = false
36
- if !iterator_wrapped && @stream.respond_to?(:each)
19
+ iterator = @stream.instance_variable_get(:@iterator)
20
+ if iterator.respond_to?(:each)
21
+ wrap_iterator(iterator)
22
+ elsif @stream.respond_to?(:each)
37
23
  wrap_each
38
- each_wrapped = true
39
- end
40
- unless iterator_wrapped || each_wrapped
24
+ else
41
25
  Logging.warn(
42
26
  "stream integration found no wrappable iterator on #{@stream.class} " \
43
27
  "(missing both `@iterator` ivar and `#each`); usage will not be captured"
@@ -52,30 +36,40 @@ module LlmCostTracker
52
36
 
53
37
  private
54
38
 
39
+ def wrap_iterator(iterator)
40
+ relayed = Enumerator.new { |yielder| relay(iterator.method(:each)) { |event| yielder << event } }
41
+ @stream.instance_variable_set(:@iterator, relayed)
42
+ close_through(iterator) if @stream.respond_to?(:close)
43
+ end
44
+
45
+ def close_through(iterator)
46
+ close = @stream.method(:close)
47
+ finish = method(:finish!)
48
+ @stream.define_singleton_method(:close) do
49
+ @iterator = iterator
50
+ close.call
51
+ ensure
52
+ finish.call(errored: false)
53
+ end
54
+ end
55
+
55
56
  def wrap_each
56
- tracker = self
57
57
  original_each = @stream.method(:each)
58
+ relayed_each = ->(&block) { relay(original_each, &block) }
58
59
  @stream.define_singleton_method(:each) do |&block|
59
60
  next enum_for(:each) unless block
60
61
 
61
- tracker.__send__(:each_from, original_each, &block)
62
+ relayed_each.call(&block)
62
63
  end
63
64
  end
64
65
 
65
- def each_from(iterable)
66
+ def relay(source)
66
67
  errored = false
67
- if iterable.respond_to?(:each)
68
- iterable.each do |event|
69
- capture(event)
70
- yield event
71
- end
72
- else
73
- iterable.call do |event|
74
- capture(event)
75
- yield event
76
- end
68
+ source.call do |event|
69
+ capture(event)
70
+ yield event
77
71
  end
78
- rescue Exception # rubocop:disable Lint/RescueException -- mark Interrupt/shutdown too, so it is not masked
72
+ rescue Exception # rubocop:disable Lint/RescueException
79
73
  errored = true
80
74
  raise
81
75
  ensure
@@ -83,34 +77,21 @@ module LlmCostTracker
83
77
  end
84
78
 
85
79
  def capture(event)
86
- raw_payload = event.try(:deep_to_h) || event.try(:to_h) || {}
87
- payload = SdkPayload.normalize(raw_payload)
88
- type = event.try(:type) || payload["type"]
89
- @collector.event(payload, type: type&.to_s)
80
+ data = event.to_h if event.respond_to?(:to_h)
81
+ type = event.type if event.respond_to?(:type)
82
+ @collector.event(data || {}, type: type&.to_s)
90
83
  rescue StandardError => e
91
84
  warn_capture_failure(e)
92
85
  end
93
86
 
94
87
  def warn_capture_failure(error)
95
- should_warn = @mutex.synchronize do
96
- next false if @capture_failed
97
-
98
- @capture_failed = true
99
- true
100
- end
101
- return unless should_warn
102
-
103
- Logging.warn("stream integration failed to capture event: #{error.class}: #{error.message}")
88
+ first_failure = @mutex.synchronize { !@capture_failed && (@capture_failed = true) }
89
+ Logging.warn("stream integration failed to capture event: #{error.class}: #{error.message}") if first_failure
104
90
  end
105
91
 
106
92
  def finish!(errored:)
107
- should_finish = @mutex.synchronize do
108
- next false if @finished
109
-
110
- @finished = true
111
- true
112
- end
113
- return unless should_finish && @active.call
93
+ claimed = @mutex.synchronize { !@finished && (@finished = true) }
94
+ return unless claimed && @active.call
114
95
 
115
96
  begin
116
97
  @finish.call(errored)
@@ -11,9 +11,8 @@ module LlmCostTracker
11
11
  end
12
12
 
13
13
  def report(checks = call)
14
- (["LLM Cost Tracker capture verification"] + checks.map do |check|
15
- "[#{check.status}] #{check.name}: #{check.message}"
16
- end).join("\n")
14
+ lines = checks.map { |check| "[#{check.status}] #{check.name}: #{check.message}" }
15
+ ["LLM Cost Tracker capture verification", *lines].join("\n")
17
16
  end
18
17
 
19
18
  def healthy?(checks = call)
@@ -25,7 +24,7 @@ module LlmCostTracker
25
24
  [
26
25
  enabled_check,
27
26
  *integration_checks,
28
- *storage_checks
27
+ *LlmCostTracker::Ingestion.verify
29
28
  ].compact
30
29
  end
31
30
 
@@ -49,11 +48,5 @@ module LlmCostTracker
49
48
  check.with(name: "sdk integration #{check.name}")
50
49
  end
51
50
  end
52
-
53
- def storage_checks
54
- LlmCostTracker::Ingestion.verify
55
- rescue LlmCostTracker::Error => e
56
- [Check.new(:error, "storage", e.message)]
57
- end
58
51
  end
59
52
  end
@@ -11,12 +11,18 @@ module LlmCostTracker
11
11
  UNKNOWN = "unknown"
12
12
  INCOMPLETE = [UNKNOWN, PARTIAL].freeze
13
13
 
14
+ TokenCharge = Data.define(:cost, :partial) do
15
+ def priced? = !cost.nil?
16
+
17
+ def unpriced? = cost.nil? || partial
18
+ end
19
+ private_constant :TokenCharge
20
+
14
21
  def self.unknown_pricing_sql(total_cost: "total_cost", cost_status: "cost_status")
15
- statuses = INCOMPLETE.map { |status| ActiveRecord::Base.connection.quote(status) }.join(", ")
22
+ statuses = INCOMPLETE.map { |status| ActiveRecord::Base.lease_connection.quote(status) }.join(", ")
16
23
  "#{total_cost} IS NULL OR #{cost_status} IN (#{statuses})"
17
24
  end
18
25
 
19
- # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
20
26
  def self.call(token_usage:,
21
27
  usage_source:,
22
28
  token_cost:,
@@ -25,28 +31,26 @@ module LlmCostTracker
25
31
  token_pricing_partial: false)
26
32
  return UNKNOWN if usage_source == Usage::Source::UNKNOWN
27
33
 
28
- token_billable = token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
29
- service_billable = false
30
- service_priced = false
31
- service_unpriced = false
32
- service_line_items.each do |line_item|
33
- next unless line_item.billable?
34
+ charges = billable_charges(token_usage, token_cost, token_pricing_partial, service_line_items)
35
+ return for_cost(total_cost) if charges.none?(&:unpriced?)
34
36
 
35
- service_billable = true
36
- service_priced ||= line_item.priced?
37
- service_unpriced ||= line_item.unpriced?
38
- break if service_priced && service_unpriced
39
- end
37
+ charges.any?(&:priced?) ? PARTIAL : UNKNOWN
38
+ end
39
+
40
+ def self.for_cost(cost)
41
+ return UNKNOWN if cost.nil?
40
42
 
41
- priced = (token_billable && !token_cost.nil?) || service_priced || (!token_billable && !service_billable)
42
- unpriced = (token_billable && (token_cost.nil? || token_pricing_partial)) || service_unpriced
43
- return UNKNOWN if unpriced && !priced
44
- return PARTIAL if unpriced
43
+ cost.zero? ? FREE : COMPLETE
44
+ end
45
45
 
46
- return UNKNOWN if total_cost.nil?
46
+ def self.billable_charges(token_usage, token_cost, token_pricing_partial, service_line_items)
47
+ charges = service_line_items.select(&:billable?)
48
+ return charges unless token_usage.total_tokens.to_i.positive? ||
49
+ token_usage.priced_quantities.any? { |_key, quantity| quantity.positive? }
47
50
 
48
- total_cost.zero? ? FREE : COMPLETE
51
+ charges + [TokenCharge.new(cost: token_cost, partial: token_pricing_partial)]
49
52
  end
53
+ private_class_method :billable_charges
50
54
  end
51
55
  end
52
56
  end
@@ -30,35 +30,27 @@ module LlmCostTracker
30
30
  )
31
31
 
32
32
  class LineItem
33
+ DIMENSION_FIELDS = %i[kind direction modality cache_state unit].freeze
34
+ DIMENSION_DEFAULTS = { cache_state: "none" }.freeze
35
+ AMOUNT_DEFAULTS = {
36
+ quantity: BigDecimal("0"), rate_amount: nil, rate_quantity: BigDecimal("1"), cost: nil
37
+ }.freeze
38
+ SOURCE_FIELDS = %i[pricing_basis price_key price_source].freeze
39
+ private_constant :DIMENSION_FIELDS, :DIMENSION_DEFAULTS, :AMOUNT_DEFAULTS, :SOURCE_FIELDS
40
+
33
41
  def self.build(attributes)
34
42
  attributes = attributes.to_h
35
- dimension = dimension_for(attributes)
36
- new(
37
- kind: attributes[:kind]&.to_s || dimension&.kind,
38
- direction: attributes[:direction]&.to_s || dimension&.direction,
39
- modality: attributes[:modality]&.to_s || dimension&.modality,
40
- cache_state: attributes[:cache_state]&.to_s || dimension&.cache_state || "none",
41
- quantity: decimal_or_nil(attributes[:quantity]) || BigDecimal("0"),
42
- unit: attributes[:unit]&.to_s || dimension&.unit,
43
- rate_amount: decimal_or_nil(attributes[:rate_amount]),
44
- rate_quantity: decimal_or_nil(attributes[:rate_quantity]) || BigDecimal("1"),
45
- cost: decimal_or_nil(attributes[:cost]),
46
- currency: canonical_currency(attributes[:currency]),
47
- cost_status: cost_status_for(attributes),
48
- pricing_basis: attributes[:pricing_basis]&.to_s,
49
- price_key: attributes[:price_key]&.to_s,
50
- price_source: attributes[:price_source]&.to_s,
51
- price_source_version: attributes[:price_source_version],
52
- provider_field: attributes[:provider_field],
53
- provider_item_id: attributes[:provider_item_id],
54
- details: attributes[:details] || {}
55
- )
43
+ new(**dimension_fields(attributes), **amount_fields(attributes), **source_fields(attributes))
56
44
  end
57
45
 
58
46
  def self.from_token_usage(token_usage)
59
47
  return [] unless token_usage
60
48
 
61
- token_usage.priced_quantities.filter_map do |key, quantity|
49
+ from_quantities(token_usage.priced_quantities)
50
+ end
51
+
52
+ def self.from_quantities(quantities)
53
+ quantities.filter_map do |key, quantity|
62
54
  next unless quantity.positive?
63
55
 
64
56
  dimension = Usage::Catalog.fetch(key)
@@ -73,14 +65,28 @@ module LlmCostTracker
73
65
  end
74
66
  end
75
67
 
76
- def self.cost_status_for(attributes)
77
- explicit = attributes[:cost_status]
78
- return explicit.to_s if explicit
68
+ def self.dimension_fields(attributes)
69
+ dimension = dimension_for(attributes).to_h
70
+ DIMENSION_FIELDS.to_h do |field|
71
+ [field, attributes[field]&.to_s || dimension[field] || DIMENSION_DEFAULTS[field]]
72
+ end
73
+ end
79
74
 
80
- cost = decimal_or_nil(attributes[:cost])
81
- return CostStatus::UNKNOWN if cost.nil?
75
+ def self.amount_fields(attributes)
76
+ amounts = AMOUNT_DEFAULTS.to_h { |field, default| [field, decimal_or_nil(attributes[field]) || default] }
77
+ amounts.merge(
78
+ currency: (attributes[:currency] || LlmCostTracker::DEFAULT_CURRENCY).to_s.upcase,
79
+ cost_status: (attributes[:cost_status] || CostStatus.for_cost(amounts[:cost])).to_s
80
+ )
81
+ end
82
82
 
83
- cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE
83
+ def self.source_fields(attributes)
84
+ SOURCE_FIELDS.to_h { |field| [field, attributes[field]&.to_s] }.merge(
85
+ price_source_version: attributes[:price_source_version],
86
+ provider_field: attributes[:provider_field],
87
+ provider_item_id: attributes[:provider_item_id],
88
+ details: attributes[:details] || {}
89
+ )
84
90
  end
85
91
 
86
92
  def self.dimension_for(attributes)
@@ -96,11 +102,7 @@ module LlmCostTracker
96
102
  BigDecimal(value.to_s)
97
103
  end
98
104
 
99
- def self.canonical_currency(value)
100
- (value || LlmCostTracker::DEFAULT_CURRENCY).to_s.upcase
101
- end
102
-
103
- private_class_method :cost_status_for, :dimension_for, :decimal_or_nil, :canonical_currency
105
+ private_class_method :dimension_fields, :amount_fields, :source_fields, :dimension_for, :decimal_or_nil
104
106
 
105
107
  def billable?
106
108
  quantity.positive?
@@ -135,7 +137,7 @@ module LlmCostTracker
135
137
  rate_quantity: rate.quantity,
136
138
  cost: applied_cost,
137
139
  currency: rate.currency.upcase,
138
- cost_status: applied_cost.zero? ? CostStatus::FREE : CostStatus::COMPLETE,
140
+ cost_status: CostStatus.for_cost(applied_cost),
139
141
  price_key: rate.source_key,
140
142
  price_source: rate.source,
141
143
  price_source_version: rate.source_version
@@ -9,14 +9,28 @@ module LlmCostTracker
9
9
  class Budgets < Section
10
10
  EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
11
11
  TOTALS_SOURCES = %i[ledger cache].freeze
12
- PER_TAG_WINDOWS = %i[daily weekly monthly].freeze
12
+ PER_TAG_WINDOWS = %i[daily weekly monthly total calls].freeze
13
13
  PER_TAG_OPTIONS = %i[behavior on_exceeded].freeze
14
14
 
15
- attributes :monthly, :daily, :per_call, :on_exceeded
15
+ LIMITS = %i[monthly daily per_call].freeze
16
+
16
17
  enum_attribute :exceeded_behavior, allowed: EXCEEDED_BEHAVIORS, default: :notify
17
18
  enum_attribute :totals_source, allowed: TOTALS_SOURCES, default: :ledger
18
19
 
19
- attr_reader :per_tag
20
+ attr_reader(*LIMITS, :on_exceeded, :per_tag)
21
+
22
+ LIMITS.each do |name|
23
+ define_method(:"#{name}=") do |value|
24
+ ensure_mutable!
25
+ amount = validated_amount("budgets.#{name}", value, minimum: 0) unless value.nil?
26
+ instance_variable_set(:"@#{name}", value.is_a?(Numeric) ? value : amount)
27
+ end
28
+ end
29
+
30
+ def on_exceeded=(value)
31
+ ensure_mutable!
32
+ @on_exceeded = validated_callback("budgets.on_exceeded", value)
33
+ end
20
34
 
21
35
  def initialize(owner)
22
36
  super
@@ -35,7 +49,7 @@ module LlmCostTracker
35
49
  end
36
50
 
37
51
  def finalize!
38
- @per_tag = deep_freeze(@per_tag || {})
52
+ @per_tag = deep_freeze(@per_tag)
39
53
  end
40
54
 
41
55
  private
@@ -48,19 +62,32 @@ module LlmCostTracker
48
62
  raise Error, "budgets.per_tag[#{key.inspect}] must be a hash" unless entry.is_a?(Hash)
49
63
 
50
64
  normalized = entry.to_h.transform_keys(&:to_sym)
51
- windows, options = normalized.partition { |name, _| !PER_TAG_OPTIONS.include?(name) }.map(&:to_h)
65
+ {
66
+ windows: validated_windows(key, normalized.except(*PER_TAG_OPTIONS)),
67
+ behavior: validated_behavior(key, normalized[:behavior]),
68
+ on_exceeded: validated_callback("budgets.per_tag[#{key.inspect}][:on_exceeded]", normalized[:on_exceeded])
69
+ }
70
+ end
71
+
72
+ def validated_amount(name, value, minimum: nil)
73
+ number = Float(value, exception: false)
74
+ return BigDecimal(value.to_s) if number && (minimum ? number >= minimum : number.positive?)
75
+
76
+ raise Error, "#{name} must be a #{minimum ? 'non-negative' : 'positive'} number, got #{value.inspect}"
77
+ end
78
+
79
+ def validated_callback(name, value)
80
+ return value if value.nil? || value.respond_to?(:call)
81
+
82
+ raise Error, "#{name} must respond to call, got #{value.inspect}"
83
+ end
84
+
85
+ def validated_windows(key, windows)
52
86
  if windows.empty?
53
- raise Error,
54
- "budgets.per_tag[#{key.inspect}] needs at least one of: #{PER_TAG_WINDOWS.join(', ')}"
87
+ raise Error, "budgets.per_tag[#{key.inspect}] needs at least one of: #{PER_TAG_WINDOWS.join(', ')}"
55
88
  end
56
89
 
57
- {
58
- windows: windows.to_h do |window, limit|
59
- [validated_window(key, window), validated_limit(key, window, limit)]
60
- end,
61
- behavior: validated_behavior(key, options[:behavior]),
62
- on_exceeded: options[:on_exceeded]
63
- }
90
+ windows.to_h { |window, limit| [validated_window(key, window), validated_limit(key, window, limit)] }
64
91
  end
65
92
 
66
93
  def validated_behavior(key, behavior)
@@ -81,12 +108,16 @@ module LlmCostTracker
81
108
  end
82
109
 
83
110
  def validated_limit(key, window, limit)
84
- numeric = Float(limit, exception: false)
85
- return BigDecimal(limit.to_s) if numeric&.positive?
111
+ return validated_calls(key, limit) if window == :calls
86
112
 
87
- raise Error,
88
- "budgets.per_tag[#{key.inspect}][#{window.inspect}] must be a positive number, " \
89
- "got #{limit.inspect}"
113
+ validated_amount("budgets.per_tag[#{key.inspect}][#{window.inspect}]", limit)
114
+ end
115
+
116
+ def validated_calls(key, limit)
117
+ count = Integer(limit.to_s, exception: false)
118
+ return count if count&.positive?
119
+
120
+ raise Error, "budgets.per_tag[#{key.inspect}][:calls] must be a positive integer, got #{limit.inspect}"
90
121
  end
91
122
  end
92
123
  end
@@ -8,7 +8,13 @@ module LlmCostTracker
8
8
  OPENAI_COMPATIBLE_PROVIDERS = {
9
9
  "openrouter.ai" => "openrouter",
10
10
  "api.deepseek.com" => "deepseek",
11
- "api.groq.com" => "groq"
11
+ "api.groq.com" => "groq",
12
+ "api.x.ai" => "xai",
13
+ "us.api.x.ai" => "xai",
14
+ "api.mistral.ai" => "mistral",
15
+ "api.eu.mistral.ai" => "mistral",
16
+ "api.us.mistral.ai" => "mistral",
17
+ "api.perplexity.ai" => "perplexity"
12
18
  }.freeze
13
19
 
14
20
  attributes :request_stream_usage
@@ -18,12 +18,12 @@ module LlmCostTracker
18
18
  deep_freeze(key)
19
19
  deep_freeze(nested_value)
20
20
  end
21
- value.frozen? ? value : value.freeze
21
+ value.freeze
22
22
  when Array, Set
23
23
  value.each { |nested_value| deep_freeze(nested_value) }
24
- value.frozen? ? value : value.freeze
24
+ value.freeze
25
25
  when String
26
- value.frozen? ? value : value.freeze
26
+ value.freeze
27
27
  else
28
28
  value
29
29
  end
@@ -29,7 +29,7 @@ module LlmCostTracker
29
29
  end
30
30
 
31
31
  def finalize!
32
- @overrides = deep_freeze(@overrides || {})
32
+ @overrides = deep_freeze(@overrides)
33
33
  end
34
34
  end
35
35
  end
@@ -18,8 +18,6 @@ module LlmCostTracker
18
18
  budgets: Budgets, capture: Capture, ingestion: Ingestion, pricing: Pricing, tags: Tags
19
19
  }.freeze
20
20
 
21
- SCALAR_ATTRIBUTES = %i[enabled].freeze
22
-
23
21
  DEPRECATED_OPTIONS = {
24
22
  monthly_budget: { to: %i[budgets monthly] },
25
23
  daily_budget: { to: %i[budgets daily] },
@@ -46,7 +44,7 @@ module LlmCostTracker
46
44
  log_level: { to: nil, note: "LlmCostTracker logs through Rails.logger, which owns the level" }
47
45
  }.freeze
48
46
 
49
- attr_reader(*SCALAR_ATTRIBUTES, *SECTIONS.keys, :instrumented_integrations)
47
+ attr_reader(:enabled, *SECTIONS.keys, :instrumented_integrations)
50
48
 
51
49
  def initialize
52
50
  SECTIONS.each { |name, klass| instance_variable_set(:"@#{name}", klass.new(self)) }
@@ -67,11 +65,9 @@ module LlmCostTracker
67
65
  @instrumented_integrations.include?(name)
68
66
  end
69
67
 
70
- SCALAR_ATTRIBUTES.each do |name|
71
- define_method(:"#{name}=") do |value|
72
- ensure_mutable!
73
- instance_variable_set(:"@#{name}", value)
74
- end
68
+ def enabled=(value)
69
+ ensure_mutable!
70
+ @enabled = value
75
71
  end
76
72
 
77
73
  DEPRECATED_OPTIONS.each do |old_name, spec|
@@ -101,7 +97,7 @@ module LlmCostTracker
101
97
 
102
98
  def finalize!
103
99
  SECTIONS.each_key { |name| public_send(name).finalize! }
104
- @instrumented_integrations = deep_freeze(@instrumented_integrations || Set.new)
100
+ @instrumented_integrations = deep_freeze(@instrumented_integrations)
105
101
  @finalized = true
106
102
  end
107
103
 
@@ -24,7 +24,17 @@ module LlmCostTracker
24
24
  private
25
25
 
26
26
  def async_ok
27
- Check.new(:ok, "async ingestion", "inbox and ingestion lease tables are on the current schema")
27
+ quarantined = LlmCostTracker::Ingestion::InboxEntry.quarantined.count
28
+ if quarantined.zero?
29
+ return Check.new(:ok, "async ingestion", "inbox and ingestion lease tables are on the current schema")
30
+ end
31
+
32
+ Check.new(
33
+ :warn,
34
+ "async ingestion",
35
+ "#{quarantined} inbox row(s) are quarantined, so their spend is not in the ledger; " \
36
+ "requeue them as docs/operations.md shows once the cause is fixed"
37
+ )
28
38
  end
29
39
 
30
40
  def inline_check
@@ -38,11 +48,19 @@ module LlmCostTracker
38
48
  Check.new(
39
49
  :warn,
40
50
  "inline ingestion",
41
- "config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}. " \
51
+ "config.ingestion.mode = :inline but found unused async ingestion tables: #{leftovers.join(', ')}" \
52
+ "#{undrained_note(leftovers)}. " \
42
53
  "Set config.ingestion.mode = :async to keep the inbox path or drop the tables."
43
54
  )
44
55
  end
45
56
 
57
+ def undrained_note(leftovers)
58
+ return unless leftovers.include?(LlmCostTracker::Ingestion::InboxEntry.table_name)
59
+
60
+ rows = LlmCostTracker::Ingestion::InboxEntry.count
61
+ "; #{rows} inbox row(s) never reached the ledger, so drain them in :async mode first" if rows.positive?
62
+ end
63
+
46
64
  def inline_leftover_tables
47
65
  async_tables.select { |table| Probe.table_exists?(table) }
48
66
  end
@@ -15,29 +15,9 @@ module LlmCostTracker
15
15
  return bundled_check unless path
16
16
  return Check.new(:error, "prices", "#{path} does not exist; #{REFRESH_COMMAND}") unless File.exist?(path)
17
17
 
18
- count = LlmCostTracker::Pricing::Registry.file_prices(path).size
19
- metadata = LlmCostTracker::Pricing::Registry.file_metadata(path)
20
- updated_at = metadata["updated_at"] || metadata[:updated_at]
21
- return configured_check(:warn, path, count, "metadata.updated_at missing; #{REFRESH_COMMAND}") unless updated_at
22
-
23
- age_days = (Date.today - Date.iso8601(updated_at.to_s)).to_i
24
- if age_days > STALE_AFTER_DAYS
25
- return configured_check(
26
- :warn,
27
- path,
28
- count,
29
- "updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days; #{REFRESH_COMMAND}"
30
- )
31
- end
32
-
33
- configured_check(:ok, path, count, "updated_at=#{updated_at}")
34
- rescue Date::Error
35
- configured_check(
36
- :warn,
37
- path,
38
- count,
39
- "metadata.updated_at=#{updated_at.inspect} is invalid; #{REFRESH_COMMAND}"
40
- )
18
+ count = Pricing::Registry.file_prices(path).size
19
+ status, freshness = freshness(Pricing::Registry.file_metadata(path))
20
+ Check.new(status, "prices", "loaded #{count} models from #{path}; #{freshness}")
41
21
  rescue LlmCostTracker::Error => e
42
22
  Check.new(:error, "prices", e.message)
43
23
  end
@@ -45,16 +25,35 @@ module LlmCostTracker
45
25
  private
46
26
 
47
27
  def bundled_check
48
- updated_at = LlmCostTracker::Pricing::Registry.metadata.fetch("updated_at", "unknown")
28
+ updated_at = Pricing::Registry.metadata.fetch("updated_at", "unknown")
49
29
  Check.new(
50
30
  :warn,
51
31
  "prices",
52
- "using bundled prices updated_at=#{updated_at}; commit a prices_file for production releases"
32
+ "using bundled prices updated_at=#{updated_at}; " \
33
+ "commit a prices_file (config.pricing.file) for production releases"
53
34
  )
54
35
  end
55
36
 
56
- def configured_check(status, path, count, freshness)
57
- Check.new(status, "prices", "loaded #{count} models from #{path}; #{freshness}")
37
+ def freshness(metadata)
38
+ updated_at = metadata["updated_at"] || metadata[:updated_at]
39
+ return [:warn, "metadata.updated_at missing; #{REFRESH_COMMAND}"] unless updated_at
40
+
41
+ reason = staleness(updated_at)
42
+ reason ? [:warn, "#{reason}; #{REFRESH_COMMAND}"] : [:ok, "updated_at=#{updated_at}"]
43
+ rescue Date::Error
44
+ [:warn, "metadata.updated_at=#{updated_at.inspect} is invalid; #{REFRESH_COMMAND}"]
45
+ end
46
+
47
+ def staleness(updated_at)
48
+ file_date = Date.iso8601(updated_at.to_s)
49
+ if (Date.today - file_date).to_i > STALE_AFTER_DAYS
50
+ return "updated_at=#{updated_at} is older than #{STALE_AFTER_DAYS} days"
51
+ end
52
+
53
+ bundled_at = Pricing::Registry.metadata["updated_at"]
54
+ return unless bundled_at && file_date < Date.iso8601(bundled_at.to_s)
55
+
56
+ "updated_at=#{updated_at} is older than the bundled prices (#{bundled_at})"
58
57
  end
59
58
  end
60
59
  end
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  class Doctor
7
7
  module Probe
8
8
  def self.table_exists?(name)
9
- LlmCostTracker::Call.connection.data_source_exists?(name)
9
+ LlmCostTracker::Call.lease_connection.data_source_exists?(name)
10
10
  rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError,
11
11
  ActiveRecord::ConnectionFailed, ActiveRecord::StatementInvalid
12
12
  false
@@ -22,8 +22,7 @@ module LlmCostTracker
22
22
  Check.new(
23
23
  :error,
24
24
  @name,
25
- "current schema required; #{errors.join('; ')}; " \
26
- "run bin/rails generate llm_cost_tracker:install && bin/rails db:migrate"
25
+ "current schema required; #{errors.join('; ')}; see docs/upgrading.md"
27
26
  )
28
27
  end
29
28
  end