llm_cost_tracker 0.5.2 → 0.5.3

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +8 -3
  4. data/docs/architecture.md +28 -0
  5. data/docs/budgets.md +45 -0
  6. data/docs/configuration.md +65 -0
  7. data/docs/cookbook.md +185 -0
  8. data/docs/dashboard-overview.png +0 -0
  9. data/docs/dashboard.md +38 -0
  10. data/docs/extending.md +32 -0
  11. data/docs/operations.md +44 -0
  12. data/docs/pricing.md +94 -0
  13. data/docs/querying.md +36 -0
  14. data/docs/streaming.md +70 -0
  15. data/docs/technical/README.md +10 -0
  16. data/docs/technical/data-flow.md +67 -0
  17. data/docs/technical/extension-points.md +111 -0
  18. data/docs/technical/module-map.md +197 -0
  19. data/docs/technical/operational-notes.md +77 -0
  20. data/docs/upgrading.md +46 -0
  21. data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
  22. data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
  23. data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
  24. data/lib/llm_cost_tracker/configuration.rb +2 -1
  25. data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
  26. data/lib/llm_cost_tracker/doctor.rb +6 -1
  27. data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
  28. data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
  29. data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
  30. data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
  31. data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
  32. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
  33. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  34. data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
  35. data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
  36. data/lib/llm_cost_tracker/pricing/lookup.rb +110 -0
  37. data/lib/llm_cost_tracker/pricing.rb +25 -108
  38. data/lib/llm_cost_tracker/retention.rb +3 -9
  39. data/lib/llm_cost_tracker/storage/active_record_backend.rb +115 -0
  40. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +42 -0
  41. data/lib/llm_cost_tracker/storage/active_record_store.rb +26 -0
  42. data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
  43. data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
  44. data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
  45. data/lib/llm_cost_tracker/storage/registry.rb +63 -0
  46. data/lib/llm_cost_tracker/tag_sql.rb +34 -0
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +3 -0
  49. data/lib/tasks/llm_cost_tracker.rake +49 -0
  50. metadata +32 -2
@@ -4,10 +4,12 @@ require_relative "errors"
4
4
  require_relative "tag_key"
5
5
  require_relative "value_helpers"
6
6
  require_relative "configuration/instrumentation"
7
+ require_relative "configuration/storage_backend"
7
8
 
8
9
  module LlmCostTracker
9
10
  class Configuration
10
11
  include ConfigurationInstrumentation
12
+ include ConfigurationStorageBackend
11
13
 
12
14
  OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
13
15
 
@@ -18,7 +20,6 @@ module LlmCostTracker
18
20
  SHARED_SCALAR_ATTRIBUTES = %i[enabled custom_storage on_budget_exceeded monthly_budget daily_budget per_call_budget
19
21
  log_level prices_file max_tag_count max_tag_value_bytesize].freeze
20
22
  SHARED_ENUM_ATTRIBUTES = {
21
- storage_backend: [STORAGE_BACKENDS, :log],
22
23
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
23
24
  storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
24
25
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Doctor
5
+ class CaptureCheck
6
+ def self.call(check_class)
7
+ new(check_class).call
8
+ end
9
+
10
+ def initialize(check_class)
11
+ @check_class = check_class
12
+ end
13
+
14
+ def call
15
+ config = LlmCostTracker.configuration
16
+ return disabled_check unless config.enabled
17
+ return integrations_check(config.instrumented_integrations) if config.instrumented_integrations.any?
18
+
19
+ check(:ok, "no SDK integrations enabled; Faraday middleware and manual capture remain available")
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :check_class
25
+
26
+ def disabled_check
27
+ check(:warn, "tracking is disabled; set config.enabled = true to record calls")
28
+ end
29
+
30
+ def integrations_check(integrations)
31
+ check(:ok, "SDK integrations enabled: #{integrations.join(', ')}")
32
+ end
33
+
34
+ def check(status, message)
35
+ check_class.new(status, "capture", message)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "price_freshness"
4
+ require_relative "doctor/capture_check"
4
5
 
5
6
  module LlmCostTracker
6
7
  class Doctor
@@ -38,6 +39,7 @@ module LlmCostTracker
38
39
  def checks
39
40
  [
40
41
  configuration_check,
42
+ capture_check,
41
43
  *integration_checks,
42
44
  active_record_check,
43
45
  table_check,
@@ -51,9 +53,12 @@ module LlmCostTracker
51
53
  private
52
54
 
53
55
  def configuration_check
54
- Check.new(:ok, "configuration", "storage_backend=#{LlmCostTracker.configuration.storage_backend.inspect}")
56
+ config = LlmCostTracker.configuration
57
+ Check.new(:ok, "configuration", "storage_backend=#{config.storage_backend.inspect}, enabled=#{config.enabled}")
55
58
  end
56
59
 
60
+ def capture_check = CaptureCheck.call(Check)
61
+
57
62
  def integration_checks
58
63
  LlmCostTracker::Integrations.checks.map do |check|
59
64
  Check.new(check.status, check.name.to_s, check.message)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "stream_tracker"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Integrations
@@ -16,11 +17,11 @@ module LlmCostTracker
16
17
 
17
18
  def patch_targets
18
19
  [
19
- patch_target("Anthropic::Resources::Messages", with: MessagesPatch, methods: :create),
20
+ patch_target("Anthropic::Resources::Messages", with: MessagesPatch, methods: %i[create stream stream_raw]),
20
21
  patch_target(
21
22
  "Anthropic::Resources::Beta::Messages",
22
23
  with: MessagesPatch,
23
- methods: :create,
24
+ methods: %i[create stream stream_raw],
24
25
  optional: true
25
26
  )
26
27
  ]
@@ -64,6 +65,28 @@ module LlmCostTracker
64
65
  ObjectReader.nested(usage, :output_tokens_details, :reasoning_tokens)
65
66
  )
66
67
  end
68
+
69
+ def track_stream(stream, collector:)
70
+ return stream unless active?
71
+
72
+ StreamTracker.wrap(
73
+ stream,
74
+ collector: collector,
75
+ active: -> { active? },
76
+ finish: ->(errored:) { finish_stream(collector, errored: errored) }
77
+ )
78
+ end
79
+
80
+ def stream_collector(request)
81
+ LlmCostTracker::StreamCollector.new(
82
+ provider: "anthropic",
83
+ model: request[:model] || request["model"]
84
+ )
85
+ end
86
+
87
+ def finish_stream(collector, errored:)
88
+ record_safely { collector.finish!(errored: errored) }
89
+ end
67
90
  end
68
91
 
69
92
  module MessagesPatch
@@ -78,6 +101,22 @@ module LlmCostTracker
78
101
  )
79
102
  message
80
103
  end
104
+
105
+ def stream(*args, **kwargs)
106
+ request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
107
+ collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
108
+ LlmCostTracker::Integrations::Anthropic.enforce_budget!
109
+ stream = super
110
+ LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
111
+ end
112
+
113
+ def stream_raw(*args, **kwargs)
114
+ request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
115
+ collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
116
+ LlmCostTracker::Integrations::Anthropic.enforce_budget!
117
+ stream = super
118
+ LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
119
+ end
81
120
  end
82
121
  end
83
122
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "stream_tracker"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Integrations
@@ -16,8 +17,16 @@ module LlmCostTracker
16
17
 
17
18
  def patch_targets
18
19
  [
19
- patch_target("OpenAI::Resources::Responses", with: ResponsesPatch, methods: :create),
20
- patch_target("OpenAI::Resources::Chat::Completions", with: ChatCompletionsPatch, methods: :create)
20
+ patch_target(
21
+ "OpenAI::Resources::Responses",
22
+ with: ResponsesPatch,
23
+ methods: %i[create stream stream_raw retrieve_streaming]
24
+ ),
25
+ patch_target(
26
+ "OpenAI::Resources::Chat::Completions",
27
+ with: ChatCompletionsPatch,
28
+ methods: %i[create stream_raw]
29
+ )
21
30
  ]
22
31
  end
23
32
 
@@ -70,6 +79,28 @@ module LlmCostTracker
70
79
  def regular_input_tokens(input_tokens, cache_read)
71
80
  [ObjectReader.integer(input_tokens) - cache_read.to_i, 0].max
72
81
  end
82
+
83
+ def track_stream(stream, collector:)
84
+ return stream unless active?
85
+
86
+ StreamTracker.wrap(
87
+ stream,
88
+ collector: collector,
89
+ active: -> { active? },
90
+ finish: ->(errored:) { finish_stream(collector, errored: errored) }
91
+ )
92
+ end
93
+
94
+ def stream_collector(request)
95
+ LlmCostTracker::StreamCollector.new(
96
+ provider: "openai",
97
+ model: request[:model] || request["model"]
98
+ )
99
+ end
100
+
101
+ def finish_stream(collector, errored:)
102
+ record_safely { collector.finish!(errored: errored) }
103
+ end
73
104
  end
74
105
 
75
106
  module ResponsesPatch
@@ -84,6 +115,31 @@ module LlmCostTracker
84
115
  )
85
116
  response
86
117
  end
118
+
119
+ def stream(*args, **kwargs)
120
+ request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
121
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
122
+ LlmCostTracker::Integrations::Openai.enforce_budget!
123
+ stream = super
124
+ LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
125
+ end
126
+
127
+ def stream_raw(*args, **kwargs)
128
+ request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
129
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
130
+ LlmCostTracker::Integrations::Openai.enforce_budget!
131
+ stream = super
132
+ LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
133
+ end
134
+
135
+ def retrieve_streaming(response_id, *args, **kwargs)
136
+ request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
137
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
138
+ collector.provider_response_id = response_id
139
+ LlmCostTracker::Integrations::Openai.enforce_budget!
140
+ stream = super
141
+ LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
142
+ end
87
143
  end
88
144
 
89
145
  module ChatCompletionsPatch
@@ -98,6 +154,14 @@ module LlmCostTracker
98
154
  )
99
155
  response
100
156
  end
157
+
158
+ def stream_raw(*args, **kwargs)
159
+ request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
160
+ collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
161
+ LlmCostTracker::Integrations::Openai.enforce_budget!
162
+ stream = super
163
+ LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
164
+ end
101
165
  end
102
166
  end
103
167
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
5
+ require_relative "../errors"
3
6
  require_relative "openai"
4
7
  require_relative "anthropic"
5
8
  require_relative "ruby_llm"
@@ -7,14 +10,22 @@ require_relative "ruby_llm"
7
10
  module LlmCostTracker
8
11
  module Integrations
9
12
  module Registry
10
- INTEGRATIONS = {
13
+ DEFAULT_INTEGRATIONS = {
11
14
  openai: Openai,
12
15
  anthropic: Anthropic,
13
16
  ruby_llm: RubyLlm
14
17
  }.freeze
18
+ MUTEX = Monitor.new
15
19
 
16
20
  module_function
17
21
 
22
+ def register(name, integration)
23
+ key = name.to_sym
24
+ validate_integration!(integration)
25
+ MUTEX.synchronize { @integrations = integrations.merge(key => integration).freeze }
26
+ integration
27
+ end
28
+
18
29
  def install!(names = LlmCostTracker.configuration.instrumented_integrations)
19
30
  normalize(names).each { |name| fetch(name).install }
20
31
  end
@@ -30,13 +41,32 @@ module LlmCostTracker
30
41
  end
31
42
 
32
43
  def fetch(name)
33
- INTEGRATIONS.fetch(name.to_sym) do
34
- message = "Unknown integration: #{name.inspect}. Use one of: #{INTEGRATIONS.keys.join(', ')}"
44
+ integrations.fetch(name.to_sym) do
45
+ message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
35
46
  raise LlmCostTracker::Error, message
36
47
  end
37
48
  end
49
+
50
+ def names
51
+ integrations.keys
52
+ end
53
+
54
+ def reset!
55
+ MUTEX.synchronize { @integrations = DEFAULT_INTEGRATIONS.dup.freeze }
56
+ end
57
+
58
+ def integrations
59
+ @integrations || MUTEX.synchronize { @integrations ||= DEFAULT_INTEGRATIONS.dup.freeze }
60
+ end
61
+
62
+ def validate_integration!(integration)
63
+ return if integration.respond_to?(:install) && integration.respond_to?(:status)
64
+
65
+ raise ArgumentError, "integration must respond to install and status"
66
+ end
38
67
  end
39
68
 
69
+ def self.register(name, integration) = Registry.register(name, integration)
40
70
  def self.install! = Registry.install!
41
71
  def self.checks = Registry.checks
42
72
  end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ require_relative "../logging"
6
+ require_relative "../stream_collector"
7
+ require_relative "../value_helpers"
8
+ require_relative "object_reader"
9
+
10
+ module LlmCostTracker
11
+ module Integrations
12
+ class StreamTracker
13
+ def self.wrap(stream, collector:, active:, finish: nil) = new(stream, collector, active, finish).wrap
14
+
15
+ def initialize(stream, collector, active, finish)
16
+ @stream = stream
17
+ @collector = collector
18
+ @active = active
19
+ @finish = finish || proc { |errored:| @collector.finish!(errored: errored) }
20
+ @finished = false
21
+ @capture_failed = false
22
+ @monitor = Monitor.new
23
+ end
24
+
25
+ def wrap
26
+ return @stream unless @stream
27
+
28
+ iterator_wrapped = @stream.instance_variable_defined?(:@iterator) && wrap_iterator?
29
+ wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
30
+
31
+ @stream
32
+ rescue StandardError => e
33
+ Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
34
+ @stream
35
+ end
36
+
37
+ private
38
+
39
+ def wrap_iterator?
40
+ iterator = @stream.instance_variable_get(:@iterator)
41
+ return false unless iterator.respond_to?(:each)
42
+
43
+ @stream.instance_variable_set(:@iterator, tracked_iterator(iterator))
44
+ true
45
+ end
46
+
47
+ def wrap_each
48
+ tracker = self
49
+ original_each = @stream.method(:each)
50
+ @stream.define_singleton_method(:each) do |&block|
51
+ next enum_for(:each) unless block
52
+
53
+ tracker.__send__(:each_from, original_each, &block)
54
+ end
55
+ end
56
+
57
+ def tracked_iterator(iterator)
58
+ Enumerator.new do |yielder|
59
+ each_from(iterator) { |event| yielder << event }
60
+ end
61
+ end
62
+
63
+ def each_from(iterable)
64
+ errored = false
65
+ iterate(iterable) do |event|
66
+ capture(event)
67
+ yield event
68
+ end
69
+ rescue StandardError
70
+ errored = true
71
+ raise
72
+ ensure
73
+ finish!(errored: errored)
74
+ end
75
+
76
+ def iterate(iterable, &)
77
+ if iterable.respond_to?(:each)
78
+ iterable.each(&)
79
+ else
80
+ iterable.call(&)
81
+ end
82
+ end
83
+
84
+ def capture(event)
85
+ payload = normalize(event_payload(event))
86
+ @collector.event(payload, type: event_type(event, payload))
87
+ rescue StandardError => e
88
+ warn_capture_failure(e)
89
+ end
90
+
91
+ def event_payload(event)
92
+ if event.respond_to?(:deep_to_h)
93
+ event.deep_to_h
94
+ elsif event.respond_to?(:to_h)
95
+ event.to_h
96
+ else
97
+ event_attributes(event)
98
+ end
99
+ end
100
+
101
+ def event_attributes(event)
102
+ %i[type id model usage response message].each_with_object({}) do |key, attributes|
103
+ value = ObjectReader.read(event, key)
104
+ attributes[key] = value unless value.nil?
105
+ end
106
+ end
107
+
108
+ def event_type(event, payload)
109
+ value = ObjectReader.first(event, :type) || payload["type"]
110
+ value&.to_s
111
+ end
112
+
113
+ def normalize(value)
114
+ case value
115
+ when Hash
116
+ value.each_with_object({}) do |(key, nested), normalized|
117
+ normalized[key.to_s] = normalize(nested)
118
+ end
119
+ when Array
120
+ value.map { |nested| normalize(nested) }
121
+ when Symbol
122
+ value.to_s
123
+ when NilClass
124
+ nil
125
+ else
126
+ converted = object_hash(value)
127
+ converted ? normalize(converted) : ValueHelpers.deep_dup(value)
128
+ end
129
+ end
130
+
131
+ def object_hash(value)
132
+ if value.respond_to?(:deep_to_h)
133
+ value.deep_to_h
134
+ elsif value.respond_to?(:to_h)
135
+ value.to_h
136
+ end
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ def warn_capture_failure(error)
142
+ should_warn = @monitor.synchronize do
143
+ next false if @capture_failed
144
+
145
+ @capture_failed = true
146
+ true
147
+ end
148
+ return unless should_warn
149
+
150
+ Logging.warn("stream integration failed to capture event: #{error.class}: #{error.message}")
151
+ end
152
+
153
+ def finish!(errored:)
154
+ should_finish = @monitor.synchronize do
155
+ next false if @finished
156
+
157
+ @finished = true
158
+ true
159
+ end
160
+ return unless should_finish && @active.call
161
+
162
+ @finish.call(errored: errored)
163
+ end
164
+ end
165
+ end
166
+ end
@@ -2,9 +2,9 @@
2
2
 
3
3
  require "active_record"
4
4
 
5
+ require_relative "llm_api_call_metrics"
5
6
  require_relative "period_grouping"
6
7
  require_relative "tag_accessors"
7
- require_relative "tag_key"
8
8
  require_relative "tag_query"
9
9
  require_relative "tags_column"
10
10
 
@@ -12,6 +12,7 @@ module LlmCostTracker
12
12
  class LlmApiCall < ActiveRecord::Base
13
13
  extend PeriodGrouping
14
14
  extend TagsColumn
15
+ extend LlmApiCallMetrics
15
16
  include TagAccessors
16
17
 
17
18
  self.table_name = "llm_api_calls"
@@ -55,82 +56,5 @@ module LlmCostTracker
55
56
  def self.by_tags(tags)
56
57
  TagQuery.apply(self, tags)
57
58
  end
58
-
59
- def self.total_cost
60
- sum(:total_cost).to_f
61
- end
62
-
63
- def self.total_tokens
64
- sum(:total_tokens).to_i
65
- end
66
-
67
- def self.cost_by_model
68
- group(:model).sum(:total_cost)
69
- end
70
-
71
- def self.cost_by_provider
72
- group(:provider).sum(:total_cost)
73
- end
74
-
75
- def self.group_by_tag(key)
76
- group(Arel.sql(tag_value_expression(key)))
77
- end
78
-
79
- def self.cost_by_tag(key, limit: nil)
80
- relation = group_by_tag(key).order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
81
- relation = relation.limit(limit) if limit
82
-
83
- costs = relation.sum(:total_cost).each_with_object(Hash.new(0.0)) do |(tag_value, cost), grouped|
84
- grouped[tag_value_label(tag_value)] += cost.to_f
85
- end
86
- costs.sort_by { |_label, cost| -cost }.to_h
87
- end
88
-
89
- def self.average_latency_ms
90
- return nil unless latency_column?
91
-
92
- average(:latency_ms)&.to_f
93
- end
94
-
95
- def self.latency_by_model
96
- return {} unless latency_column?
97
-
98
- group(:model).average(:latency_ms).transform_values(&:to_f)
99
- end
100
-
101
- def self.latency_by_provider
102
- return {} unless latency_column?
103
-
104
- group(:provider).average(:latency_ms).transform_values(&:to_f)
105
- end
106
-
107
- def self.tag_value_label(value)
108
- value.nil? || value == "" ? "(untagged)" : value.to_s
109
- end
110
-
111
- def self.tag_value_expression(key, table_name: quoted_table_name)
112
- key = validated_tag_key(key)
113
- column = "#{table_name}.#{connection.quote_column_name('tags')}"
114
-
115
- case connection.adapter_name
116
- when /postgres/i
117
- json_column = tags_jsonb_column? ? column : "(#{column})::jsonb"
118
- "#{json_column}->>#{connection.quote(key)}"
119
- when /mysql/i
120
- "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{connection.quote(json_path(key))}))"
121
- else
122
- "json_extract(#{column}, #{connection.quote(json_path(key))})"
123
- end
124
- end
125
-
126
- def self.validated_tag_key(key)
127
- TagKey.validate!(key)
128
- end
129
- private_class_method :validated_tag_key
130
-
131
- def self.json_path(key)
132
- "$.\"#{key}\""
133
- end
134
- private_class_method :json_path
135
59
  end
136
60
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tag_sql"
4
+
5
+ module LlmCostTracker
6
+ module LlmApiCallMetrics
7
+ def total_cost
8
+ sum(:total_cost).to_f
9
+ end
10
+
11
+ def total_tokens
12
+ sum(:total_tokens).to_i
13
+ end
14
+
15
+ def cost_by_model
16
+ group(:model).sum(:total_cost)
17
+ end
18
+
19
+ def cost_by_provider
20
+ group(:provider).sum(:total_cost)
21
+ end
22
+
23
+ def group_by_tag(key)
24
+ group(Arel.sql(tag_value_expression(key)))
25
+ end
26
+
27
+ def cost_by_tag(key, limit: nil)
28
+ relation = group_by_tag(key).order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
29
+ relation = relation.limit(limit) if limit
30
+
31
+ costs = relation.sum(:total_cost).each_with_object(Hash.new(0.0)) do |(tag_value, cost), grouped|
32
+ grouped[tag_value_label(tag_value)] += cost.to_f
33
+ end
34
+ costs.sort_by { |_label, cost| -cost }.to_h
35
+ end
36
+
37
+ def average_latency_ms
38
+ return nil unless latency_column?
39
+
40
+ average(:latency_ms)&.to_f
41
+ end
42
+
43
+ def latency_by_model
44
+ return {} unless latency_column?
45
+
46
+ group(:model).average(:latency_ms).transform_values(&:to_f)
47
+ end
48
+
49
+ def latency_by_provider
50
+ return {} unless latency_column?
51
+
52
+ group(:provider).average(:latency_ms).transform_values(&:to_f)
53
+ end
54
+
55
+ def tag_value_label(value)
56
+ TagSql.value_label(value)
57
+ end
58
+
59
+ def tag_value_expression(key, table_name: quoted_table_name)
60
+ TagSql.value_expression(self, key, table_name: table_name)
61
+ end
62
+ end
63
+ end
@@ -61,7 +61,7 @@ module LlmCostTracker
61
61
 
62
62
  def detect_stream_usage(events)
63
63
  find_event_value(events, reverse: true) do |data|
64
- usage = data["usage"]
64
+ usage = data["usage"] || data.dig("response", "usage")
65
65
  usage if usage.is_a?(Hash)
66
66
  end
67
67
  end