llm_cost_tracker 0.6.1 → 0.7.1

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +13 -12
  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 -37
  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/config/routes.rb +1 -1
  43. data/lib/llm_cost_tracker/assets.rb +0 -6
  44. data/lib/llm_cost_tracker/budget.rb +10 -24
  45. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
  47. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  48. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  49. data/lib/llm_cost_tracker/configuration.rb +30 -45
  50. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  51. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -61
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  54. data/lib/llm_cost_tracker/doctor.rb +66 -79
  55. data/lib/llm_cost_tracker/engine.rb +0 -3
  56. data/lib/llm_cost_tracker/errors.rb +4 -15
  57. data/lib/llm_cost_tracker/event.rb +6 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +15 -14
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -21
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  67. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  69. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  70. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  71. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
  73. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  74. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  76. data/lib/llm_cost_tracker/integrations.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  78. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  79. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  80. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  81. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  82. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  84. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  88. data/lib/llm_cost_tracker/ledger.rb +13 -0
  89. data/lib/llm_cost_tracker/logging.rb +3 -6
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  92. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  94. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  95. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  96. data/lib/llm_cost_tracker/parsers.rb +20 -0
  97. data/lib/llm_cost_tracker/prices.json +52 -11
  98. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  99. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  100. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  101. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  102. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  103. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  106. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  107. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  108. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  109. data/lib/llm_cost_tracker/pricing.rb +33 -32
  110. data/lib/llm_cost_tracker/railtie.rb +7 -10
  111. data/lib/llm_cost_tracker/report/data.rb +72 -0
  112. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  113. data/lib/llm_cost_tracker/report.rb +8 -10
  114. data/lib/llm_cost_tracker/retention.rb +27 -10
  115. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  116. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  117. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  119. data/lib/llm_cost_tracker/tracker.rb +38 -70
  120. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +56 -90
  123. data/lib/tasks/llm_cost_tracker.rake +18 -13
  124. metadata +85 -99
  125. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  126. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  127. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -49
  128. data/lib/llm_cost_tracker/capture_verifier.rb +0 -71
  129. data/lib/llm_cost_tracker/configuration/storage_backend.rb +0 -26
  130. data/lib/llm_cost_tracker/cost.rb +0 -12
  131. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  132. data/lib/llm_cost_tracker/engine_compatibility.rb +0 -15
  133. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  136. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  137. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  138. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  139. data/lib/llm_cost_tracker/integrations/registry.rb +0 -73
  140. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  141. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  142. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  143. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  144. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  145. data/lib/llm_cost_tracker/period_grouping.rb +0 -69
  146. data/lib/llm_cost_tracker/period_total.rb +0 -9
  147. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  148. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  149. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  150. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  151. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  152. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  153. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  154. data/lib/llm_cost_tracker/report_data.rb +0 -94
  155. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  156. data/lib/llm_cost_tracker/request_url.rb +0 -20
  157. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -166
  158. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  159. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -165
  160. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  161. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  162. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  163. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  164. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  165. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -32
  166. data/lib/llm_cost_tracker/storage/dispatcher.rb +0 -45
  167. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -38
  168. data/lib/llm_cost_tracker/storage/registry.rb +0 -63
  169. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  170. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  171. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  172. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  173. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  174. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  175. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  176. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  177. data/lib/llm_cost_tracker/tags_column.rb +0 -103
  178. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  179. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  180. 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,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "registry"
4
-
5
- module LlmCostTracker
6
- module Storage
7
- class CustomBackend
8
- class << self
9
- def save(event)
10
- result = LlmCostTracker.configuration.custom_storage&.call(event)
11
- result == false ? false : event
12
- end
13
-
14
- def verify
15
- if LlmCostTracker.configuration.custom_storage.respond_to?(:call)
16
- return [
17
- VerificationResult.new(
18
- :ok,
19
- "storage",
20
- "custom storage callable configured; external sink was not invoked"
21
- )
22
- ]
23
- end
24
-
25
- [
26
- VerificationResult.new(:error, "storage", "custom storage backend requires config.custom_storage")
27
- ]
28
- end
29
- end
30
- end
31
- end
32
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../logging"
4
- require_relative "registry"
5
- require_relative "active_record_backend"
6
- require_relative "custom_backend"
7
- require_relative "log_backend"
8
-
9
- module LlmCostTracker
10
- module Storage
11
- class Dispatcher
12
- class << self
13
- def save(event)
14
- backend.save(event)
15
- rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
16
- raise
17
- rescue StandardError => e
18
- handle_error(e)
19
- false
20
- end
21
-
22
- private
23
-
24
- def backend
25
- Registry.fetch(LlmCostTracker.configuration.storage_backend)
26
- end
27
-
28
- def handle_error(error)
29
- case LlmCostTracker.configuration.storage_error_behavior
30
- when :ignore
31
- nil
32
- when :warn
33
- Logging.warn("Storage failed; tracking event was not persisted: #{error.class}: #{error.message}")
34
- when :raise
35
- raise StorageError, error
36
- end
37
- end
38
- end
39
- end
40
-
41
- Registry.register(:log, LogBackend)
42
- Registry.register(:active_record, ActiveRecordBackend)
43
- Registry.register(:custom, CustomBackend)
44
- end
45
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../logging"
4
- require_relative "registry"
5
-
6
- module LlmCostTracker
7
- module Storage
8
- class LogBackend
9
- class << self
10
- def save(event)
11
- config = LlmCostTracker.configuration
12
- message = "#{event.provider}/#{event.model} " \
13
- "tokens=#{event.total_tokens} " \
14
- "cost=#{cost_label(event)}"
15
- message += " latency=#{event.latency_ms}ms" if event.latency_ms
16
- message += " stream=#{event.stream}" if event.stream
17
- message += " source=#{event.usage_source}" if event.usage_source
18
- message += " tags=#{event.tags}" unless event.tags.empty?
19
-
20
- Logging.log(config.log_level, message)
21
- event
22
- end
23
-
24
- def verify
25
- [
26
- VerificationResult.new(:ok, "storage", "log backend configured; capture writes to logs only")
27
- ]
28
- end
29
-
30
- private
31
-
32
- def cost_label(event)
33
- event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
34
- end
35
- end
36
- end
37
- end
38
- end
@@ -1,63 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "monitor"
4
-
5
- require_relative "../errors"
6
-
7
- module LlmCostTracker
8
- module Storage
9
- VerificationResult = Data.define(:status, :name, :message)
10
-
11
- module Registry
12
- MUTEX = Monitor.new
13
-
14
- class << self
15
- def register(name, backend)
16
- name = normalize_name(name)
17
- validate_backend!(backend)
18
- MUTEX.synchronize { @backends = backends.merge(name => backend).freeze }
19
- backend
20
- end
21
-
22
- def fetch(name)
23
- key = normalize_name(name)
24
- backends.fetch(key) do
25
- raise Error, "Unknown storage_backend: #{key.inspect}. Use one of: #{names.join(', ')}"
26
- end
27
- end
28
-
29
- def registered?(name)
30
- backends.key?(normalize_name(name))
31
- end
32
-
33
- def names
34
- backends.keys
35
- end
36
-
37
- private
38
-
39
- def backends
40
- @backends || MUTEX.synchronize { @backends ||= {}.freeze }
41
- end
42
-
43
- def normalize_name(name)
44
- name.to_sym
45
- end
46
-
47
- def validate_backend!(backend)
48
- return if backend.respond_to?(:save)
49
-
50
- raise ArgumentError, "storage backend must respond to save"
51
- end
52
- end
53
- end
54
-
55
- def self.register(name, backend)
56
- Registry.register(name, backend)
57
- end
58
-
59
- def self.backends
60
- Registry.names
61
- end
62
- end
63
- 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