llm_cost_tracker 0.2.0 → 0.3.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +124 -68
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +1 -4
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -2
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  9. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  10. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +6 -1
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
  15. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  16. data/app/services/llm_cost_tracker/dashboard/filter.rb +26 -24
  17. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
  19. data/app/services/llm_cost_tracker/pagination.rb +1 -9
  20. data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
  21. data/app/views/llm_cost_tracker/calls/index.html.erb +23 -13
  22. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
  23. data/app/views/llm_cost_tracker/dashboard/index.html.erb +11 -1
  24. data/app/views/llm_cost_tracker/data_quality/index.html.erb +78 -10
  25. data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
  26. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
  27. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
  28. data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
  29. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  30. data/lib/llm_cost_tracker/assets.rb +6 -11
  31. data/lib/llm_cost_tracker/configuration.rb +78 -43
  32. data/lib/llm_cost_tracker/event.rb +3 -0
  33. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +6 -0
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  40. data/lib/llm_cost_tracker/llm_api_call.rb +14 -2
  41. data/lib/llm_cost_tracker/middleware/faraday.rb +58 -9
  42. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  43. data/lib/llm_cost_tracker/parsed_usage.rb +18 -3
  44. data/lib/llm_cost_tracker/parsers/anthropic.rb +98 -1
  45. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  46. data/lib/llm_cost_tracker/parsers/gemini.rb +83 -6
  47. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  48. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +12 -5
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +69 -1
  50. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  51. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +23 -8
  53. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  54. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  55. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  56. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  57. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  58. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  59. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  60. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  61. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  62. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  63. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  64. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  65. data/lib/llm_cost_tracker/price_sync.rb +142 -0
  66. data/lib/llm_cost_tracker/pricing.rb +0 -11
  67. data/lib/llm_cost_tracker/railtie.rb +0 -1
  68. data/lib/llm_cost_tracker/report.rb +0 -5
  69. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -9
  70. data/lib/llm_cost_tracker/stream_collector.rb +162 -0
  71. data/lib/llm_cost_tracker/tags_column.rb +12 -0
  72. data/lib/llm_cost_tracker/tracker.rb +23 -12
  73. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  74. data/lib/llm_cost_tracker/version.rb +1 -1
  75. data/lib/llm_cost_tracker.rb +48 -35
  76. data/lib/tasks/llm_cost_tracker.rake +116 -0
  77. data/llm_cost_tracker.gemspec +8 -6
  78. metadata +30 -8
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ require_relative "price_sync/fetcher"
6
+ require_relative "price_sync/raw_price"
7
+ require_relative "price_sync/source"
8
+ require_relative "price_sync/source_result"
9
+ require_relative "price_sync/registry_loader"
10
+ require_relative "price_sync/registry_writer"
11
+ require_relative "price_sync/refresh_plan_builder"
12
+ require_relative "price_sync/model_catalog"
13
+ require_relative "price_sync/merger"
14
+ require_relative "price_sync/validator"
15
+ require_relative "price_sync/sources/litellm"
16
+ require_relative "price_sync/sources/open_router"
17
+
18
+ module LlmCostTracker
19
+ module PriceSync
20
+ DEFAULT_OUTPUT_PATH = PriceRegistry::DEFAULT_PRICES_PATH
21
+
22
+ SourceUsage = Data.define(:prices_count, :source_version)
23
+ SyncResult = Data.define(
24
+ :path,
25
+ :updated_models,
26
+ :changes,
27
+ :orphaned_models,
28
+ :failed_sources,
29
+ :discrepancies,
30
+ :rejected,
31
+ :flagged,
32
+ :sources_used,
33
+ :written
34
+ )
35
+ CheckResult = Data.define(
36
+ :path,
37
+ :changes,
38
+ :orphaned_models,
39
+ :failed_sources,
40
+ :discrepancies,
41
+ :rejected,
42
+ :flagged,
43
+ :sources_used,
44
+ :up_to_date
45
+ )
46
+ RefreshPlan = Data.define(
47
+ :path,
48
+ :registry,
49
+ :updated_registry,
50
+ :accepted,
51
+ :changes,
52
+ :orphaned_models,
53
+ :failed_sources,
54
+ :discrepancies,
55
+ :rejected,
56
+ :flagged,
57
+ :sources_used,
58
+ :source_results
59
+ ) do
60
+ def refresh_succeeded?
61
+ source_results.any? { |_source, result| result.prices.any? }
62
+ end
63
+
64
+ def up_to_date?
65
+ changes.empty? && failed_sources.empty? && rejected.empty?
66
+ end
67
+ end
68
+
69
+ class << self
70
+ def sync(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, preview: false, strict: false,
71
+ fetcher: Fetcher.new, today: Date.today)
72
+ plan = RefreshPlanBuilder.new(sources: sources).call(
73
+ path: path,
74
+ seed_path: seed_path,
75
+ fetcher: fetcher,
76
+ today: today
77
+ )
78
+ raise Error, strict_failure_message(plan) if strict_sync_failure?(plan, strict: strict)
79
+
80
+ written = !preview && plan.refresh_succeeded?
81
+ RegistryWriter.new.call(path: plan.path, registry: plan.updated_registry) if written
82
+
83
+ SyncResult.new(
84
+ path: plan.path,
85
+ updated_models: plan.changes.keys.sort,
86
+ changes: plan.changes,
87
+ orphaned_models: plan.orphaned_models,
88
+ failed_sources: plan.failed_sources,
89
+ discrepancies: plan.discrepancies,
90
+ rejected: plan.rejected,
91
+ flagged: plan.flagged,
92
+ sources_used: plan.sources_used,
93
+ written: written
94
+ )
95
+ end
96
+
97
+ def check(path: DEFAULT_OUTPUT_PATH, seed_path: DEFAULT_OUTPUT_PATH, fetcher: Fetcher.new, today: Date.today)
98
+ plan = RefreshPlanBuilder.new(sources: sources).call(
99
+ path: path,
100
+ seed_path: seed_path,
101
+ fetcher: fetcher,
102
+ today: today
103
+ )
104
+
105
+ CheckResult.new(
106
+ path: plan.path,
107
+ changes: plan.changes,
108
+ orphaned_models: plan.orphaned_models,
109
+ failed_sources: plan.failed_sources,
110
+ discrepancies: plan.discrepancies,
111
+ rejected: plan.rejected,
112
+ flagged: plan.flagged,
113
+ sources_used: plan.sources_used,
114
+ up_to_date: plan.up_to_date?
115
+ )
116
+ end
117
+
118
+ private
119
+
120
+ def sources
121
+ [Sources::Litellm.new, Sources::OpenRouter.new]
122
+ end
123
+
124
+ def strict_sync_failure?(plan, strict:)
125
+ strict && (plan.failed_sources.any? || plan.rejected.any?)
126
+ end
127
+
128
+ def strict_failure_message(plan)
129
+ messages = []
130
+ if plan.failed_sources.any?
131
+ details = plan.failed_sources.map { |source, message| "#{source}: #{message}" }.join(", ")
132
+ messages << "source failures: #{details}"
133
+ end
134
+ if plan.rejected.any?
135
+ details = plan.rejected.map { |issue| "#{issue.model} (#{issue.reason})" }.join(", ")
136
+ messages << "validator rejections: #{details}"
137
+ end
138
+ "Price sync failed in strict mode: #{messages.join('; ')}"
139
+ end
140
+ end
141
+ end
142
+ end
@@ -3,21 +3,11 @@
3
3
  require "monitor"
4
4
 
5
5
  module LlmCostTracker
6
- # Calculates costs from price entries expressed in USD per 1M tokens.
7
6
  module Pricing
8
7
  PRICES = PriceRegistry.builtin_prices
9
8
  MUTEX = Monitor.new
10
9
 
11
10
  class << self
12
- # Estimate model cost from token counts.
13
- #
14
- # @param model [String] Provider model identifier.
15
- # @param input_tokens [Integer] Input token count, including cached tokens if reported that way.
16
- # @param output_tokens [Integer] Output token count.
17
- # @param cached_input_tokens [Integer] OpenAI-style cached input tokens.
18
- # @param cache_read_input_tokens [Integer] Anthropic-style cache read tokens.
19
- # @param cache_creation_input_tokens [Integer] Anthropic-style cache creation tokens.
20
- # @return [LlmCostTracker::Cost, nil] nil when no price is configured for the model.
21
11
  def cost_for(model:, input_tokens:, output_tokens:, cached_input_tokens: 0,
22
12
  cache_read_input_tokens: 0, cache_creation_input_tokens: 0)
23
13
  prices = lookup(model)
@@ -111,7 +101,6 @@ module LlmCostTracker
111
101
  model.to_s.split("/").last
112
102
  end
113
103
 
114
- # Try to match model names like "gpt-4o-2024-08-06" to "gpt-4o".
115
104
  def fuzzy_match(model, normalized_model, table)
116
105
  sorted_price_keys(table).each do |key|
117
106
  return table[key] if model.start_with?(key) || normalized_model.start_with?(key)
@@ -15,7 +15,6 @@ module LlmCostTracker
15
15
  end
16
16
 
17
17
  initializer "llm_cost_tracker.configure" do
18
- # Auto-require ActiveRecord storage if configured
19
18
  ActiveSupport.on_load(:active_record) do
20
19
  if LlmCostTracker.configuration.active_record?
21
20
  require_relative "llm_api_call"
@@ -8,11 +8,6 @@ module LlmCostTracker
8
8
  DEFAULT_DAYS = ReportData::DEFAULT_DAYS
9
9
 
10
10
  class << self
11
- # Render a terminal-friendly cost report from ActiveRecord storage.
12
- #
13
- # @param days [Integer] Number of trailing days to include.
14
- # @param now [Time] Report end time.
15
- # @return [String]
16
11
  def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
17
12
  ReportFormatter.new(data(days: days, now: now, tag_breakdowns: tag_breakdowns)).to_s
18
13
  rescue LoadError => e
@@ -19,22 +19,23 @@ module LlmCostTracker
19
19
  tags: tags_for_storage(tags),
20
20
  tracked_at: event.tracked_at
21
21
  }
22
- attributes[:latency_ms] = event.latency_ms if model_class.latency_column?
23
-
24
- model_class.create!(attributes)
22
+ attributes[:latency_ms] = event.latency_ms if LlmCostTracker::LlmApiCall.latency_column?
23
+ attributes[:stream] = event.stream if LlmCostTracker::LlmApiCall.stream_column?
24
+ attributes[:usage_source] = event.usage_source if LlmCostTracker::LlmApiCall.usage_source_column?
25
+ if LlmCostTracker::LlmApiCall.provider_response_id_column?
26
+ attributes[:provider_response_id] = event.provider_response_id
27
+ end
28
+
29
+ LlmCostTracker::LlmApiCall.create!(attributes)
25
30
  end
26
31
 
27
32
  def monthly_total(time: Time.now.utc)
28
- model_class
33
+ LlmCostTracker::LlmApiCall
29
34
  .where(tracked_at: time.beginning_of_month..time)
30
35
  .sum(:total_cost)
31
36
  .to_f
32
37
  end
33
38
 
34
- def model_class
35
- LlmCostTracker::LlmApiCall
36
- end
37
-
38
39
  private
39
40
 
40
41
  def stringify_tags(tags)
@@ -42,7 +43,7 @@ module LlmCostTracker
42
43
  end
43
44
 
44
45
  def tags_for_storage(tags)
45
- model_class.tags_json_column? ? tags : tags.to_json
46
+ LlmCostTracker::LlmApiCall.tags_json_column? ? tags : tags.to_json
46
47
  end
47
48
 
48
49
  def stringify_tag_value(value)
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ require_relative "value_helpers"
6
+
7
+ module LlmCostTracker
8
+ class StreamCollector
9
+ attr_reader :provider
10
+
11
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, metadata: {})
12
+ @provider = provider.to_s
13
+ @model = model
14
+ @latency_ms = latency_ms
15
+ @provider_response_id = provider_response_id
16
+ @metadata = ValueHelpers.deep_dup(metadata || {})
17
+ @events = []
18
+ @explicit_usage = nil
19
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ @finished = false
21
+ @monitor = Monitor.new
22
+ end
23
+
24
+ def model = @monitor.synchronize { @model }
25
+
26
+ def metadata = @monitor.synchronize { ValueHelpers.deep_dup(@metadata) }
27
+
28
+ def provider_response_id = @monitor.synchronize { @provider_response_id }
29
+
30
+ def model=(value)
31
+ @monitor.synchronize do
32
+ ensure_open!
33
+ @model = value
34
+ end
35
+ end
36
+
37
+ def provider_response_id=(value)
38
+ @monitor.synchronize do
39
+ ensure_open!
40
+ @provider_response_id = value
41
+ end
42
+ end
43
+
44
+ def event(data, type: nil)
45
+ @monitor.synchronize do
46
+ ensure_open!
47
+ @events << { event: type, data: ValueHelpers.deep_dup(data) } unless data.nil?
48
+ end
49
+ self
50
+ end
51
+ alias chunk event
52
+
53
+ def usage(input_tokens:, output_tokens:, **extra)
54
+ @monitor.synchronize do
55
+ ensure_open!
56
+ @explicit_usage = ValueHelpers.deep_dup(
57
+ extra.merge(
58
+ input_tokens: input_tokens.to_i,
59
+ output_tokens: output_tokens.to_i
60
+ )
61
+ )
62
+ end
63
+ self
64
+ end
65
+
66
+ def finish!(errored: false)
67
+ snapshot = @monitor.synchronize do
68
+ return if @finished
69
+
70
+ @finished = true
71
+ {
72
+ events: ValueHelpers.deep_dup(@events),
73
+ explicit_usage: ValueHelpers.deep_dup(@explicit_usage),
74
+ model: @model,
75
+ latency_ms: @latency_ms,
76
+ provider_response_id: @provider_response_id,
77
+ metadata: ValueHelpers.deep_dup(@metadata)
78
+ }
79
+ end
80
+
81
+ parsed = build_parsed_usage(snapshot)
82
+ Tracker.record(
83
+ provider: parsed.provider,
84
+ model: parsed.model,
85
+ input_tokens: parsed.input_tokens,
86
+ output_tokens: parsed.output_tokens,
87
+ latency_ms: snapshot[:latency_ms] || elapsed_ms,
88
+ stream: true,
89
+ usage_source: parsed.usage_source,
90
+ provider_response_id: parsed.provider_response_id || snapshot[:provider_response_id],
91
+ metadata: error_metadata(errored).merge(snapshot[:metadata]).merge(parsed.metadata)
92
+ )
93
+ end
94
+
95
+ private
96
+
97
+ def ensure_open!
98
+ return unless @finished
99
+
100
+ raise FrozenError, "can't modify finished LlmCostTracker::StreamCollector"
101
+ end
102
+
103
+ def build_parsed_usage(snapshot)
104
+ return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
105
+
106
+ parsed = Parsers::Registry.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
107
+ return finalize(parsed, snapshot) if parsed
108
+
109
+ build_unknown_usage(snapshot)
110
+ end
111
+
112
+ def finalize(parsed, snapshot)
113
+ parsed.with(
114
+ provider: @provider,
115
+ model: present_model(parsed.model) || snapshot[:model]
116
+ )
117
+ end
118
+
119
+ def present_model(value)
120
+ return nil if value.nil?
121
+
122
+ string = value.to_s
123
+ return nil if string.empty? || string == "unknown"
124
+
125
+ string
126
+ end
127
+
128
+ def build_from_explicit_usage(snapshot)
129
+ explicit = snapshot[:explicit_usage]
130
+ input = explicit[:input_tokens]
131
+ output = explicit[:output_tokens]
132
+ extras = explicit.except(:input_tokens, :output_tokens)
133
+
134
+ ParsedUsage.build(
135
+ provider: @provider,
136
+ model: snapshot[:model],
137
+ input_tokens: input,
138
+ output_tokens: output,
139
+ total_tokens: input + output,
140
+ stream: true,
141
+ usage_source: :manual,
142
+ **extras
143
+ )
144
+ end
145
+
146
+ def build_unknown_usage(snapshot)
147
+ ParsedUsage.build(
148
+ provider: @provider,
149
+ model: snapshot[:model],
150
+ input_tokens: 0,
151
+ output_tokens: 0,
152
+ total_tokens: 0,
153
+ stream: true,
154
+ usage_source: :unknown
155
+ )
156
+ end
157
+
158
+ def error_metadata(errored) = errored ? { stream_errored: true } : {}
159
+
160
+ def elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round
161
+ end
162
+ end
@@ -24,5 +24,17 @@ module LlmCostTracker
24
24
  def latency_column?
25
25
  columns_hash.key?("latency_ms")
26
26
  end
27
+
28
+ def stream_column?
29
+ columns_hash.key?("stream")
30
+ end
31
+
32
+ def usage_source_column?
33
+ columns_hash.key?("usage_source")
34
+ end
35
+
36
+ def provider_response_id_column?
37
+ columns_hash.key?("provider_response_id")
38
+ end
27
39
  end
28
40
  end
@@ -6,21 +6,15 @@ module LlmCostTracker
6
6
  class Tracker
7
7
  EVENT_NAME = "llm_request.llm_cost_tracker"
8
8
 
9
+ USAGE_SOURCES = %i[response stream_final manual unknown].freeze
10
+
9
11
  class << self
10
12
  def enforce_budget!
11
13
  Budget.enforce!
12
14
  end
13
15
 
14
- # Build, notify, persist, and budget-check a single LLM usage event.
15
- #
16
- # @param provider [String] Provider name.
17
- # @param model [String] Model identifier.
18
- # @param input_tokens [Integer] Input token count.
19
- # @param output_tokens [Integer] Output token count.
20
- # @param metadata [Hash] Attribution tags plus provider-specific usage metadata.
21
- # @param latency_ms [Integer, nil] Optional latency in milliseconds.
22
- # @return [LlmCostTracker::Event]
23
- def record(provider:, model:, input_tokens:, output_tokens:, metadata: {}, latency_ms: nil)
16
+ def record(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false,
17
+ usage_source: nil, provider_response_id: nil, metadata: {})
24
18
  usage = EventMetadata.usage_data(input_tokens, output_tokens, metadata)
25
19
 
26
20
  cost_data = Pricing.cost_for(
@@ -43,13 +37,14 @@ module LlmCostTracker
43
37
  cost: cost_data,
44
38
  tags: LlmCostTracker.configuration.default_tags.merge(EventMetadata.tags(metadata)).freeze,
45
39
  latency_ms: normalized_latency_ms(latency_ms),
40
+ stream: stream ? true : false,
41
+ usage_source: normalized_usage_source(usage_source),
42
+ provider_response_id: normalized_provider_response_id(provider_response_id),
46
43
  tracked_at: Time.now.utc
47
44
  )
48
45
 
49
- # Emit ActiveSupport::Notifications event
50
46
  ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
51
47
 
52
- # Store based on backend
53
48
  stored = store(event)
54
49
  Budget.check!(event) unless stored == false
55
50
 
@@ -77,6 +72,8 @@ module LlmCostTracker
77
72
  "tokens=#{event.input_tokens}+#{event.output_tokens} " \
78
73
  "cost=#{log_cost_label(event)}"
79
74
  message += " latency=#{event.latency_ms}ms" if event.latency_ms
75
+ message += " stream=#{event.stream}" if event.stream
76
+ message += " source=#{event.usage_source}" if event.usage_source
80
77
  message += " tags=#{event.tags}" unless event.tags.empty?
81
78
 
82
79
  Logging.log(config.log_level, message)
@@ -119,6 +116,20 @@ module LlmCostTracker
119
116
 
120
117
  [latency_ms.to_i, 0].max
121
118
  end
119
+
120
+ def normalized_usage_source(value)
121
+ return nil if value.nil?
122
+
123
+ symbol = value.to_sym
124
+ USAGE_SOURCES.include?(symbol) ? symbol.to_s : nil
125
+ end
126
+
127
+ def normalized_provider_response_id(value)
128
+ return nil if value.nil?
129
+
130
+ string = value.to_s
131
+ string.empty? ? nil : string
132
+ end
122
133
  end
123
134
  end
124
135
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module ValueHelpers
5
+ class << self
6
+ def deep_dup(value)
7
+ case value
8
+ when Hash
9
+ value.each_with_object({}) do |(key, nested_value), duplicated|
10
+ duplicated[deep_dup(key)] = deep_dup(nested_value)
11
+ end
12
+ when Array
13
+ value.map { |nested_value| deep_dup(nested_value) }
14
+ when String
15
+ value.dup
16
+ else
17
+ value
18
+ end
19
+ end
20
+
21
+ def deep_freeze(value)
22
+ case value
23
+ when Hash
24
+ value.each do |key, nested_value|
25
+ deep_freeze(key)
26
+ deep_freeze(nested_value)
27
+ end
28
+ value.frozen? ? value : value.freeze
29
+ when Array
30
+ value.each { |nested_value| deep_freeze(nested_value) }
31
+ value.frozen? ? value : value.freeze
32
+ when String
33
+ value.frozen? ? value : value.freeze
34
+ else
35
+ value
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
@@ -2,15 +2,18 @@
2
2
 
3
3
  require "active_support"
4
4
  require "active_support/notifications"
5
+ require "monitor"
5
6
 
6
7
  require_relative "llm_cost_tracker/version"
7
8
  require_relative "llm_cost_tracker/configuration"
8
9
  require_relative "llm_cost_tracker/errors"
9
10
  require_relative "llm_cost_tracker/logging"
11
+ require_relative "llm_cost_tracker/parameter_hash"
10
12
  require_relative "llm_cost_tracker/cost"
11
13
  require_relative "llm_cost_tracker/event"
12
14
  require_relative "llm_cost_tracker/parsed_usage"
13
15
  require_relative "llm_cost_tracker/price_registry"
16
+ require_relative "llm_cost_tracker/price_sync"
14
17
  require_relative "llm_cost_tracker/pricing"
15
18
  require_relative "llm_cost_tracker/parsers/base"
16
19
  require_relative "llm_cost_tracker/parsers/openai_usage"
@@ -18,6 +21,7 @@ require_relative "llm_cost_tracker/parsers/openai"
18
21
  require_relative "llm_cost_tracker/parsers/openai_compatible"
19
22
  require_relative "llm_cost_tracker/parsers/anthropic"
20
23
  require_relative "llm_cost_tracker/parsers/gemini"
24
+ require_relative "llm_cost_tracker/parsers/sse"
21
25
  require_relative "llm_cost_tracker/parsers/registry"
22
26
  require_relative "llm_cost_tracker/middleware/faraday"
23
27
  require_relative "llm_cost_tracker/budget"
@@ -34,71 +38,80 @@ require_relative "llm_cost_tracker/report_formatter"
34
38
  require_relative "llm_cost_tracker/report"
35
39
 
36
40
  module LlmCostTracker
37
- class << self
38
- attr_writer :configuration
41
+ CONFIGURATION_MUTEX = Monitor.new
39
42
 
43
+ class << self
40
44
  def configuration
41
- @configuration ||= Configuration.new
45
+ CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new }
42
46
  end
43
47
 
44
- # Configure the gem once during application boot.
45
- #
46
- # @yieldparam configuration [LlmCostTracker::Configuration]
47
- # @return [void]
48
48
  def configure
49
- yield(configuration)
50
- configuration.normalize_openai_compatible_providers!
51
- warn_for_configuration!
49
+ config = CONFIGURATION_MUTEX.synchronize do
50
+ current = @configuration || Configuration.new
51
+ current = current.dup_for_configuration if current.finalized?
52
+ @configuration = current
53
+ yield(current)
54
+ current.normalize_openai_compatible_providers!
55
+ current.finalize!
56
+ current
57
+ end
58
+ warn_for_configuration!(config)
52
59
  end
53
60
 
54
61
  def reset_configuration!
55
- @configuration = Configuration.new
62
+ CONFIGURATION_MUTEX.synchronize { @configuration = Configuration.new }
63
+ end
64
+
65
+ def enforce_budget!
66
+ Tracker.enforce_budget!
56
67
  end
57
68
 
58
- # Track an LLM request manually for non-Faraday clients.
59
- #
60
- # LlmCostTracker.track(
61
- # provider: :openai,
62
- # model: "gpt-4o",
63
- # input_tokens: 150,
64
- # output_tokens: 50,
65
- # feature: "chat",
66
- # user_id: current_user.id
67
- # )
68
- #
69
- # @param provider [String, Symbol] Provider name, such as :openai or :anthropic.
70
- # @param model [String] Provider model identifier.
71
- # @param input_tokens [Integer] Billed input token count.
72
- # @param output_tokens [Integer] Billed output token count.
73
- # @param latency_ms [Integer, nil] Optional request latency in milliseconds.
74
- # @param metadata [Hash] Attribution tags and provider-specific usage metadata.
75
- # @return [LlmCostTracker::Event] The tracked event.
76
- def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, **metadata)
69
+ def track(provider:, model:, input_tokens:, output_tokens:, latency_ms: nil, stream: false, usage_source: :manual,
70
+ enforce_budget: false, provider_response_id: nil, **metadata)
71
+ enforce_budget! if enforce_budget
77
72
  Tracker.record(
78
73
  provider: provider.to_s,
79
74
  model: model,
80
75
  input_tokens: input_tokens,
81
76
  output_tokens: output_tokens,
82
77
  latency_ms: latency_ms,
78
+ stream: stream,
79
+ usage_source: usage_source,
80
+ provider_response_id: provider_response_id,
81
+ metadata: metadata
82
+ )
83
+ end
84
+
85
+ def track_stream(provider:, model:, latency_ms: nil, enforce_budget: false, provider_response_id: nil, **metadata)
86
+ require_relative "llm_cost_tracker/stream_collector"
87
+ enforce_budget! if enforce_budget
88
+ collector = StreamCollector.new(
89
+ provider: provider.to_s,
90
+ model: model,
91
+ latency_ms: latency_ms,
92
+ provider_response_id: provider_response_id,
83
93
  metadata: metadata
84
94
  )
95
+ yield collector
96
+ collector.finish!
97
+ rescue StandardError
98
+ collector&.finish!(errored: true)
99
+ raise
85
100
  end
86
101
 
87
102
  private
88
103
 
89
- def warn_for_configuration!
90
- return unless configuration.budget_exceeded_behavior == :block_requests
91
- return if configuration.active_record?
104
+ def warn_for_configuration!(config = configuration)
105
+ return unless config.budget_exceeded_behavior == :block_requests
106
+ return if config.active_record?
92
107
 
93
108
  Logging.warn(":block_requests requires storage_backend = :active_record; preflight blocking will be skipped.")
94
109
  end
95
110
  end
96
111
  end
97
112
 
98
- # Load Railtie if Rails is present
99
113
  require_relative "llm_cost_tracker/railtie" if defined?(Rails::Railtie)
100
114
 
101
- # Auto-register Faraday middleware
102
115
  if defined?(Faraday)
103
116
  Faraday::Middleware.register_middleware(
104
117
  llm_cost_tracker: LlmCostTracker::Middleware::Faraday