llm_cost_tracker 0.12.0 → 0.13.0

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.
@@ -9,22 +9,36 @@ module LlmCostTracker
9
9
  module Matcher
10
10
  Match = Data.define(:source, :key, :prices, :matched_by)
11
11
 
12
+ CACHE_LIMIT = 2048
13
+ private_constant :CACHE_LIMIT
14
+
12
15
  class << self
13
16
  def lookup(provider:, model:)
14
17
  provider_name = provider.to_s.presence
15
18
  model_name = model.to_s
16
19
  return nil if model_name.empty?
17
20
 
18
- lookup_match(provider_name: provider_name, model_name: model_name)
21
+ sources = Registry.sources
22
+ reset_cache(sources) unless @cache_sources.equal?(sources)
23
+ key = [provider_name, model_name].freeze
24
+ return @cache[key] if @cache.key?(key)
25
+
26
+ @cache.clear if @cache.size >= CACHE_LIMIT
27
+ @cache[key] = lookup_match(sources, provider_name, model_name)
19
28
  end
20
29
 
21
30
  private
22
31
 
23
- def lookup_match(provider_name:, model_name:)
32
+ def reset_cache(sources)
33
+ @cache_sources = sources
34
+ @cache = {}
35
+ end
36
+
37
+ def lookup_match(sources, provider_name, model_name)
24
38
  provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
25
39
  normalized = normalize_model_name(model_name)
26
40
 
27
- Registry.sources.each do |source|
41
+ sources.each do |source|
28
42
  match = match_in_source(source, provider_model, model_name, normalized)
29
43
  return match if match
30
44
  end
@@ -4,8 +4,8 @@ module LlmCostTracker
4
4
  module Pricing
5
5
  module Mode
6
6
  STANDARD_MODE_VALUES = %w[auto default standard standard_only unspecified].freeze
7
- COMPOUND_MODIFIERS = %w[data_residency].freeze
8
7
  KNOWN_MODIFIERS = %w[batch flex priority scale fast on_demand data_residency].freeze
8
+ HOST_DERIVED_MODIFIERS = %w[data_residency].freeze
9
9
  MAX_PERMUTED_MODIFIERS = 6
10
10
 
11
11
  def self.normalize(value)
@@ -23,7 +23,7 @@ module LlmCostTracker
23
23
  return normalize(request_mode) if provider_mode.to_s.strip.empty?
24
24
 
25
25
  provider_tokens = tokenize(provider_mode) - STANDARD_MODE_VALUES
26
- request_host_tokens = tokenize(request_mode || "") & COMPOUND_MODIFIERS
26
+ request_host_tokens = tokenize(request_mode || "") & HOST_DERIVED_MODIFIERS
27
27
  combined = provider_tokens | request_host_tokens
28
28
  return nil if combined.empty?
29
29
 
@@ -41,12 +41,12 @@ module LlmCostTracker
41
41
  loop do
42
42
  break if remaining.empty?
43
43
 
44
- compound = COMPOUND_MODIFIERS.find do |token|
45
- remaining == token || remaining.start_with?("#{token}_")
44
+ known = KNOWN_MODIFIERS.find do |modifier|
45
+ remaining == modifier || remaining.start_with?("#{modifier}_")
46
46
  end
47
- if compound
48
- tokens << compound
49
- remaining = remaining.delete_prefix(compound).delete_prefix("_")
47
+ if known
48
+ tokens << known
49
+ remaining = remaining.delete_prefix(known).delete_prefix("_")
50
50
  else
51
51
  first, _, rest = remaining.partition("_")
52
52
  tokens << first unless first.empty?
@@ -13,16 +13,15 @@ module LlmCostTracker
13
13
  EVENT_NAME = "llm_request.llm_cost_tracker"
14
14
 
15
15
  class << self
16
- def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil, enforce_budget: false)
16
+ def record(event:, latency_ms: nil, metadata: {}, context_tags: nil, enforce_budget: false)
17
17
  return unless LlmCostTracker.configuration.enabled
18
18
 
19
- pricing_mode ||= event.pricing_mode
20
19
  calculation = Pricing::Calculation.for(
21
20
  provider: event.provider,
22
21
  model: event.model,
23
22
  tokens: event.token_usage,
24
23
  line_items: event.line_items,
25
- pricing_mode: pricing_mode,
24
+ pricing_mode: event.pricing_mode,
26
25
  usage_source: event.usage_source
27
26
  )
28
27
 
@@ -31,6 +31,10 @@ module LlmCostTracker
31
31
  @token_priced ||= all.select(&:token_key).freeze
32
32
  end
33
33
 
34
+ def find_by(kind:, direction:, modality:, cache_state:, unit:)
35
+ by_attributes[[kind, direction, modality, cache_state, unit]]
36
+ end
37
+
34
38
  def token_priced_for(kind:, direction:, cache_state:)
35
39
  token_priced.find do |dimension|
36
40
  dimension.kind == kind && dimension.direction == direction && dimension.cache_state == cache_state
@@ -43,6 +47,13 @@ module LlmCostTracker
43
47
  @index ||= all.to_h { |dimension| [dimension.key, dimension] }.freeze
44
48
  end
45
49
 
50
+ def by_attributes
51
+ @by_attributes ||= all.to_h do |dimension|
52
+ key = [dimension.kind, dimension.direction, dimension.modality, dimension.cache_state, dimension.unit]
53
+ [key, dimension]
54
+ end.freeze
55
+ end
56
+
46
57
  def load_definitions
47
58
  Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
48
59
  .map { |attributes| build(attributes) }
@@ -140,27 +140,3 @@
140
140
  cache_state: none
141
141
  unit: minute
142
142
  rate_basis: per_minute
143
-
144
- - key: image_generation_call
145
- kind: image_generation_call
146
- direction: output
147
- modality: image
148
- cache_state: none
149
- unit: image
150
- rate_basis: per_image
151
-
152
- - key: computer_call
153
- kind: computer_call
154
- direction: neither
155
- modality: text
156
- cache_state: none
157
- unit: request
158
- rate_basis: per_request
159
-
160
- - key: mcp_call
161
- kind: mcp_call
162
- direction: neither
163
- modality: text
164
- cache_state: none
165
- unit: request
166
- rate_basis: per_request
@@ -4,97 +4,42 @@ require_relative "catalog"
4
4
 
5
5
  module LlmCostTracker
6
6
  module Usage
7
- KNOWN_TOKEN_KEYS = (
8
- Catalog.token_priced.map { |dimension| dimension.token_key.to_s } + %w[total_tokens hidden_output_tokens]
9
- ).freeze
10
-
11
- TokenUsage = Data.define(
12
- :input_tokens,
13
- :cache_read_input_tokens,
14
- :cache_write_input_tokens,
15
- :cache_write_extended_input_tokens,
16
- :audio_input_tokens,
17
- :image_input_tokens,
18
- :output_tokens,
19
- :audio_output_tokens,
20
- :image_output_tokens,
21
- :total_tokens,
22
- :hidden_output_tokens
23
- ) do
7
+ TokenUsage = Data.define(*Catalog.token_priced.map(&:token_key), :total_tokens, :hidden_output_tokens) do
24
8
  def priced_quantities
25
9
  Catalog.token_priced.to_h { |dimension| [dimension.key, public_send(dimension.token_key)] }
26
10
  end
27
11
 
12
+ def self.build(**values)
13
+ unknown = values.keys - members
14
+ raise ArgumentError, "unknown token keys: #{unknown.inspect}" if unknown.any?
15
+
16
+ priced = Catalog.token_priced.to_h do |dimension|
17
+ [dimension.token_key, non_negative_int(values[dimension.token_key])]
18
+ end
19
+ subtotal = priced.values.sum
20
+ declared_total = values[:total_tokens]
21
+ total = declared_total ? [non_negative_int(declared_total), subtotal].max : subtotal
22
+ new(**priced, total_tokens: total, hidden_output_tokens: non_negative_int(values[:hidden_output_tokens]))
23
+ end
24
+
28
25
  def self.build_from_tokens(tokens)
29
26
  return tokens if tokens.is_a?(self)
30
27
  raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
31
28
 
32
29
  values = tokens.to_h.transform_keys(&:to_s)
33
- warn_on_unknown_keys(values)
34
- token_attributes = Catalog.token_priced.to_h do |dimension|
35
- [dimension.token_key, values.fetch(dimension.token_key.to_s, 0)]
30
+ known = members.map(&:to_s)
31
+ unknown = values.keys - known
32
+ if unknown.any?
33
+ hint = values.keys.intersect?(known) ? "" : ". Did you pass a raw provider response?"
34
+ raise ArgumentError, "unknown token keys: #{unknown.inspect}; expected #{known.inspect}#{hint}"
36
35
  end
37
36
 
38
- build(
39
- **token_attributes,
40
- total_tokens: values["total_tokens"],
41
- hidden_output_tokens: values.fetch("hidden_output_tokens", 0)
42
- )
43
- end
44
-
45
- def self.warn_on_unknown_keys(values)
46
- return if values.empty?
47
- return if values.keys.intersect?(KNOWN_TOKEN_KEYS)
48
-
49
- Logging.warn(
50
- "tokens hash contains no recognized keys (#{values.keys.inspect}); " \
51
- "expected one of #{KNOWN_TOKEN_KEYS.inspect}. Did you pass a raw provider response?"
52
- )
37
+ build(**values.transform_keys(&:to_sym))
53
38
  end
54
39
 
55
40
  def self.non_negative_int(value)
56
41
  [value.to_i, 0].max
57
42
  end
58
-
59
- def self.build(input_tokens:,
60
- output_tokens:,
61
- cache_read_input_tokens: 0,
62
- cache_write_input_tokens: 0,
63
- cache_write_extended_input_tokens: 0,
64
- audio_input_tokens: 0,
65
- audio_output_tokens: 0,
66
- image_input_tokens: 0,
67
- image_output_tokens: 0,
68
- total_tokens: nil,
69
- hidden_output_tokens: 0)
70
- input = non_negative_int(input_tokens)
71
- output = non_negative_int(output_tokens)
72
- cache_read = non_negative_int(cache_read_input_tokens)
73
- cache_write = non_negative_int(cache_write_input_tokens)
74
- cache_write_extended = non_negative_int(cache_write_extended_input_tokens)
75
- audio_input = non_negative_int(audio_input_tokens)
76
- audio_output = non_negative_int(audio_output_tokens)
77
- image_input = non_negative_int(image_input_tokens)
78
- image_output = non_negative_int(image_output_tokens)
79
- hidden_output = non_negative_int(hidden_output_tokens)
80
- calculated_total = input + cache_read + cache_write + cache_write_extended +
81
- audio_input + image_input + output + audio_output + image_output
82
- total = total_tokens ? [non_negative_int(total_tokens), calculated_total].max : calculated_total
83
-
84
- new(
85
- input_tokens: input,
86
- cache_read_input_tokens: cache_read,
87
- cache_write_input_tokens: cache_write,
88
- cache_write_extended_input_tokens: cache_write_extended,
89
- audio_input_tokens: audio_input,
90
- image_input_tokens: image_input,
91
- output_tokens: output,
92
- audio_output_tokens: audio_output,
93
- image_output_tokens: image_output,
94
- total_tokens: total,
95
- hidden_output_tokens: hidden_output
96
- )
97
- end
98
43
  end
99
44
  end
100
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -30,7 +30,7 @@ require_relative "llm_cost_tracker/ingestion"
30
30
  require_relative "llm_cost_tracker/tracker"
31
31
 
32
32
  module LlmCostTracker
33
- autoload :Engine, "llm_cost_tracker/engine"
33
+ require_relative "llm_cost_tracker/engine"
34
34
  autoload :Doctor, "llm_cost_tracker/doctor"
35
35
  autoload :CaptureVerifier, "llm_cost_tracker/capture_verifier"
36
36
  autoload :Report, "llm_cost_tracker/report"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_cost_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergii Khomenko