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
@@ -6,22 +6,23 @@ module LlmCostTracker
6
6
 
7
7
  class << self
8
8
  def prune(older_than:, batch_size: DEFAULT_BATCH_SIZE, now: Time.now.utc)
9
- batch_size = normalized_batch_size(batch_size)
9
+ batch_size = batch_size.to_i
10
+ raise ArgumentError, "batch_size must be positive: #{batch_size.inspect}" unless batch_size.positive?
11
+
10
12
  cutoff = resolve_cutoff(older_than, now)
11
- require_relative "storage/active_record_backend"
13
+ require_relative "ledger"
12
14
 
13
- Storage::ActiveRecordBackend.prune(cutoff: cutoff, batch_size: batch_size)
15
+ deleted = 0
16
+ loop do
17
+ batch = prune_batch(cutoff, batch_size)
18
+ deleted += batch
19
+ break if batch < batch_size
20
+ end
21
+ deleted
14
22
  end
15
23
 
16
24
  private
17
25
 
18
- def normalized_batch_size(value)
19
- value = value.to_i
20
- raise ArgumentError, "batch_size must be positive: #{value.inspect}" unless value.positive?
21
-
22
- value
23
- end
24
-
25
26
  def resolve_cutoff(older_than, now)
26
27
  cutoff = case older_than
27
28
  when Time, DateTime then older_than.utc
@@ -46,6 +47,22 @@ module LlmCostTracker
46
47
 
47
48
  now - (days * 86_400)
48
49
  end
50
+
51
+ def prune_batch(cutoff, batch_size)
52
+ LlmCostTracker::Ledger::Call.transaction do
53
+ rows = LlmCostTracker::Ledger::Call
54
+ .where(tracked_at: ...cutoff)
55
+ .order(:id)
56
+ .limit(batch_size)
57
+ .lock
58
+ .pluck(:id, :tracked_at, :total_cost)
59
+ next 0 if rows.empty?
60
+
61
+ deleted = LlmCostTracker::Ledger::Call.where(id: rows.map(&:first)).delete_all
62
+ LlmCostTracker::Ledger::Rollups.decrement!(rows) if deleted.positive?
63
+ deleted
64
+ end
65
+ end
49
66
  end
50
67
  end
51
68
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require "active_support/isolated_execution_state"
5
+
6
+ module LlmCostTracker
7
+ module Tags
8
+ module Context
9
+ KEY = :llm_cost_tracker_tags
10
+
11
+ class << self
12
+ def with(tags)
13
+ stack = ActiveSupport::IsolatedExecutionState[KEY] || []
14
+ ActiveSupport::IsolatedExecutionState[KEY] = stack + [(tags || {}).deep_dup.to_h]
15
+ yield
16
+ ensure
17
+ ActiveSupport::IsolatedExecutionState[KEY] = stack
18
+ end
19
+
20
+ def tags
21
+ default_tags = LlmCostTracker.configuration.default_tags
22
+ default_tags = default_tags.call if default_tags.respond_to?(:call)
23
+
24
+ (default_tags || {}).deep_dup.to_h.merge(
25
+ (ActiveSupport::IsolatedExecutionState[KEY] || []).reduce({}) { |merged, tags| merged.merge(tags) }
26
+ )
27
+ end
28
+
29
+ def clear!
30
+ ActiveSupport::IsolatedExecutionState[KEY] = []
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Tags
5
+ module Key
6
+ PATTERN = /\A[\w.-]+\z/
7
+
8
+ class << self
9
+ def validate!(key, error_class: ArgumentError)
10
+ key = key.to_s
11
+ return key if key.match?(PATTERN)
12
+
13
+ raise error_class, "invalid tag key: #{key.inspect}"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Tags
7
+ module Sanitizer
8
+ REDACTED_VALUE = "[REDACTED]"
9
+
10
+ class << self
11
+ def call(tags, config: LlmCostTracker.configuration)
12
+ tags = (tags || {}).to_h
13
+ tags.first([config.max_tag_count.to_i, 0].max).each_with_object({}) do |(key, value), sanitized|
14
+ sanitized[key] = sanitized_value(key, value, config)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def sanitized_value(key, value, config)
21
+ return REDACTED_VALUE if redacted_key?(key, config)
22
+
23
+ string = value_string(value)
24
+ limit = [config.max_tag_value_bytesize.to_i, 0].max
25
+ return value if string.bytesize <= limit
26
+
27
+ string.byteslice(0, limit).to_s.encode("UTF-8", invalid: :replace, undef: :replace)
28
+ end
29
+
30
+ def redacted_key?(key, config)
31
+ normalized = normalized_key(key)
32
+ Array(config.redacted_tag_keys).map { |redacted_key| normalized_key(redacted_key) }.any? do |candidate|
33
+ redacted_key_component?(normalized, candidate)
34
+ end
35
+ end
36
+
37
+ def normalized_key(key)
38
+ key.to_s
39
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
40
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
41
+ .downcase
42
+ .gsub(/[^a-z0-9]+/, "_")
43
+ .gsub(/_+/, "_")
44
+ .delete_prefix("_")
45
+ .delete_suffix("_")
46
+ end
47
+
48
+ def redacted_key_component?(key, candidate)
49
+ key == candidate ||
50
+ key.start_with?("#{candidate}_") ||
51
+ key.end_with?("_#{candidate}") ||
52
+ key.include?("_#{candidate}_")
53
+ end
54
+
55
+ def value_string(value)
56
+ case value
57
+ when Hash, Array
58
+ JSON.generate(value)
59
+ else
60
+ value.to_s
61
+ end
62
+ rescue JSON::GeneratorError, TypeError
63
+ value.to_s
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/keys"
4
+
5
+ module LlmCostTracker
6
+ TokenUsage = Data.define(
7
+ :input_tokens,
8
+ :cache_read_input_tokens,
9
+ :cache_write_input_tokens,
10
+ :cache_write_1h_input_tokens,
11
+ :output_tokens,
12
+ :total_tokens,
13
+ :hidden_output_tokens
14
+ ) do
15
+ def self.build(input_tokens:, output_tokens:, cache_read_input_tokens: 0,
16
+ cache_write_input_tokens: 0, cache_write_1h_input_tokens: 0,
17
+ total_tokens: nil, hidden_output_tokens: 0)
18
+ input = input_tokens.to_i
19
+ output = output_tokens.to_i
20
+ cache_read = cache_read_input_tokens.to_i
21
+ cache_write = cache_write_input_tokens.to_i
22
+ cache_write_1h = cache_write_1h_input_tokens.to_i
23
+ calculated_total = input + cache_read + cache_write + cache_write_1h + output
24
+ total = total_tokens.nil? ? calculated_total : [total_tokens.to_i, calculated_total].max
25
+
26
+ new(
27
+ input_tokens: input,
28
+ cache_read_input_tokens: cache_read,
29
+ cache_write_input_tokens: cache_write,
30
+ cache_write_1h_input_tokens: cache_write_1h,
31
+ output_tokens: output,
32
+ total_tokens: total,
33
+ hidden_output_tokens: hidden_output_tokens.to_i
34
+ )
35
+ end
36
+
37
+ def self.from_hash(attributes)
38
+ attributes = attributes.to_h.symbolize_keys
39
+ values = TokenUsage::COMPONENT_TOKEN_KEYS.to_h { |key| [key, attributes[key]] }
40
+ build(
41
+ **values,
42
+ total_tokens: attributes[:total_tokens]
43
+ )
44
+ end
45
+
46
+ def price_quantities
47
+ {
48
+ input: input_tokens,
49
+ cache_read_input: cache_read_input_tokens,
50
+ cache_write_input: cache_write_input_tokens,
51
+ cache_write_1h_input: cache_write_1h_input_tokens,
52
+ output: output_tokens
53
+ }
54
+ end
55
+
56
+ def stored_attributes
57
+ to_h.slice(*self.class::STORED_KEYS)
58
+ end
59
+
60
+ def to_h
61
+ super.compact
62
+ end
63
+ end
64
+
65
+ TokenUsage::STORED_KEYS = TokenUsage.members.freeze
66
+ TokenUsage::COMPONENT_TOKEN_KEYS = (TokenUsage.members - %i[total_tokens]).freeze
67
+ end
@@ -1,14 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/object/blank"
3
4
  require "securerandom"
4
5
 
5
- require_relative "storage/writer"
6
+ require_relative "ingestion"
7
+ require_relative "ledger"
8
+ require_relative "pricing"
6
9
 
7
10
  module LlmCostTracker
8
11
  class Tracker
9
12
  EVENT_NAME = "llm_request.llm_cost_tracker"
10
13
 
11
14
  USAGE_SOURCES = %i[response stream_final sdk_response ruby_llm manual unknown].freeze
15
+ TRACKING_METADATA_KEYS = (TokenUsage.members.map(&:to_s) + %w[pricing_mode provider_response_id]).freeze
12
16
 
13
17
  class << self
14
18
  def enforce_budget!
@@ -17,99 +21,65 @@ module LlmCostTracker
17
21
  Budget.enforce!
18
22
  end
19
23
 
20
- def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
21
- usage_source: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
24
+ def record(capture:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
22
25
  return unless LlmCostTracker.configuration.enabled
23
26
 
24
- model = normalize_model(model)
25
- usage = usage_data(input_tokens, output_tokens, metadata, pricing_mode)
26
- cost_data = cost_for_usage(provider, model, usage)
27
+ pricing_mode = Pricing.normalize_mode(pricing_mode) || capture.pricing_mode
28
+ cost_data = Pricing.cost_for(
29
+ provider: capture.provider,
30
+ model: capture.model,
31
+ token_usage: capture.token_usage,
32
+ pricing_mode: pricing_mode
33
+ )
27
34
 
28
- UnknownPricing.handle!(model) unless cost_data
35
+ Pricing::Unknown.handle!(capture.model) unless cost_data
29
36
 
30
37
  event = build_event(
31
- provider: provider,
32
- model: model,
33
- usage: usage,
38
+ capture: capture,
39
+ pricing_mode: pricing_mode,
34
40
  cost_data: cost_data,
35
41
  metadata: metadata,
36
42
  latency_ms: latency_ms,
37
- stream: stream,
38
- usage_source: usage_source,
39
- provider_response_id: provider_response_id
43
+ context_tags: context_tags
40
44
  )
41
45
 
42
46
  ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
43
47
 
44
- stored = Storage::Writer.save(event)
45
- Budget.check!(event) unless stored == false
48
+ Ingestion::Inbox.save(event)
49
+ Budget.check!(event)
46
50
 
47
51
  event
48
52
  end
49
53
 
50
54
  private
51
55
 
52
- def usage_data(input_tokens, output_tokens, metadata, pricing_mode)
53
- metadata = metadata.merge(pricing_mode: pricing_mode) unless pricing_mode.nil?
54
-
55
- EventMetadata.usage_data(
56
- input_tokens,
57
- output_tokens,
58
- metadata
59
- )
60
- end
61
-
62
- def cost_for_usage(provider, model, usage)
63
- Pricing.cost_for(
64
- provider: provider,
65
- model: model,
66
- input_tokens: usage[:input_tokens],
67
- output_tokens: usage[:output_tokens],
68
- cache_read_input_tokens: usage[:cache_read_input_tokens],
69
- cache_write_input_tokens: usage[:cache_write_input_tokens],
70
- pricing_mode: usage[:pricing_mode]
71
- )
72
- end
73
-
74
- def normalize_model(value) = value.to_s.strip.then { |model| model.empty? ? ParsedUsage::UNKNOWN_MODEL : model }
56
+ def build_event(capture:, pricing_mode:, cost_data:, metadata:, latency_ms:, context_tags:)
57
+ usage_source = if capture.usage_source.nil?
58
+ nil
59
+ else
60
+ symbol = capture.usage_source.to_sym
61
+ USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
62
+ end
63
+ tags = metadata.to_h.reject { |key, _value| TRACKING_METADATA_KEYS.include?(key.to_s) }
64
+ context_tags = context_tags.nil? ? LlmCostTracker::Tags::Context.tags : context_tags.to_h
75
65
 
76
- def build_event(provider:, model:, usage:, cost_data:, metadata:, latency_ms:, stream:, usage_source:,
77
- provider_response_id:)
78
66
  Event.new(
79
67
  event_id: SecureRandom.uuid,
80
- provider: provider,
81
- model: model,
82
- input_tokens: usage[:input_tokens],
83
- output_tokens: usage[:output_tokens],
84
- total_tokens: usage[:total_tokens],
85
- cache_read_input_tokens: usage[:cache_read_input_tokens],
86
- cache_write_input_tokens: usage[:cache_write_input_tokens],
87
- hidden_output_tokens: usage[:hidden_output_tokens],
88
- pricing_mode: usage[:pricing_mode],
68
+ provider: capture.provider,
69
+ model: capture.model,
70
+ token_usage: capture.token_usage,
71
+ pricing_mode: pricing_mode,
89
72
  cost: cost_data,
90
- tags: sanitized_tags(metadata).freeze,
91
- latency_ms: normalized_latency_ms(latency_ms),
92
- stream: stream ? true : false,
93
- usage_source: normalized_usage_source(usage_source),
94
- provider_response_id: normalized_provider_response_id(provider_response_id),
73
+ tags: LlmCostTracker::Tags::Sanitizer.call(
74
+ context_tags.merge(tags)
75
+ ).freeze,
76
+ latency_ms: latency_ms.nil? ? nil : [latency_ms.to_i, 0].max,
77
+ stream: capture.stream ? true : false,
78
+ usage_source: usage_source,
79
+ provider_response_id: capture.provider_response_id.to_s.presence,
95
80
  tracked_at: Time.now.utc
96
81
  )
97
82
  end
98
-
99
- def normalized_latency_ms(latency_ms) = latency_ms.nil? ? nil : [latency_ms.to_i, 0].max
100
-
101
- def sanitized_tags(metadata)
102
- LlmCostTracker::TagSanitizer.call(LlmCostTracker::TagContext.tags.merge(EventMetadata.tags(metadata)))
103
- end
104
-
105
- def normalized_usage_source(value)
106
- return nil if value.nil?
107
-
108
- symbol = value.to_sym
109
- USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
110
- end
111
-
112
- def normalized_provider_response_id(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
113
83
  end
114
84
  end
115
85
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+
5
+ require_relative "pricing"
6
+
7
+ module LlmCostTracker
8
+ UsageCapture = Data.define(
9
+ :provider,
10
+ :model,
11
+ :token_usage,
12
+ :stream,
13
+ :usage_source,
14
+ :provider_response_id,
15
+ :pricing_mode
16
+ )
17
+
18
+ class UsageCapture
19
+ UNKNOWN_MODEL = "unknown"
20
+
21
+ def self.build(**attributes)
22
+ new(
23
+ provider: attributes.fetch(:provider).to_s,
24
+ model: attributes.fetch(:model).to_s.strip.presence || UNKNOWN_MODEL,
25
+ token_usage: attributes.fetch(:token_usage),
26
+ stream: attributes[:stream] || false,
27
+ usage_source: attributes[:usage_source],
28
+ provider_response_id: attributes[:provider_response_id],
29
+ pricing_mode: Pricing.normalize_mode(attributes[:pricing_mode])
30
+ )
31
+ end
32
+
33
+ def to_h
34
+ super.compact
35
+ end
36
+ end
37
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.2"
5
5
  end
@@ -1,21 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rails"
3
4
  require "active_support"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/object/deep_dup"
7
+ require "active_support/core_ext/object/try"
8
+ require "active_support/core_ext/hash/indifferent_access"
9
+ require "active_support/core_ext/string/inflections"
4
10
  require "active_support/notifications"
5
- require "monitor"
6
11
 
7
12
  require_relative "llm_cost_tracker/version"
8
13
  require_relative "llm_cost_tracker/configuration"
9
14
  require_relative "llm_cost_tracker/errors"
10
15
  require_relative "llm_cost_tracker/logging"
11
- require_relative "llm_cost_tracker/parameter_hash"
12
- require_relative "llm_cost_tracker/cost"
13
- require_relative "llm_cost_tracker/usage_breakdown"
16
+ require_relative "llm_cost_tracker/tags/key"
17
+ require_relative "llm_cost_tracker/tags/context"
18
+ require_relative "llm_cost_tracker/tags/sanitizer"
19
+ require_relative "llm_cost_tracker/token_usage"
14
20
  require_relative "llm_cost_tracker/event"
15
- require_relative "llm_cost_tracker/parsed_usage"
16
- require_relative "llm_cost_tracker/price_registry"
17
- require_relative "llm_cost_tracker/price_sync"
18
21
  require_relative "llm_cost_tracker/pricing"
22
+ require_relative "llm_cost_tracker/usage_capture"
23
+ require_relative "llm_cost_tracker/pricing/sync"
19
24
  require_relative "llm_cost_tracker/parsers/base"
20
25
  require_relative "llm_cost_tracker/parsers/openai_usage"
21
26
  require_relative "llm_cost_tracker/parsers/openai"
@@ -23,87 +28,59 @@ require_relative "llm_cost_tracker/parsers/openai_compatible"
23
28
  require_relative "llm_cost_tracker/parsers/anthropic"
24
29
  require_relative "llm_cost_tracker/parsers/gemini"
25
30
  require_relative "llm_cost_tracker/parsers/sse"
26
- require_relative "llm_cost_tracker/parsers/registry"
31
+ require_relative "llm_cost_tracker/parsers"
27
32
  require_relative "llm_cost_tracker/middleware/faraday"
28
- require_relative "llm_cost_tracker/integrations/registry"
33
+ require_relative "llm_cost_tracker/integrations"
29
34
  require_relative "llm_cost_tracker/budget"
30
- require_relative "llm_cost_tracker/unknown_pricing"
31
- require_relative "llm_cost_tracker/event_metadata"
32
- require_relative "llm_cost_tracker/tag_context"
33
- require_relative "llm_cost_tracker/tag_sanitizer"
34
- require_relative "llm_cost_tracker/active_record_adapter"
35
- require_relative "llm_cost_tracker/tags_column"
36
- require_relative "llm_cost_tracker/tag_key"
37
- require_relative "llm_cost_tracker/tag_sql"
38
- require_relative "llm_cost_tracker/tag_query"
39
- require_relative "llm_cost_tracker/tag_accessors"
40
- require_relative "llm_cost_tracker/llm_api_call_metrics"
35
+ require_relative "llm_cost_tracker/pricing/unknown"
36
+ require_relative "llm_cost_tracker/ledger"
37
+ require_relative "llm_cost_tracker/ingestion"
41
38
  require_relative "llm_cost_tracker/tracker"
42
39
  require_relative "llm_cost_tracker/retention"
43
- require_relative "llm_cost_tracker/report_data"
44
- require_relative "llm_cost_tracker/report_formatter"
45
40
  require_relative "llm_cost_tracker/report"
46
41
  require_relative "llm_cost_tracker/doctor"
47
- require_relative "llm_cost_tracker/capture_verifier"
42
+ require_relative "llm_cost_tracker/doctor/capture_verifier"
48
43
 
49
44
  module LlmCostTracker
50
- CONFIGURATION_MUTEX = Monitor.new
45
+ @configuration = Configuration.new
51
46
 
52
47
  class << self
53
- def configuration
54
- CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
55
- end
56
-
57
- def configuration_generation
58
- CONFIGURATION_MUTEX.synchronize { @configuration_generation ||= 0 }
59
- end
48
+ attr_reader :configuration
60
49
 
61
50
  def configure
62
- config = CONFIGURATION_MUTEX.synchronize do
63
- current = @configuration || Configuration.new
64
- current = current.dup_for_configuration if current.finalized?
65
- @configuration = current
66
- yield(current)
67
- current.openai_compatible_providers = current.openai_compatible_providers.dup
68
- current.finalize!
69
- @configuration_generation = @configuration_generation.to_i + 1
70
- current
71
- end
72
- Integrations::Registry.install!
51
+ config = configuration
52
+ raise Error, "LlmCostTracker is already configured" if config.finalized?
53
+
54
+ yield(config)
55
+ config.openai_compatible_providers = config.openai_compatible_providers.dup
56
+ config.finalize!
57
+ Pricing::Lookup.reset!
58
+ Integrations.install!
73
59
  config
74
60
  end
75
61
 
76
62
  def reset_configuration!
77
- Storage::ActiveRecordInbox.reset! if defined?(Storage::ActiveRecordInbox)
78
- Storage::ActiveRecordIngestor.shutdown!(drain: false) if defined?(Storage::ActiveRecordIngestor)
79
- CONFIGURATION_MUTEX.synchronize do
80
- @configuration = Configuration.new
81
- @configuration_generation = @configuration_generation.to_i + 1
82
- end
83
- UnknownPricing.reset! if defined?(UnknownPricing)
84
- Storage::ActiveRecordStore.reset! if defined?(Storage::ActiveRecordStore)
85
- Storage::ActiveRecordInbox.reset! if defined?(Storage::ActiveRecordInbox)
86
- Storage::ActiveRecordIngestor.reset! if defined?(Storage::ActiveRecordIngestor)
87
- TagContext.clear! if defined?(TagContext)
63
+ Ingestion::Worker.shutdown!(drain: false)
64
+ @configuration = Configuration.new
65
+ Pricing::Lookup.reset!
66
+ Pricing::Unknown.reset!
67
+ Ingestion::Worker.reset!
68
+ Tags::Context.clear!
88
69
  end
89
70
 
90
71
  def flush!(timeout: nil)
91
- return true unless defined?(Storage::ActiveRecordIngestor)
92
-
93
72
  if timeout
94
- Storage::ActiveRecordIngestor.flush!(timeout: timeout)
73
+ Ingestion::Worker.flush!(timeout: timeout)
95
74
  else
96
- Storage::ActiveRecordIngestor.flush!
75
+ Ingestion::Worker.flush!
97
76
  end
98
77
  end
99
78
 
100
79
  def shutdown!(timeout: nil, drain: true)
101
- return true unless defined?(Storage::ActiveRecordIngestor)
102
-
103
80
  if timeout
104
- Storage::ActiveRecordIngestor.shutdown!(timeout: timeout, drain: drain)
81
+ Ingestion::Worker.shutdown!(timeout: timeout, drain: drain)
105
82
  else
106
- Storage::ActiveRecordIngestor.shutdown!(drain: drain)
83
+ Ingestion::Worker.shutdown!(drain: drain)
107
84
  end
108
85
  end
109
86
 
@@ -113,21 +90,24 @@ module LlmCostTracker
113
90
 
114
91
  def with_tags(tags = nil, **kwargs, &)
115
92
  merged = (tags || {}).to_h.merge(kwargs)
116
- TagContext.with(merged, &)
93
+ Tags::Context.with(merged, &)
117
94
  end
118
95
 
119
96
  def track(provider:, input_tokens:, output_tokens:, model: nil, latency_ms: nil, stream: false,
120
97
  usage_source: :manual, enforce_budget: false, provider_response_id: nil, pricing_mode: nil, **metadata)
121
98
  enforce_budget! if enforce_budget
99
+ token_usage = TokenUsage.from_hash(metadata.merge(input_tokens: input_tokens, output_tokens: output_tokens))
100
+
122
101
  Tracker.record(
123
- provider: provider.to_s,
124
- model: model,
125
- input_tokens: input_tokens,
126
- output_tokens: output_tokens,
102
+ capture: UsageCapture.build(
103
+ provider: provider,
104
+ model: model,
105
+ token_usage: token_usage,
106
+ stream: stream,
107
+ usage_source: usage_source,
108
+ provider_response_id: provider_response_id
109
+ ),
127
110
  latency_ms: latency_ms,
128
- stream: stream,
129
- usage_source: usage_source,
130
- provider_response_id: provider_response_id,
131
111
  pricing_mode: pricing_mode,
132
112
  metadata: metadata
133
113
  )
@@ -135,9 +115,9 @@ module LlmCostTracker
135
115
 
136
116
  def track_stream(provider:, model: nil, latency_ms: nil, enforce_budget: false, provider_response_id: nil,
137
117
  pricing_mode: nil, **metadata)
138
- require_relative "llm_cost_tracker/stream_collector"
118
+ require_relative "llm_cost_tracker/capture/stream_collector"
139
119
  enforce_budget! if enforce_budget
140
- collector = StreamCollector.new(
120
+ collector = Capture::StreamCollector.new(
141
121
  provider: provider.to_s,
142
122
  model: model,
143
123
  latency_ms: latency_ms,
@@ -154,12 +134,10 @@ module LlmCostTracker
154
134
  end
155
135
  end
156
136
 
157
- require_relative "llm_cost_tracker/railtie" if defined?(Rails::Railtie)
137
+ require_relative "llm_cost_tracker/railtie"
158
138
 
159
- if defined?(Faraday)
160
- Faraday::Middleware.register_middleware(
161
- llm_cost_tracker: LlmCostTracker::Middleware::Faraday
162
- )
163
- end
139
+ Faraday::Middleware.register_middleware(
140
+ llm_cost_tracker: LlmCostTracker::Middleware::Faraday
141
+ )
164
142
 
165
- at_exit { LlmCostTracker.shutdown!(drain: false) if defined?(LlmCostTracker) }
143
+ at_exit { LlmCostTracker.shutdown!(drain: false) }