llm_cost_tracker 0.7.0 → 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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +11 -9
  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 +182 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +28 -35
  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 +52 -34
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  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 +35 -36
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  89. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  91. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  92. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  93. data/lib/llm_cost_tracker/parsers.rb +20 -0
  94. data/lib/llm_cost_tracker/prices.json +52 -11
  95. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  96. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  97. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  98. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  99. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  100. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  101. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  102. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  104. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  105. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  106. data/lib/llm_cost_tracker/pricing.rb +33 -32
  107. data/lib/llm_cost_tracker/railtie.rb +7 -8
  108. data/lib/llm_cost_tracker/report/data.rb +72 -0
  109. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  110. data/lib/llm_cost_tracker/report.rb +8 -8
  111. data/lib/llm_cost_tracker/retention.rb +27 -10
  112. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  113. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  114. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  115. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  116. data/lib/llm_cost_tracker/tracker.rb +38 -70
  117. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  118. data/lib/llm_cost_tracker/version.rb +1 -1
  119. data/lib/llm_cost_tracker.rb +56 -78
  120. data/lib/tasks/llm_cost_tracker.rake +18 -13
  121. metadata +54 -58
  122. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  123. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  124. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  125. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  126. data/lib/llm_cost_tracker/cost.rb +0 -12
  127. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  128. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  129. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  130. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  131. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  132. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  133. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  134. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  135. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  136. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  137. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  138. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  139. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  140. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  141. data/lib/llm_cost_tracker/period_total.rb +0 -9
  142. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  143. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  144. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  145. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  146. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  147. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  148. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  149. data/lib/llm_cost_tracker/report_data.rb +0 -94
  150. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  151. data/lib/llm_cost_tracker/request_url.rb +0 -20
  152. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  153. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  154. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  155. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  156. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  157. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  158. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  159. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  160. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  161. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  162. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  163. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  164. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  165. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  166. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  167. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  168. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  169. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  170. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  171. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  172. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -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,63 @@ 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: {})
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
- latency_ms: latency_ms,
37
- stream: stream,
38
- usage_source: usage_source,
39
- provider_response_id: provider_response_id
42
+ latency_ms: latency_ms
40
43
  )
41
44
 
42
45
  ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
43
46
 
44
- stored = Storage::Writer.save(event)
45
- Budget.check!(event) unless stored == false
47
+ Ingestion::Inbox.save(event)
48
+ Budget.check!(event)
46
49
 
47
50
  event
48
51
  end
49
52
 
50
53
  private
51
54
 
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 }
55
+ def build_event(capture:, pricing_mode:, cost_data:, metadata:, latency_ms:)
56
+ usage_source = if capture.usage_source.nil?
57
+ nil
58
+ else
59
+ symbol = capture.usage_source.to_sym
60
+ USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
61
+ end
62
+ tags = metadata.to_h.reject { |key, _value| TRACKING_METADATA_KEYS.include?(key.to_s) }
75
63
 
76
- def build_event(provider:, model:, usage:, cost_data:, metadata:, latency_ms:, stream:, usage_source:,
77
- provider_response_id:)
78
64
  Event.new(
79
65
  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],
66
+ provider: capture.provider,
67
+ model: capture.model,
68
+ token_usage: capture.token_usage,
69
+ pricing_mode: pricing_mode,
89
70
  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),
71
+ tags: LlmCostTracker::Tags::Sanitizer.call(
72
+ LlmCostTracker::Tags::Context.tags.merge(tags)
73
+ ).freeze,
74
+ latency_ms: latency_ms.nil? ? nil : [latency_ms.to_i, 0].max,
75
+ stream: capture.stream ? true : false,
76
+ usage_source: usage_source,
77
+ provider_response_id: capture.provider_response_id.to_s.presence,
95
78
  tracked_at: Time.now.utc
96
79
  )
97
80
  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
81
  end
114
82
  end
115
83
  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.1"
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) }
@@ -17,14 +17,16 @@ namespace :llm_cost_tracker do
17
17
  task :verify_capture do
18
18
  Rake::Task["environment"].invoke if Rake::Task.task_defined?("environment")
19
19
  require_relative "../llm_cost_tracker"
20
- checks = LlmCostTracker::CaptureVerifier.call
21
- puts LlmCostTracker::CaptureVerifier.report(checks)
22
- abort("llm_cost_tracker: capture verification failed") unless LlmCostTracker::CaptureVerifier.healthy?(checks)
20
+ checks = LlmCostTracker::Doctor::CaptureVerifier.call
21
+ puts LlmCostTracker::Doctor::CaptureVerifier.report(checks)
22
+ unless LlmCostTracker::Doctor::CaptureVerifier.healthy?(checks)
23
+ abort("llm_cost_tracker: capture verification failed")
24
+ end
23
25
  end
24
26
 
25
27
  desc "Print an LLM cost report from ActiveRecord storage"
26
28
  task report: :environment do
27
- days = (ENV["DAYS"] || LlmCostTracker::ReportData::DEFAULT_DAYS).to_i
29
+ days = (ENV["DAYS"] || LlmCostTracker::Report::Data::DEFAULT_DAYS).to_i
28
30
  puts LlmCostTracker::Report.generate(days: days)
29
31
  end
30
32
 
@@ -46,9 +48,9 @@ namespace :llm_cost_tracker do
46
48
  require_relative "../llm_cost_tracker"
47
49
 
48
50
  output_path = price_refresh_output_path
49
- source_url = LlmCostTracker::PriceSync.configured_remote_url
51
+ source_url = LlmCostTracker::Pricing::Sync.configured_remote_url
50
52
  preview = ENV["PREVIEW"] == "1"
51
- result = LlmCostTracker::PriceSync.refresh(
53
+ result = LlmCostTracker::Pricing::Sync.refresh(
52
54
  path: output_path,
53
55
  url: source_url,
54
56
  preview: preview
@@ -74,8 +76,8 @@ namespace :llm_cost_tracker do
74
76
  require_relative "../llm_cost_tracker"
75
77
 
76
78
  output_path = price_refresh_output_path
77
- source_url = LlmCostTracker::PriceSync.configured_remote_url
78
- result = LlmCostTracker::PriceSync.check(path: output_path, url: source_url)
79
+ source_url = LlmCostTracker::Pricing::Sync.configured_remote_url
80
+ result = LlmCostTracker::Pricing::Sync.check(path: output_path, url: source_url)
79
81
 
80
82
  puts "llm_cost_tracker: checked pricing file #{result.path}"
81
83
  puts " source: #{result.source_url}"
@@ -112,7 +114,7 @@ def print_changes(changes)
112
114
  end
113
115
 
114
116
  def price_refresh_output_path
115
- path = LlmCostTracker::PriceSync.configured_output_path
117
+ path = LlmCostTracker::Pricing::Sync.configured_output_path
116
118
  FileUtils.mkdir_p(File.dirname(path))
117
119
  path
118
120
  end
@@ -126,10 +128,13 @@ def price_explanation_from_env
126
128
  provider: provider,
127
129
  model: model,
128
130
  pricing_mode: ENV.fetch("PRICING_MODE", nil),
129
- input_tokens: ENV.fetch("INPUT_TOKENS", 1).to_i,
130
- output_tokens: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
131
- cache_read_input_tokens: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
132
- cache_write_input_tokens: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i
131
+ token_usage: LlmCostTracker::TokenUsage.build(
132
+ input_tokens: ENV.fetch("INPUT_TOKENS", 1).to_i,
133
+ output_tokens: ENV.fetch("OUTPUT_TOKENS", 1).to_i,
134
+ cache_read_input_tokens: ENV.fetch("CACHE_READ_INPUT_TOKENS", 0).to_i,
135
+ cache_write_input_tokens: ENV.fetch("CACHE_WRITE_INPUT_TOKENS", 0).to_i,
136
+ cache_write_1h_input_tokens: ENV.fetch("CACHE_WRITE_1H_INPUT_TOKENS", 0).to_i
137
+ )
133
138
  )
134
139
  end
135
140