llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/object/deep_dup"
5
+
6
+ require_relative "stream"
7
+
8
+ module LlmCostTracker
9
+ module Capture
10
+ class StreamCollector
11
+ attr_reader :provider
12
+
13
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {},
14
+ context_tags: nil)
15
+ @provider = provider.to_s
16
+ @model = model
17
+ @latency_ms = latency_ms
18
+ @provider_response_id = provider_response_id
19
+ @pricing_mode = pricing_mode
20
+ @metadata = (metadata || {}).deep_dup
21
+ @context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
22
+ @events = []
23
+ @captured_bytes = 0
24
+ @overflowed = false
25
+ @explicit_usage = nil
26
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ @finished = false
28
+ @mutex = Mutex.new
29
+ end
30
+
31
+ def model
32
+ @mutex.synchronize { @model }
33
+ end
34
+
35
+ def metadata
36
+ @mutex.synchronize { @metadata.deep_dup }
37
+ end
38
+
39
+ def provider_response_id
40
+ @mutex.synchronize { @provider_response_id }
41
+ end
42
+
43
+ def model=(value)
44
+ @mutex.synchronize do
45
+ ensure_open!
46
+ @model = value
47
+ end
48
+ end
49
+
50
+ def provider_response_id=(value)
51
+ @mutex.synchronize do
52
+ ensure_open!
53
+ @provider_response_id = value
54
+ end
55
+ end
56
+
57
+ def event(data, type: nil)
58
+ @mutex.synchronize do
59
+ ensure_open!
60
+ capture_event(data, type: type) unless data.nil?
61
+ end
62
+ self
63
+ end
64
+
65
+ def usage(input_tokens:, output_tokens:, **extra)
66
+ @mutex.synchronize do
67
+ ensure_open!
68
+ @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
69
+ @explicit_usage = TokenUsage.from_hash(extra.merge(
70
+ input_tokens: input_tokens.to_i,
71
+ output_tokens: output_tokens.to_i
72
+ ))
73
+ end
74
+ self
75
+ end
76
+
77
+ def finish!(errored: false)
78
+ snapshot = @mutex.synchronize do
79
+ return if @finished
80
+
81
+ @finished = true
82
+ {
83
+ events: @events.dup,
84
+ overflowed: @overflowed,
85
+ explicit_usage: @explicit_usage,
86
+ model: @model,
87
+ latency_ms: @latency_ms,
88
+ provider_response_id: @provider_response_id,
89
+ pricing_mode: @pricing_mode,
90
+ metadata: @metadata.deep_dup,
91
+ context_tags: @context_tags.deep_dup
92
+ }
93
+ end
94
+
95
+ capture = build_usage_capture(snapshot)
96
+ provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
97
+ capture = capture.with(provider_response_id: provider_response_id)
98
+
99
+ Tracker.record(
100
+ capture: capture,
101
+ latency_ms: snapshot[:latency_ms] ||
102
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
103
+ pricing_mode: snapshot[:pricing_mode],
104
+ metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
105
+ context_tags: snapshot[:context_tags]
106
+ )
107
+ end
108
+
109
+ private
110
+
111
+ def ensure_open!
112
+ return unless @finished
113
+
114
+ raise FrozenError, "can't modify finished LlmCostTracker::Capture::StreamCollector"
115
+ end
116
+
117
+ def build_usage_capture(snapshot)
118
+ return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
119
+ return build_unknown_usage(snapshot) if snapshot[:overflowed]
120
+
121
+ capture = Parsers.find_for_provider(@provider)&.parse_stream(
122
+ response_status: 200,
123
+ events: snapshot[:events]
124
+ )
125
+ if capture
126
+ model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
127
+ return capture.with(provider: @provider, model: model)
128
+ end
129
+
130
+ build_unknown_usage(snapshot)
131
+ end
132
+
133
+ def present_model(value)
134
+ return nil if value.nil?
135
+
136
+ string = value.to_s.presence
137
+ return nil if string.nil? || string == "unknown"
138
+
139
+ string
140
+ end
141
+
142
+ def build_from_explicit_usage(snapshot)
143
+ UsageCapture.build(
144
+ provider: @provider,
145
+ model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
146
+ token_usage: snapshot[:explicit_usage],
147
+ stream: true,
148
+ usage_source: :manual
149
+ )
150
+ end
151
+
152
+ def build_unknown_usage(snapshot)
153
+ UsageCapture.build(
154
+ provider: @provider,
155
+ model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
156
+ token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
157
+ stream: true,
158
+ usage_source: :unknown
159
+ )
160
+ end
161
+
162
+ def capture_event(data, type:)
163
+ size = type.to_s.bytesize + estimated_bytes(data) + 32
164
+ if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
165
+ @events << { event: type, data: data.deep_dup }
166
+ @captured_bytes += size
167
+ else
168
+ @overflowed = true
169
+ @events.clear
170
+ end
171
+ end
172
+
173
+ def estimated_bytes(value)
174
+ case value
175
+ when Hash
176
+ value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
177
+ when Array
178
+ value.sum { |nested| estimated_bytes(nested) + 2 }
179
+ when String
180
+ value.bytesize + 2
181
+ when Numeric, true, false, nil
182
+ value.to_s.bytesize
183
+ else
184
+ value.to_s.bytesize + 2
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -1,31 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require "active_support/core_ext/object/try"
4
5
 
5
6
  require_relative "../logging"
6
- require_relative "../stream_collector"
7
- require_relative "../value_helpers"
8
- require_relative "object_reader"
9
7
 
10
8
  module LlmCostTracker
11
- module Integrations
9
+ module Capture
12
10
  class StreamTracker
13
- def self.wrap(stream, collector:, active:, finish: nil) = new(stream, collector, active, finish).wrap
14
-
15
- def initialize(stream, collector, active, finish)
11
+ def initialize(stream:, collector:, active:, finish: nil)
16
12
  @stream = stream
17
13
  @collector = collector
18
14
  @active = active
19
15
  @finish = finish || proc { |errored:| @collector.finish!(errored: errored) }
20
16
  @finished = false
21
17
  @capture_failed = false
22
- @monitor = Monitor.new
18
+ @mutex = Mutex.new
23
19
  end
24
20
 
25
21
  def wrap
26
22
  return @stream unless @stream
27
23
 
28
- iterator_wrapped = @stream.instance_variable_defined?(:@iterator) && wrap_iterator?
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, Enumerator.new do |yielder|
29
+ each_from(iterator) { |event| yielder << event }
30
+ end)
31
+ iterator_wrapped = true
32
+ end
33
+ end
29
34
  wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
30
35
 
31
36
  @stream
@@ -36,14 +41,6 @@ module LlmCostTracker
36
41
 
37
42
  private
38
43
 
39
- def wrap_iterator?
40
- iterator = @stream.instance_variable_get(:@iterator)
41
- return false unless iterator.respond_to?(:each)
42
-
43
- @stream.instance_variable_set(:@iterator, tracked_iterator(iterator))
44
- true
45
- end
46
-
47
44
  def wrap_each
48
45
  tracker = self
49
46
  original_each = @stream.method(:each)
@@ -54,17 +51,18 @@ module LlmCostTracker
54
51
  end
55
52
  end
56
53
 
57
- def tracked_iterator(iterator)
58
- Enumerator.new do |yielder|
59
- each_from(iterator) { |event| yielder << event }
60
- end
61
- end
62
-
63
54
  def each_from(iterable)
64
55
  errored = false
65
- iterate(iterable) do |event|
66
- capture(event)
67
- yield event
56
+ if iterable.respond_to?(:each)
57
+ iterable.each do |event|
58
+ capture(event)
59
+ yield event
60
+ end
61
+ else
62
+ iterable.call do |event|
63
+ capture(event)
64
+ yield event
65
+ end
68
66
  end
69
67
  rescue StandardError
70
68
  errored = true
@@ -73,41 +71,17 @@ module LlmCostTracker
73
71
  finish!(errored: errored)
74
72
  end
75
73
 
76
- def iterate(iterable, &)
77
- if iterable.respond_to?(:each)
78
- iterable.each(&)
79
- else
80
- iterable.call(&)
81
- end
82
- end
83
-
84
74
  def capture(event)
85
- payload = normalize(event_payload(event))
86
- @collector.event(payload, type: event_type(event, payload))
87
- rescue StandardError => e
88
- warn_capture_failure(e)
89
- end
90
-
91
- def event_payload(event)
92
- if event.respond_to?(:deep_to_h)
93
- event.deep_to_h
94
- elsif event.respond_to?(:to_h)
95
- event.to_h
96
- else
97
- event_attributes(event)
98
- end
99
- end
100
-
101
- def event_attributes(event)
102
- %i[type id model usage response message].each_with_object({}) do |key, attributes|
103
- value = ObjectReader.read(event, key)
75
+ raw_payload = event.try(:deep_to_h) || event.try(:to_h)
76
+ raw_payload ||= %i[type id model usage response message].each_with_object({}) do |key, attributes|
77
+ value = event.try(key)
104
78
  attributes[key] = value unless value.nil?
105
79
  end
106
- end
107
-
108
- def event_type(event, payload)
109
- value = ObjectReader.first(event, :type) || payload["type"]
110
- value&.to_s
80
+ payload = normalize(raw_payload)
81
+ type = event.try(:type) || payload["type"]
82
+ @collector.event(payload, type: type&.to_s)
83
+ rescue StandardError => e
84
+ warn_capture_failure(e)
111
85
  end
112
86
 
113
87
  def normalize(value)
@@ -123,23 +97,17 @@ module LlmCostTracker
123
97
  when NilClass
124
98
  nil
125
99
  else
126
- converted = object_hash(value)
127
- converted ? normalize(converted) : ValueHelpers.deep_dup(value)
128
- end
129
- end
130
-
131
- def object_hash(value)
132
- if value.respond_to?(:deep_to_h)
133
- value.deep_to_h
134
- elsif value.respond_to?(:to_h)
135
- value.to_h
100
+ converted = begin
101
+ value.try(:deep_to_h) || value.try(:to_h)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ converted ? normalize(converted) : value.deep_dup
136
106
  end
137
- rescue StandardError
138
- nil
139
107
  end
140
108
 
141
109
  def warn_capture_failure(error)
142
- should_warn = @monitor.synchronize do
110
+ should_warn = @mutex.synchronize do
143
111
  next false if @capture_failed
144
112
 
145
113
  @capture_failed = true
@@ -151,7 +119,7 @@ module LlmCostTracker
151
119
  end
152
120
 
153
121
  def finish!(errored:)
154
- should_finish = @monitor.synchronize do
122
+ should_finish = @mutex.synchronize do
155
123
  next false if @finished
156
124
 
157
125
  @finished = true
@@ -16,7 +16,7 @@ module LlmCostTracker
16
16
  def normalize_instrumentation_names(names)
17
17
  names.flatten.flat_map do |name|
18
18
  key = name.to_sym
19
- next available_instrumentation_names if key == :all
19
+ next Integrations.names if key == :all
20
20
 
21
21
  validate_instrumentation_name!(key)
22
22
  key
@@ -24,14 +24,10 @@ module LlmCostTracker
24
24
  end
25
25
 
26
26
  def validate_instrumentation_name!(name)
27
- return if available_instrumentation_names.include?(name)
27
+ return if Integrations.names.include?(name)
28
28
 
29
29
  raise Error, "Unknown integration: #{name.inspect}. " \
30
- "Use one of: #{available_instrumentation_names.join(', ')}"
31
- end
32
-
33
- def available_instrumentation_names
34
- Integrations::Registry.names
30
+ "Use one of: #{Integrations.names.join(', ')}"
35
31
  end
36
32
  end
37
33
  end
@@ -1,24 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
- require_relative "tag_key"
5
- require_relative "value_helpers"
4
+ require_relative "tags/key"
6
5
  require_relative "configuration/instrumentation"
7
6
 
8
7
  module LlmCostTracker
9
8
  class Configuration
10
9
  include ConfigurationInstrumentation
11
10
 
12
- OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
11
+ OPENAI_COMPATIBLE_PROVIDERS = {
12
+ "openrouter.ai" => "openrouter",
13
+ "api.deepseek.com" => "deepseek",
14
+ "api.groq.com" => "groq"
15
+ }.freeze
13
16
 
14
17
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
15
- STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
16
18
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
17
19
  SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget log_level
18
20
  prices_file max_tag_count max_tag_value_bytesize].freeze
19
21
  SHARED_ENUM_ATTRIBUTES = {
20
22
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
21
- storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
22
23
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
23
24
  }.freeze
24
25
  DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
@@ -31,7 +32,6 @@ module LlmCostTracker
31
32
  :instrumented_integrations,
32
33
  :report_tag_breakdowns,
33
34
  :redacted_tag_keys,
34
- :storage_error_behavior,
35
35
  :unknown_pricing_behavior,
36
36
  :openai_compatible_providers
37
37
  )
@@ -44,7 +44,6 @@ module LlmCostTracker
44
44
  @daily_budget = nil
45
45
  @per_call_budget = nil
46
46
  self.budget_exceeded_behavior = :notify
47
- self.storage_error_behavior = :warn
48
47
  self.unknown_pricing_behavior = :warn
49
48
  @log_level = :info
50
49
  @prices_file = nil
@@ -75,7 +74,7 @@ module LlmCostTracker
75
74
 
76
75
  def report_tag_breakdowns=(value)
77
76
  ensure_shared_configuration_mutable!
78
- @report_tag_breakdowns = normalize_report_tag_breakdowns(value)
77
+ @report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
79
78
  end
80
79
 
81
80
  def redacted_tag_keys=(value)
@@ -98,34 +97,18 @@ module LlmCostTracker
98
97
  end
99
98
 
100
99
  def finalize!
101
- @default_tags = ValueHelpers.deep_freeze(@default_tags || {})
102
- @pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
103
- @instrumented_integrations = ValueHelpers.deep_freeze(@instrumented_integrations || [])
104
- @report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
105
- @redacted_tag_keys = ValueHelpers.deep_freeze(Array(@redacted_tag_keys))
106
- @openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
100
+ @default_tags = deep_freeze(@default_tags || {})
101
+ @pricing_overrides = deep_freeze(@pricing_overrides || {})
102
+ @instrumented_integrations = deep_freeze(@instrumented_integrations || [])
103
+ @report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
104
+ @redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
105
+ @openai_compatible_providers = deep_freeze(@openai_compatible_providers || {})
107
106
  @finalized = true
108
107
  self
109
108
  end
110
109
 
111
- def finalized? = @finalized
112
-
113
- def dup_for_configuration
114
- copy = dup
115
- copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
116
- copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
117
- copy.instance_variable_set(
118
- :@instrumented_integrations,
119
- ValueHelpers.deep_dup(@instrumented_integrations || [])
120
- )
121
- copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
122
- copy.instance_variable_set(:@redacted_tag_keys, ValueHelpers.deep_dup(@redacted_tag_keys || []))
123
- copy.instance_variable_set(
124
- :@openai_compatible_providers,
125
- ValueHelpers.deep_dup(@openai_compatible_providers || {})
126
- )
127
- copy.instance_variable_set(:@finalized, false)
128
- copy
110
+ def finalized?
111
+ @finalized
129
112
  end
130
113
 
131
114
  private
@@ -144,14 +127,28 @@ module LlmCostTracker
144
127
  end
145
128
  end
146
129
 
147
- def normalize_report_tag_breakdowns(value)
148
- Array(value).map { |key| TagKey.validate!(key, error_class: Error) }
149
- end
150
-
151
130
  def ensure_shared_configuration_mutable!
152
131
  return unless finalized?
153
132
 
154
133
  raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
155
134
  end
135
+
136
+ def deep_freeze(value)
137
+ case value
138
+ when Hash
139
+ value.each do |key, nested_value|
140
+ deep_freeze(key)
141
+ deep_freeze(nested_value)
142
+ end
143
+ value.frozen? ? value : value.freeze
144
+ when Array
145
+ value.each { |nested_value| deep_freeze(nested_value) }
146
+ value.frozen? ? value : value.freeze
147
+ when String
148
+ value.frozen? ? value : value.freeze
149
+ else
150
+ value
151
+ end
152
+ end
156
153
  end
157
154
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "../ingestion"
5
+
6
+ module LlmCostTracker
7
+ class Doctor
8
+ class CaptureVerifier
9
+ class << self
10
+ def call
11
+ new.checks
12
+ end
13
+
14
+ def report(checks = call)
15
+ (["LLM Cost Tracker capture verification"] + checks.map do |check|
16
+ "[#{check.status}] #{check.name}: #{check.message}"
17
+ end).join("\n")
18
+ end
19
+
20
+ def healthy?(checks = call)
21
+ checks.none? { |check| check.status == :error }
22
+ end
23
+ end
24
+
25
+ def checks
26
+ [
27
+ enabled_check,
28
+ *integration_checks,
29
+ *storage_checks
30
+ ].compact
31
+ end
32
+
33
+ private
34
+
35
+ def enabled_check
36
+ return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
37
+
38
+ Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
39
+ end
40
+
41
+ def integration_checks
42
+ enabled = LlmCostTracker.configuration.instrumented_integrations
43
+ if enabled.empty?
44
+ return [
45
+ Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
46
+ ]
47
+ end
48
+
49
+ LlmCostTracker::Integrations.checks.map do |check|
50
+ Check.new(check.status, "sdk integration #{check.name}", check.message)
51
+ end
52
+ end
53
+
54
+ def storage_checks
55
+ LlmCostTracker::Ingestion.verify
56
+ rescue LlmCostTracker::Error => e
57
+ [Check.new(:error, "storage", e.message)]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Doctor
5
+ Check = Data.define(:status, :name, :message)
6
+ end
7
+ end