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
@@ -1,145 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "active_record_inbox"
4
- require_relative "active_record_period_totals"
5
- require_relative "active_record_rollups"
6
-
7
- module LlmCostTracker
8
- module Storage
9
- class ActiveRecordStore
10
- class << self
11
- def reset!
12
- ActiveRecordRollups.reset!
13
- end
14
-
15
- def save(event)
16
- model = LlmCostTracker::LlmApiCall
17
- attributes = attributes_for(event, model)
18
-
19
- model.transaction do
20
- call = model.create!(attributes)
21
- ActiveRecordRollups.increment!(event)
22
- call
23
- end
24
- end
25
-
26
- def insert_many(events)
27
- events = Array(events)
28
- return [] if events.empty?
29
-
30
- model = LlmCostTracker::LlmApiCall
31
- insertable = new_events(model, events)
32
-
33
- if insertable.any?
34
- rows = insertable.map { |event| attributes_for(event, model) }
35
- model.insert_all!(rows, **insert_options)
36
- ActiveRecordRollups.increment_many!(insertable)
37
- end
38
- events
39
- end
40
-
41
- def attributes_for(event, model = LlmCostTracker::LlmApiCall)
42
- tags = stringify_tags(event.tags || {})
43
- columns = model.columns_hash
44
-
45
- attributes = {
46
- provider: event.provider,
47
- model: event.model,
48
- input_tokens: event.input_tokens,
49
- output_tokens: event.output_tokens,
50
- total_tokens: event.total_tokens,
51
- input_cost: event.cost&.input_cost,
52
- output_cost: event.cost&.output_cost,
53
- total_cost: event.cost&.total_cost,
54
- tags: tags_for_storage(tags, model),
55
- tracked_at: event.tracked_at
56
- }
57
- attributes[:event_id] = event.event_id if columns.key?("event_id")
58
- optional_attributes(event).each do |name, value|
59
- attributes[name] = value if columns.key?(name.to_s)
60
- end
61
- attributes[:latency_ms] = event.latency_ms if columns.key?("latency_ms")
62
- attributes[:stream] = event.stream if columns.key?("stream")
63
- attributes[:usage_source] = event.usage_source if columns.key?("usage_source")
64
- attributes[:provider_response_id] = event.provider_response_id if columns.key?("provider_response_id")
65
-
66
- attributes
67
- end
68
-
69
- def monthly_total(time: Time.now.utc)
70
- period_totals(%i[monthly], time: time).fetch(:monthly)
71
- end
72
-
73
- def daily_total(time: Time.now.utc)
74
- period_totals(%i[daily], time: time).fetch(:daily)
75
- end
76
-
77
- def period_totals(periods, time: Time.now.utc)
78
- ActiveRecordPeriodTotals.call(periods, time: time)
79
- end
80
-
81
- def prune(cutoff:, batch_size:)
82
- deleted = 0
83
- loop do
84
- batch = prune_batch(cutoff, batch_size)
85
- deleted += batch
86
- break if batch < batch_size
87
- end
88
- deleted
89
- end
90
-
91
- private
92
-
93
- def new_events(model, events)
94
- return events unless model.columns_hash.key?("event_id")
95
-
96
- existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
97
- events.reject { |event| existing_ids.include?(event.event_id) }
98
- end
99
-
100
- def insert_options = { record_timestamps: true, returning: false }
101
-
102
- def prune_batch(cutoff, batch_size)
103
- LlmCostTracker::LlmApiCall.transaction do
104
- rows = LlmCostTracker::LlmApiCall
105
- .where(tracked_at: ...cutoff)
106
- .order(:id)
107
- .limit(batch_size)
108
- .lock
109
- .pluck(:id, :tracked_at, :total_cost)
110
- next 0 if rows.empty?
111
-
112
- deleted = LlmCostTracker::LlmApiCall.where(id: rows.map(&:first)).delete_all
113
- ActiveRecordRollups.decrement!(rows) if deleted.positive?
114
- deleted
115
- end
116
- end
117
-
118
- def stringify_tags(tags)
119
- tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
120
- end
121
-
122
- def tags_for_storage(tags, model)
123
- model.tags_json_column? ? tags : tags.to_json
124
- end
125
-
126
- def optional_attributes(event)
127
- {
128
- cache_read_input_tokens: event.cache_read_input_tokens,
129
- cache_write_input_tokens: event.cache_write_input_tokens,
130
- hidden_output_tokens: event.hidden_output_tokens,
131
- cache_read_input_cost: event.cost&.cache_read_input_cost,
132
- cache_write_input_cost: event.cost&.cache_write_input_cost,
133
- pricing_mode: event.pricing_mode
134
- }
135
- end
136
-
137
- def stringify_tag_value(value)
138
- return value.transform_values { |nested| stringify_tag_value(nested) } if value.is_a?(Hash)
139
-
140
- value.to_s
141
- end
142
- end
143
- end
144
- end
145
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../errors"
4
- require_relative "../logging"
5
- require_relative "active_record_backend"
6
-
7
- module LlmCostTracker
8
- module Storage
9
- class Writer
10
- class << self
11
- def save(event)
12
- ActiveRecordBackend.save(event)
13
- rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
14
- raise
15
- rescue StandardError => e
16
- handle_error(e)
17
- false
18
- end
19
-
20
- private
21
-
22
- def handle_error(error)
23
- case LlmCostTracker.configuration.storage_error_behavior
24
- when :ignore
25
- nil
26
- when :warn
27
- Logging.warn("ActiveRecord ledger write failed: #{error.class}: #{error.message}")
28
- when :raise
29
- raise StorageError, error
30
- end
31
- end
32
- end
33
- end
34
- end
35
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module StreamCapture
5
- LIMIT_BYTES = 1_048_576
6
- end
7
- end
@@ -1,199 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "monitor"
4
-
5
- require_relative "stream_capture"
6
- require_relative "value_helpers"
7
-
8
- module LlmCostTracker
9
- class StreamCollector
10
- attr_reader :provider
11
-
12
- def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
13
- @provider = provider.to_s
14
- @model = model
15
- @latency_ms = latency_ms
16
- @provider_response_id = provider_response_id
17
- @pricing_mode = pricing_mode
18
- @metadata = ValueHelpers.deep_dup(metadata || {})
19
- @events = []
20
- @captured_bytes = 0
21
- @overflowed = false
22
- @explicit_usage = nil
23
- @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
24
- @finished = false
25
- @monitor = Monitor.new
26
- end
27
-
28
- def model = @monitor.synchronize { @model }
29
-
30
- def metadata = @monitor.synchronize { ValueHelpers.deep_dup(@metadata) }
31
-
32
- def provider_response_id = @monitor.synchronize { @provider_response_id }
33
-
34
- def model=(value)
35
- @monitor.synchronize do
36
- ensure_open!
37
- @model = value
38
- end
39
- end
40
-
41
- def provider_response_id=(value)
42
- @monitor.synchronize do
43
- ensure_open!
44
- @provider_response_id = value
45
- end
46
- end
47
-
48
- def event(data, type: nil)
49
- @monitor.synchronize do
50
- ensure_open!
51
- capture_event(data, type: type) unless data.nil?
52
- end
53
- self
54
- end
55
- alias chunk event
56
-
57
- def usage(input_tokens:, output_tokens:, **extra)
58
- @monitor.synchronize do
59
- ensure_open!
60
- @explicit_usage = ValueHelpers.deep_dup(
61
- extra.merge(
62
- input_tokens: input_tokens.to_i,
63
- output_tokens: output_tokens.to_i
64
- )
65
- )
66
- end
67
- self
68
- end
69
-
70
- def finish!(errored: false)
71
- snapshot = @monitor.synchronize do
72
- return if @finished
73
-
74
- @finished = true
75
- {
76
- events: @events.dup,
77
- overflowed: @overflowed,
78
- explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
79
- model: @model,
80
- latency_ms: @latency_ms,
81
- provider_response_id: @provider_response_id,
82
- pricing_mode: @pricing_mode,
83
- metadata: ValueHelpers.deep_dup(@metadata)
84
- }
85
- end
86
-
87
- parsed = build_parsed_usage(snapshot)
88
- Tracker.record(
89
- provider: parsed.provider,
90
- model: parsed.model,
91
- input_tokens: parsed.input_tokens,
92
- output_tokens: parsed.output_tokens,
93
- latency_ms: snapshot[:latency_ms] || elapsed_ms,
94
- stream: true,
95
- usage_source: parsed.usage_source,
96
- provider_response_id: parsed.provider_response_id || snapshot[:provider_response_id],
97
- pricing_mode: snapshot[:pricing_mode],
98
- metadata: error_metadata(errored).merge(snapshot[:metadata]).merge(parsed.metadata)
99
- )
100
- end
101
-
102
- private
103
-
104
- def ensure_open!
105
- return unless @finished
106
-
107
- raise FrozenError, "can't modify finished LlmCostTracker::StreamCollector"
108
- end
109
-
110
- def build_parsed_usage(snapshot)
111
- return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
112
- return build_unknown_usage(snapshot) if snapshot[:overflowed]
113
-
114
- parsed = Parsers::Registry.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
115
- return finalize(parsed, snapshot) if parsed
116
-
117
- build_unknown_usage(snapshot)
118
- end
119
-
120
- def finalize(parsed, snapshot)
121
- parsed.with(
122
- provider: @provider,
123
- model: present_model(parsed.model) || present_model(snapshot[:model]) || ParsedUsage::UNKNOWN_MODEL
124
- )
125
- end
126
-
127
- def present_model(value)
128
- return nil if value.nil?
129
-
130
- string = value.to_s
131
- return nil if string.empty? || string == "unknown"
132
-
133
- string
134
- end
135
-
136
- def build_from_explicit_usage(snapshot)
137
- explicit = snapshot[:explicit_usage]
138
- input = explicit[:input_tokens]
139
- output = explicit[:output_tokens]
140
- extras = explicit.except(:input_tokens, :output_tokens)
141
-
142
- ParsedUsage.build(
143
- provider: @provider,
144
- model: snapshot[:model] || ParsedUsage::UNKNOWN_MODEL,
145
- input_tokens: input,
146
- output_tokens: output,
147
- stream: true,
148
- usage_source: :manual,
149
- **extras
150
- )
151
- end
152
-
153
- def build_unknown_usage(snapshot)
154
- ParsedUsage.build(
155
- provider: @provider,
156
- model: snapshot[:model] || ParsedUsage::UNKNOWN_MODEL,
157
- input_tokens: 0,
158
- output_tokens: 0,
159
- total_tokens: 0,
160
- stream: true,
161
- usage_source: :unknown
162
- )
163
- end
164
-
165
- def capture_event(data, type:)
166
- size = event_bytes(data, type)
167
- if @captured_bytes + size <= StreamCapture::LIMIT_BYTES
168
- @events << { event: type, data: ValueHelpers.deep_dup(data) }
169
- @captured_bytes += size
170
- else
171
- @overflowed = true
172
- @events.clear
173
- end
174
- end
175
-
176
- def event_bytes(data, type)
177
- type.to_s.bytesize + estimated_bytes(data) + 32
178
- end
179
-
180
- def estimated_bytes(value)
181
- case value
182
- when Hash
183
- value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
184
- when Array
185
- value.sum { |nested| estimated_bytes(nested) + 2 }
186
- when String
187
- value.bytesize + 2
188
- when Numeric, true, false, nil
189
- value.to_s.bytesize
190
- else
191
- value.to_s.bytesize + 2
192
- end
193
- end
194
-
195
- def error_metadata(errored) = errored ? { stream_errored: true } : {}
196
-
197
- def elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
198
- end
199
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module LlmCostTracker
6
- module TagAccessors
7
- def parsed_tags
8
- return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
9
-
10
- JSON.parse(tags || "{}")
11
- rescue JSON::ParserError
12
- {}
13
- end
14
- end
15
- end
@@ -1,52 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/isolated_execution_state"
4
-
5
- require_relative "value_helpers"
6
-
7
- module LlmCostTracker
8
- module TagContext
9
- KEY = :llm_cost_tracker_tags
10
-
11
- class << self
12
- def with(tags)
13
- stack = current_stack
14
- ActiveSupport::IsolatedExecutionState[KEY] = stack + [normalize(tags)]
15
- yield
16
- ensure
17
- ActiveSupport::IsolatedExecutionState[KEY] = stack
18
- end
19
-
20
- def tags
21
- config_tags.merge(scoped_tags)
22
- end
23
-
24
- def clear!
25
- ActiveSupport::IsolatedExecutionState[KEY] = []
26
- end
27
-
28
- private
29
-
30
- def config_tags
31
- normalize(resolve_default_tags)
32
- end
33
-
34
- def resolve_default_tags
35
- tags = LlmCostTracker.configuration.default_tags
36
- tags.respond_to?(:call) ? tags.call : tags
37
- end
38
-
39
- def scoped_tags
40
- current_stack.reduce({}) { |merged, tags| merged.merge(tags) }
41
- end
42
-
43
- def current_stack
44
- ActiveSupport::IsolatedExecutionState[KEY] || []
45
- end
46
-
47
- def normalize(tags)
48
- ValueHelpers.deep_dup(tags || {}).to_h
49
- end
50
- end
51
- end
52
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module TagKey
5
- PATTERN = /\A[\w.-]+\z/
6
-
7
- class << self
8
- def validate!(key, error_class: ArgumentError)
9
- key = key.to_s
10
- return key if key.match?(PATTERN)
11
-
12
- raise error_class, "invalid tag key: #{key.inspect}"
13
- end
14
- end
15
- end
16
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module LlmCostTracker
6
- module TagQuery
7
- class << self
8
- def apply(model, tags)
9
- normalized_tags = normalize_tags(tags)
10
- return model.all if normalized_tags.empty?
11
-
12
- return postgres_json_query(model, normalized_tags) if model.tags_jsonb_column?
13
- return mysql_json_query(model, normalized_tags) if model.tags_mysql_json_column?
14
-
15
- text_query(model, normalized_tags)
16
- end
17
-
18
- def normalize_tags(tags)
19
- (tags || {}).to_h.transform_keys(&:to_s).transform_values(&:to_s)
20
- end
21
-
22
- private
23
-
24
- def postgres_json_query(model, tags)
25
- model.where("tags @> ?::jsonb", tags.to_json)
26
- end
27
-
28
- def mysql_json_query(model, tags)
29
- model.where("JSON_CONTAINS(tags, ?)", tags.to_json)
30
- end
31
-
32
- def text_query(model, tags)
33
- tags.reduce(model.all) do |relation, (key, value)|
34
- relation.where("tags LIKE ? ESCAPE '\\'", "%#{model.sanitize_sql_like(json_tag_fragment(key, value))}%")
35
- end
36
- end
37
-
38
- def json_tag_fragment(key, value)
39
- JSON.generate(key => value).delete_prefix("{").delete_suffix("}")
40
- end
41
- end
42
- end
43
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module LlmCostTracker
6
- module TagSanitizer
7
- REDACTED_VALUE = "[REDACTED]"
8
-
9
- class << self
10
- def call(tags, config: LlmCostTracker.configuration)
11
- tags = (tags || {}).to_h
12
- tags.first(max_tag_count(config)).each_with_object({}) do |(key, value), sanitized|
13
- sanitized[key] = sanitized_value(key, value, config)
14
- end
15
- end
16
-
17
- private
18
-
19
- def sanitized_value(key, value, config)
20
- return REDACTED_VALUE if redacted_key?(key, config)
21
-
22
- string = value_string(value)
23
- return value if string.bytesize <= max_tag_value_bytesize(config)
24
-
25
- truncate_bytes(string, max_tag_value_bytesize(config))
26
- end
27
-
28
- def redacted_key?(key, config)
29
- normalized = normalized_key(key)
30
- redacted_keys(config).any? do |candidate|
31
- redacted_key_component?(normalized, candidate)
32
- end
33
- end
34
-
35
- def redacted_keys(config)
36
- Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
37
- end
38
-
39
- def normalized_key(key)
40
- key.to_s
41
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
42
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
43
- .downcase
44
- .gsub(/[^a-z0-9]+/, "_")
45
- .gsub(/_+/, "_")
46
- .delete_prefix("_")
47
- .delete_suffix("_")
48
- end
49
-
50
- def redacted_key_component?(key, candidate)
51
- key == candidate ||
52
- key.start_with?("#{candidate}_") ||
53
- key.end_with?("_#{candidate}") ||
54
- key.include?("_#{candidate}_")
55
- end
56
-
57
- def value_string(value)
58
- case value
59
- when Hash, Array
60
- JSON.generate(value)
61
- else
62
- value.to_s
63
- end
64
- rescue JSON::GeneratorError, TypeError
65
- value.to_s
66
- end
67
-
68
- def truncate_bytes(string, limit)
69
- string.byteslice(0, limit).to_s.encode("UTF-8", invalid: :replace, undef: :replace)
70
- end
71
-
72
- def max_tag_count(config)
73
- [config.max_tag_count.to_i, 0].max
74
- end
75
-
76
- def max_tag_value_bytesize(config)
77
- [config.max_tag_value_bytesize.to_i, 0].max
78
- end
79
- end
80
- end
81
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "active_record_adapter"
4
- require_relative "tag_key"
5
-
6
- module LlmCostTracker
7
- module TagSql
8
- class << self
9
- def value_expression(model, key, table_name:)
10
- key = TagKey.validate!(key)
11
- column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
12
-
13
- if ActiveRecordAdapter.postgresql?(model.connection)
14
- json_column = model.tags_jsonb_column? ? column : "(#{column})::jsonb"
15
- "#{json_column}->>#{model.connection.quote(key)}"
16
- elsif ActiveRecordAdapter.mysql?(model.connection)
17
- "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
18
- else
19
- ActiveRecordAdapter.ensure_supported!(model.connection)
20
- end
21
- end
22
-
23
- def value_label(value)
24
- value.nil? || value == "" ? "(untagged)" : value.to_s
25
- end
26
-
27
- private
28
-
29
- def json_path(key)
30
- "$.\"#{key}\""
31
- end
32
- end
33
- end
34
- end