llm_cost_tracker 0.14.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +16 -5
  4. data/app/assets/llm_cost_tracker/application.css +16 -0
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +1 -3
  6. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  7. data/app/controllers/llm_cost_tracker/tags_controller.rb +5 -4
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +4 -25
  9. data/app/helpers/llm_cost_tracker/chart_helper.rb +6 -10
  10. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +8 -28
  11. data/app/models/llm_cost_tracker/call.rb +10 -14
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +4 -15
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -9
  14. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  15. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  16. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +1 -1
  17. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +2 -3
  18. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -0
  19. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -2
  21. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -22
  22. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  23. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  24. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  25. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  26. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  27. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -4
  28. data/app/views/llm_cost_tracker/tags/show.html.erb +5 -5
  29. data/lib/llm_cost_tracker/budget/per_tag.rb +15 -9
  30. data/lib/llm_cost_tracker/budget.rb +44 -55
  31. data/lib/llm_cost_tracker/capture/event_window.rb +3 -2
  32. data/lib/llm_cost_tracker/capture/sdk_payload.rb +5 -1
  33. data/lib/llm_cost_tracker/capture/stream_collector.rb +25 -26
  34. data/lib/llm_cost_tracker/capture/stream_tracker.rb +9 -23
  35. data/lib/llm_cost_tracker/capture_verifier.rb +1 -7
  36. data/lib/llm_cost_tracker/charges/line_item.rb +5 -1
  37. data/lib/llm_cost_tracker/configuration/budgets.rb +1 -1
  38. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  39. data/lib/llm_cost_tracker/configuration.rb +5 -9
  40. data/lib/llm_cost_tracker/doctor/price_check.rb +13 -3
  41. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  42. data/lib/llm_cost_tracker/doctor.rb +19 -2
  43. data/lib/llm_cost_tracker/engine.rb +0 -1
  44. data/lib/llm_cost_tracker/event.rb +8 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  48. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  49. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  50. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +1 -1
  51. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +11 -9
  52. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -1
  53. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -1
  54. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +1 -1
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -1
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  62. data/lib/llm_cost_tracker/ingestion/inbox.rb +2 -8
  63. data/lib/llm_cost_tracker/ingestion.rb +3 -1
  64. data/lib/llm_cost_tracker/integrations/anthropic.rb +63 -10
  65. data/lib/llm_cost_tracker/integrations/base.rb +39 -18
  66. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +16 -13
  67. data/lib/llm_cost_tracker/integrations/openai/patches.rb +8 -0
  68. data/lib/llm_cost_tracker/integrations/openai.rb +42 -59
  69. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +241 -76
  70. data/lib/llm_cost_tracker/integrations.rb +1 -20
  71. data/lib/llm_cost_tracker/ledger/isolation.rb +0 -7
  72. data/lib/llm_cost_tracker/ledger/period/totals.rb +14 -10
  73. data/lib/llm_cost_tracker/ledger/rollups.rb +14 -14
  74. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +14 -3
  75. data/lib/llm_cost_tracker/ledger/schema/base.rb +5 -4
  76. data/lib/llm_cost_tracker/ledger/store.rb +2 -2
  77. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +1 -5
  78. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  79. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  80. data/lib/llm_cost_tracker/middleware/faraday.rb +36 -19
  81. data/lib/llm_cost_tracker/parsers.rb +7 -40
  82. data/lib/llm_cost_tracker/prices.json +2237 -154
  83. data/lib/llm_cost_tracker/pricing/backfill.rb +46 -13
  84. data/lib/llm_cost_tracker/pricing/calculation.rb +118 -75
  85. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  86. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  87. data/lib/llm_cost_tracker/pricing/matcher.rb +36 -22
  88. data/lib/llm_cost_tracker/pricing/mode.rb +2 -13
  89. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  90. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  91. data/lib/llm_cost_tracker/pricing/registry.rb +5 -16
  92. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  93. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  94. data/lib/llm_cost_tracker/pricing/sync.rb +22 -71
  95. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +33 -3
  96. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +11 -5
  97. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +81 -12
  98. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -7
  99. data/lib/llm_cost_tracker/providers/gemini/parser.rb +172 -48
  100. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +13 -22
  101. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -1
  102. data/lib/llm_cost_tracker/providers/openai/parser.rb +11 -13
  103. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +96 -26
  104. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +57 -40
  105. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +14 -1
  106. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +3 -1
  107. data/lib/llm_cost_tracker/report/data.rb +1 -1
  108. data/lib/llm_cost_tracker/retention.rb +1 -3
  109. data/lib/llm_cost_tracker/tracker.rb +17 -9
  110. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  111. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  112. data/lib/llm_cost_tracker/usage/dimensions.yml +61 -0
  113. data/lib/llm_cost_tracker/version.rb +1 -1
  114. data/lib/tasks/llm_cost_tracker.rake +24 -21
  115. data/llm_cost_tracker.gemspec +63 -0
  116. metadata +8 -7
@@ -13,9 +13,12 @@ module LlmCostTracker
13
13
  RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost)
14
14
 
15
15
  DEFAULT_BATCH_SIZE = 500
16
+ REGISTRY_SOURCES = %w[pricing_overrides prices_file bundled].freeze
17
+ RATE_FIELDS = %i[rate_amount rate_quantity cost cost_status price_key price_source price_source_version].freeze
18
+ private_constant :REGISTRY_SOURCES, :RATE_FIELDS
16
19
 
17
20
  class << self
18
- def call(scope: default_scope, batch_size: DEFAULT_BATCH_SIZE)
21
+ def call(scope: default_scope, batch_size: DEFAULT_BATCH_SIZE, reprice: false)
19
22
  examined = 0
20
23
  recomputed = 0
21
24
 
@@ -24,11 +27,11 @@ module LlmCostTracker
24
27
  LlmCostTracker::Call.transaction do
25
28
  batch.each do |call|
26
29
  examined += 1
27
- calculation = recompute_for(call)
30
+ calculation = recompute_for(call, reprice: reprice)
28
31
  next unless calculation
29
32
 
30
- persist!(call, calculation)
31
33
  rollup_events << rollup_event_for(call, calculation)
34
+ persist!(call, calculation)
32
35
  recomputed += 1
33
36
  end
34
37
  Ledger::Rollups.increment!(rollup_events)
@@ -39,21 +42,46 @@ module LlmCostTracker
39
42
  end
40
43
 
41
44
  def default_scope
42
- LlmCostTracker::Call.where(total_cost: nil)
45
+ with_known_usage(LlmCostTracker::Call.unknown_pricing)
46
+ end
47
+
48
+ def reprice_scope(range)
49
+ with_known_usage(LlmCostTracker::Call.where(tracked_at: range))
43
50
  end
44
51
 
45
52
  private
46
53
 
47
- def recompute_for(call)
54
+ def with_known_usage(calls)
55
+ calls.where(usage_source: nil).or(calls.where.not(usage_source: Usage::Source::UNKNOWN))
56
+ end
57
+
58
+ def recompute_for(call, reprice:)
48
59
  calculation = Pricing::Calculation.for(
49
60
  provider: call.provider,
50
61
  model: call.model,
51
62
  tokens: token_usage_from(call),
52
- line_items: service_line_items_from(call),
53
- pricing_mode: call.pricing_mode,
54
- usage_source: call.usage_source
63
+ line_items: line_items_from(call, reprice: reprice),
64
+ pricing_mode: call.pricing_mode || bedrock_pricing_mode(call),
65
+ usage_source: call.usage_source,
66
+ at: call.tracked_at
55
67
  )
56
- calculation if calculation.token_cost
68
+ return unless calculation.token_cost || provider_billed?(calculation)
69
+ return if [calculation.cost.total, calculation.cost_status] == [call.total_cost, call.cost_status]
70
+ return calculation if reprice
71
+
72
+ rates = calculation.priced_line_items.to_h { |item| [dimension_key(item), item.rate_amount] }
73
+ recorded = call.line_items.select { |record| record.unit == "token" && record.rate_amount }
74
+ calculation if recorded.all? { |record| record.rate_amount == rates[dimension_key(record)] }
75
+ end
76
+
77
+ def provider_billed?(calculation)
78
+ calculation.priced_line_items.any? { |item| item.kind == "billed_request" && item.priced? }
79
+ end
80
+
81
+ def bedrock_pricing_mode(call)
82
+ return unless call.provider == "bedrock"
83
+
84
+ Providers::Anthropic::UsageExtractor.pricing_mode(request: { model: call.model }, usage: {})
57
85
  end
58
86
 
59
87
  def persist!(call, calculation)
@@ -103,7 +131,7 @@ module LlmCostTracker
103
131
  provider: call.provider,
104
132
  tracked_at: call.tracked_at,
105
133
  pricing_snapshot: calculation.snapshot,
106
- total_cost: calculation.cost.total
134
+ total_cost: calculation.cost.total - call.total_cost.to_d
107
135
  )
108
136
  end
109
137
 
@@ -111,11 +139,16 @@ module LlmCostTracker
111
139
  Usage::TokenUsage.build(**call.attributes.transform_keys(&:to_sym).slice(*Usage::TokenUsage.members))
112
140
  end
113
141
 
114
- def service_line_items_from(call)
115
- call.line_items.reject { |record| record.unit == "token" }.sort_by(&:position).map do |record|
116
- Charges::LineItem.build(record.attributes.transform_keys(&:to_sym).slice(*Charges::LineItem.members))
142
+ def line_items_from(call, reprice:)
143
+ call.line_items.sort_by(&:position).map do |record|
144
+ attributes = record.attributes.transform_keys(&:to_sym).slice(*Charges::LineItem.members)
145
+ Charges::LineItem.build(reprice ? at_current_rates(record, attributes) : attributes)
117
146
  end
118
147
  end
148
+
149
+ def at_current_rates(record, attributes)
150
+ REGISTRY_SOURCES.include?(record.price_source) ? attributes.except(*RATE_FIELDS) : attributes
151
+ end
119
152
  end
120
153
  end
121
154
  end
@@ -11,24 +11,27 @@ module LlmCostTracker
11
11
  class Calculation
12
12
  RATE_DENOMINATOR_TOKENS = Pricing::RATE_BASIS_QUANTITIES.fetch("per_million_tokens")
13
13
  SNAPSHOT_SCHEMA_VERSION = 1
14
- private_constant :RATE_DENOMINATOR_TOKENS, :SNAPSHOT_SCHEMA_VERSION
14
+ CACHE_INPUT_KEYS = %w[cache_read_input cache_write_input].freeze
15
+ private_constant :RATE_DENOMINATOR_TOKENS, :SNAPSHOT_SCHEMA_VERSION, :CACHE_INPUT_KEYS
15
16
 
16
- def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil)
17
+ def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil, at: Time.now)
17
18
  new(provider: provider,
18
19
  model: model,
19
20
  token_usage: Usage::TokenUsage.build_from_tokens(tokens),
20
21
  line_items: line_items,
21
22
  mode: Mode.normalize(pricing_mode),
22
- usage_source: usage_source)
23
+ usage_source: usage_source,
24
+ at: at)
23
25
  end
24
26
 
25
- def initialize(provider:, model:, token_usage:, line_items:, mode:, usage_source: nil)
27
+ def initialize(provider:, model:, token_usage:, line_items:, mode:, usage_source: nil, at: Time.now)
26
28
  @provider = provider
27
29
  @model = model
28
30
  @token_usage = token_usage
29
31
  @line_items = line_items
30
32
  @mode = mode
31
33
  @usage_source = usage_source
34
+ @at = at
32
35
  end
33
36
 
34
37
  attr_reader :mode
@@ -36,21 +39,26 @@ module LlmCostTracker
36
39
  def match
37
40
  return @match if defined?(@match)
38
41
 
39
- @match = Matcher.lookup(provider: @provider, model: @model)
42
+ @match = Matcher.lookup(provider: @provider, model: @model, at: @at)
40
43
  end
41
44
 
42
45
  def effective
43
46
  return @effective if defined?(@effective)
44
47
 
45
48
  @effective = match && EffectivePrices.call(
46
- usage: @token_usage, quantities: quantities, prices: match.prices, pricing_mode: @mode
49
+ usage: @token_usage,
50
+ quantities: quantities,
51
+ prices: match.prices,
52
+ pricing_mode: @mode,
53
+ cache_at_input_rate: cache_keys_at_input_rate
47
54
  )
48
55
  end
49
56
 
50
57
  def token_cost
51
58
  return @token_cost if defined?(@token_cost)
52
59
 
53
- @token_cost = priceable? ? build_token_cost : nil
60
+ known = priceable? && @usage_source != Usage::Source::UNKNOWN && !only_unpriced_lines?
61
+ @token_cost = known ? build_token_cost : nil
54
62
  end
55
63
 
56
64
  def priced_line_items
@@ -77,39 +85,73 @@ module LlmCostTracker
77
85
  end
78
86
 
79
87
  def cost_status
80
- @cost_status ||= Charges::CostStatus.call(
81
- token_usage: @token_usage,
82
- usage_source: @usage_source,
83
- token_cost: token_cost,
84
- token_pricing_partial: token_pricing_partial?,
85
- service_line_items: priced_line_items.reject(&:token?),
86
- total_cost: cost&.total
87
- )
88
+ @cost_status ||= begin
89
+ status = billed_status || Charges::CostStatus.call(
90
+ token_usage: @token_usage,
91
+ usage_source: @usage_source,
92
+ token_cost: token_cost,
93
+ token_pricing_partial: token_pricing_partial?,
94
+ service_line_items: priced_line_items.reject(&:token?),
95
+ total_cost: cost&.total
96
+ )
97
+ @partial_iteration && status != Charges::CostStatus::UNKNOWN ? Charges::CostStatus::PARTIAL : status
98
+ end
88
99
  end
89
100
 
90
101
  private
91
102
 
103
+ def cache_keys_at_input_rate
104
+ return [] if match.source.name == "pricing_overrides" || !match.key.start_with?("openai/")
105
+
106
+ listed = Registry.builtin_prices[match.key] || (match.prices unless manual_file_entry?)
107
+ listed ? CACHE_INPUT_KEYS - listed.keys : []
108
+ end
109
+
110
+ def manual_file_entry?
111
+ entry = Registry.raw_file_registry(LlmCostTracker.configuration.pricing.file).dig("models", match.key)
112
+ entry.is_a?(Hash) && entry["_source"].to_s == "manual"
113
+ end
114
+
92
115
  def quantities
93
- @quantities ||= @token_usage.priced_quantities
116
+ @quantities ||= @line_items.each_with_object(@token_usage.priced_quantities) do |line_item, result|
117
+ dimension = line_item.dimension
118
+ next unless dimension&.parent
119
+
120
+ quantity = [line_item.quantity.to_i, result.fetch(dimension.parent)].min
121
+ result[dimension.parent] -= quantity
122
+ result[dimension.key] = result.fetch(dimension.key, 0) + quantity
123
+ end
94
124
  end
95
125
 
96
126
  def unpriced_line_items
97
- Charges::LineItem.from_token_usage(@token_usage) + @line_items.reject(&:token?)
127
+ Charges::LineItem.from_quantities(quantities) + @line_items.reject(&:token?)
98
128
  end
99
129
 
100
130
  def priceable?
101
- !match.nil? && !all_billable_unpriced?
131
+ !match.nil? && billed_line.nil? && !all_billable_unpriced?
102
132
  end
103
133
 
104
- def all_billable_unpriced?
105
- any_billable = false
106
- quantities.each_pair do |key, quantity|
107
- next unless quantity.positive?
108
- return false if effective[key]
134
+ def billed_line
135
+ @line_items.find { |line_item| line_item.kind == "billed_request" }
136
+ end
109
137
 
110
- any_billable = true
111
- end
112
- any_billable
138
+ def billed_status
139
+ return unless billed_line
140
+
141
+ unpriced_attempt = priced_line_items.any? { |item| item.kind == "model_iteration" && item.unpriced? }
142
+ return Charges::CostStatus::PARTIAL if unpriced_attempt
143
+
144
+ billed_line.priced? && cost.total.positive? ? Charges::CostStatus::COMPLETE : billed_line.cost_status
145
+ end
146
+
147
+ def only_unpriced_lines?
148
+ billable = priced_line_items.select(&:billable?)
149
+ billable.any? && billable.none?(&:priced?)
150
+ end
151
+
152
+ def all_billable_unpriced?
153
+ billable = quantities.select { |_key, quantity| quantity.positive? }.keys
154
+ billable.any? && billable.none? { |key| effective[key] }
113
155
  end
114
156
 
115
157
  def priced_token_line_items
@@ -117,10 +159,12 @@ module LlmCostTracker
117
159
  end
118
160
 
119
161
  def build_token_cost
120
- by_dimension = priced_token_line_items.to_h { |line_item| [line_item.dimension, line_item] }
162
+ by_component = priced_token_line_items.group_by { |item| item.dimension.parent || item.dimension.key }
121
163
  components = Usage::Catalog.token_priced.each_with_object({}) do |dimension, result|
122
- cost = token_dimension_cost(dimension, by_dimension[dimension])
123
- result[dimension.cost_key] = cost.round(8) unless cost.nil?
164
+ line_items = by_component.fetch(dimension.key, [])
165
+ next if line_items.any?(&:unpriced?)
166
+
167
+ result[dimension.cost_key] = line_items.sum(BigDecimal("0")) { |line_item| line_item.cost_value.round(8) }
124
168
  end
125
169
  Charges::Cost.new(
126
170
  components: components.freeze,
@@ -129,12 +173,6 @@ module LlmCostTracker
129
173
  )
130
174
  end
131
175
 
132
- def token_dimension_cost(dimension, line_item)
133
- return BigDecimal("0") if quantities[dimension.key].zero?
134
-
135
- line_item&.cost
136
- end
137
-
138
176
  def build_snapshot
139
177
  {
140
178
  "schema_version" => SNAPSHOT_SCHEMA_VERSION,
@@ -143,7 +181,7 @@ module LlmCostTracker
143
181
  "source_version" => match.source.version,
144
182
  "matched_by" => match.matched_by.to_s,
145
183
  "currency" => match.source.currency,
146
- "rates" => service_charge_rates.merge(token_charge_rates)
184
+ "rates" => charge_rates(kept_service_lines).merge(charge_rates(priced_token_line_items))
147
185
  }
148
186
  end
149
187
 
@@ -155,20 +193,12 @@ module LlmCostTracker
155
193
  "source_version" => primary.price_source_version,
156
194
  "matched_by" => "service_charges",
157
195
  "currency" => cost.currency,
158
- "rates" => service_charge_rates
196
+ "rates" => charge_rates(kept_service_lines)
159
197
  }
160
198
  end
161
199
 
162
- def token_charge_rates
163
- priced_token_line_items.each_with_object({}) do |line_item, rates|
164
- next if line_item.price_key.nil? || line_item.rate_amount.nil?
165
-
166
- rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
167
- end
168
- end
169
-
170
- def service_charge_rates
171
- kept_service_lines.each_with_object({}) do |line_item, rates|
200
+ def charge_rates(line_items)
201
+ line_items.each_with_object({}) do |line_item, rates|
172
202
  next if line_item.price_key.nil? || line_item.rate_amount.nil?
173
203
 
174
204
  rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
@@ -180,14 +210,8 @@ module LlmCostTracker
180
210
  end
181
211
 
182
212
  def price_token(line_item)
183
- dimension = line_item.dimension
184
- return line_item unless dimension
185
- return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) unless priceable?
186
-
187
- price = effective[dimension.key]
188
- return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) if price.nil?
189
-
190
- line_item.with_rate(token_rate(price))
213
+ price = priceable? && effective[line_item.dimension.key]
214
+ price ? line_item.with_rate(token_rate(price)) : line_item
191
215
  end
192
216
 
193
217
  def token_rate(price)
@@ -202,7 +226,8 @@ module LlmCostTracker
202
226
  end
203
227
 
204
228
  def price_service(line_item)
205
- return line_item if line_item.priced? || !line_item.billable?
229
+ return price_iteration(line_item) if line_item.kind == "model_iteration"
230
+ return line_item if line_item.priced? || !line_item.billable? || billed_line
206
231
 
207
232
  rate = model_rate(line_item) ||
208
233
  ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: @mode)
@@ -211,19 +236,45 @@ module LlmCostTracker
211
236
  line_item.with_rate(rate)
212
237
  end
213
238
 
239
+ def price_iteration(line_item)
240
+ details = line_item.details.to_h.transform_keys(&:to_sym)
241
+ model = details[:model].to_s
242
+ calculation = Calculation.for(
243
+ provider: @provider,
244
+ model: model,
245
+ tokens: details.slice(*Usage::TokenUsage.members),
246
+ pricing_mode: iteration_mode(model),
247
+ at: @at
248
+ )
249
+ cost = calculation.token_cost
250
+ return line_item unless cost
251
+
252
+ @partial_iteration ||= calculation.cost_status == Charges::CostStatus::PARTIAL
253
+ status = cost.total.zero? ? Charges::CostStatus::FREE : Charges::CostStatus::COMPLETE
254
+ line_item.with(rate_amount: cost.total, cost: cost.total, currency: cost.currency, cost_status: status)
255
+ end
256
+
257
+ def iteration_mode(model)
258
+ return @mode if Matcher.modifier_priced?(provider: @provider, model: model, modifier: "fast")
259
+
260
+ Mode.compose(Mode.tokenize(@mode) - ["fast"])
261
+ end
262
+
214
263
  def model_rate(line_item)
215
264
  return nil unless priceable?
216
265
 
217
- amount = match.prices[line_item.kind]
218
- return nil unless amount.is_a?(Numeric)
266
+ modes = @mode ? Mode.permutations_for(@mode) : []
267
+ key = [*modes.map { |mode| PriceKey.build(line_item.kind, mode: mode) }, line_item.kind]
268
+ .find { |candidate| match.prices[candidate].is_a?(Numeric) }
269
+ return nil unless key
219
270
 
220
271
  dimension = Usage::Catalog[line_item.kind]
221
272
  Pricing::Rate.new(
222
- amount: amount.to_d,
273
+ amount: match.prices[key].to_d,
223
274
  quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
224
275
  currency: match.source.currency,
225
276
  source: match.source.name,
226
- source_key: "#{match.key}.#{line_item.kind}",
277
+ source_key: "#{match.key}.#{key}",
227
278
  source_version: match.source.version
228
279
  )
229
280
  end
@@ -231,17 +282,13 @@ module LlmCostTracker
231
282
  def kept_service_lines
232
283
  return @kept_service_lines if defined?(@kept_service_lines)
233
284
 
234
- @kept_service_lines = begin
235
- priced_services = priced_line_items.reject(&:token?).select(&:priced?)
236
- if priced_services.empty?
237
- []
238
- else
239
- base_currency = base_currency_for(token_cost, priced_services)
240
- matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
241
- warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
242
- matching
243
- end
244
- end
285
+ priced_services = priced_line_items.reject(&:token?).select(&:priced?)
286
+ return @kept_service_lines = [] if priced_services.empty?
287
+
288
+ base_currency = token_cost&.currency || priced_services.first.currency || LlmCostTracker::DEFAULT_CURRENCY
289
+ matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
290
+ warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
291
+ @kept_service_lines = matching
245
292
  end
246
293
 
247
294
  def combine_service_lines
@@ -256,10 +303,6 @@ module LlmCostTracker
256
303
  )
257
304
  end
258
305
 
259
- def base_currency_for(cost, priced_services)
260
- cost&.currency || priced_services.first.currency || LlmCostTracker::DEFAULT_CURRENCY
261
- end
262
-
263
306
  def warn_currency_mismatch(lines, base_currency)
264
307
  currencies = lines.map { |line| line.currency.to_s }.uniq.sort
265
308
  Logging.warn(
@@ -11,18 +11,14 @@ module LlmCostTracker
11
11
  Resolved = Data.define(:amount, :key)
12
12
 
13
13
  class << self
14
- def call(usage:, quantities:, prices:, pricing_mode:)
14
+ def call(usage:, quantities:, prices:, pricing_mode:, cache_at_input_rate: [])
15
15
  context_tier = context_tier?(usage: usage, prices: prices)
16
16
  orderings = pricing_mode && Mode.permutations_for(pricing_mode)
17
+ lookup = { prices: prices, orderings: orderings, context_tier: context_tier }
17
18
 
18
19
  quantities.to_h do |price_key, tokens|
19
20
  resolved = if tokens.positive?
20
- price_for(
21
- prices: prices,
22
- key: price_key,
23
- orderings: orderings,
24
- context_tier: context_tier
25
- )
21
+ price_or_fallback(price_key, lookup, cache_at_input_rate)
26
22
  else
27
23
  Resolved.new(amount: BigDecimal("0"), key: price_key)
28
24
  end
@@ -32,6 +28,11 @@ module LlmCostTracker
32
28
 
33
29
  private
34
30
 
31
+ def price_or_fallback(key, lookup, cache_at_input_rate)
32
+ fallback = Usage::Catalog.fetch(key).parent || ("input" if cache_at_input_rate.include?(key))
33
+ price_for(key: key, **lookup) || (fallback && price_or_fallback(fallback, lookup, cache_at_input_rate))
34
+ end
35
+
35
36
  def price_for(prices:, key:, orderings:, context_tier:)
36
37
  unless orderings
37
38
  standard = PriceKey.build(key, above_context: context_tier)
@@ -45,8 +46,7 @@ module LlmCostTracker
45
46
  end
46
47
  return nil if %w[input output].include?(key)
47
48
 
48
- derived = derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
49
- resolve(derived, nil)
49
+ derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
50
50
  end
51
51
 
52
52
  def resolve(amount, key)
@@ -63,7 +63,8 @@ module LlmCostTracker
63
63
  mode_base_price = prices[PriceKey.build("input", mode: mode, above_context: context_tier)]
64
64
  next unless mode_base_price
65
65
 
66
- return standard_price.to_d * mode_base_price.to_d / base_price.to_d
66
+ return resolve(standard_price.to_d * mode_base_price.to_d / base_price.to_d,
67
+ PriceKey.build(key, mode: mode, above_context: context_tier))
67
68
  end
68
69
  nil
69
70
  end
@@ -22,8 +22,11 @@ module LlmCostTracker
22
22
 
23
23
  def self.char_count(value)
24
24
  case value
25
- when String then value.length
26
- when Hash then value.values.sum { |nested| char_count(nested) }
25
+ when String then value.match?(/\Adata:[^,]*;base64,/) ? 0 : value.length
26
+ when Hash
27
+ return 0 if value["type"].to_s == "base64"
28
+
29
+ value.except("input_audio", "inline_data", "inlineData").values.sum { |nested| char_count(nested) }
27
30
  when Array then value.sum { |nested| char_count(nested) }
28
31
  else 0
29
32
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
+ require "date"
4
5
 
5
6
  require_relative "registry"
6
7
 
@@ -10,21 +11,37 @@ module LlmCostTracker
10
11
  Match = Data.define(:source, :key, :prices, :matched_by)
11
12
 
12
13
  CACHE_LIMIT = 2048
13
- private_constant :CACHE_LIMIT
14
+ BEDROCK_ANTHROPIC_ID = /\A(?:[a-z]+\.)?anthropic\.(claude-.+?)(?:-v\d+(?::\d+)?)?\z/
15
+ private_constant :CACHE_LIMIT, :BEDROCK_ANTHROPIC_ID
14
16
 
15
17
  class << self
16
- def lookup(provider:, model:)
18
+ def lookup(provider:, model:, at: Time.now)
17
19
  provider_name = provider.to_s.presence
18
20
  model_name = model.to_s
19
21
  return nil if model_name.empty?
20
22
 
21
23
  sources = Registry.sources
22
24
  reset_cache(sources) unless @cache_sources.equal?(sources)
23
- key = [provider_name, model_name].freeze
25
+ date = at.getutc.to_date.iso8601
26
+ key = [provider_name, model_name, date].freeze
24
27
  return @cache[key] if @cache.key?(key)
25
28
 
26
29
  @cache.clear if @cache.size >= CACHE_LIMIT
27
- @cache[key] = lookup_match(sources, provider_name, model_name)
30
+ @cache[key] = lookup_match(sources, provider_name, model_name)&.then do |match|
31
+ match.with(prices: prices_on(match.prices, date))
32
+ end
33
+ end
34
+
35
+ def prices_on(prices, date)
36
+ scheduled = prices.keys.grep(PriceKey::SCHEDULED_SUFFIX)
37
+ return prices if scheduled.empty?
38
+
39
+ current = prices.except(*scheduled)
40
+ scheduled.sort_by { |key| key[PriceKey::SCHEDULED_SUFFIX, 1] }.each do |key|
41
+ from = key[PriceKey::SCHEDULED_SUFFIX, 1]
42
+ current[key.delete_suffix("_from_#{from}")] = prices[key] if from <= date
43
+ end
44
+ current.freeze
28
45
  end
29
46
 
30
47
  def modifier_priced?(provider:, model:, modifier:)
@@ -45,35 +62,31 @@ module LlmCostTracker
45
62
  provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
46
63
  normalized = normalize_model_name(model_name)
47
64
 
48
- sources.each do |source|
49
- match = match_in_source(source, provider_model, model_name, normalized)
50
- return match if match
51
- end
52
- nil
65
+ live = sources.reject { |source| source.prices.empty? }
66
+ live.lazy.filter_map { |source| exact_match(source, provider_model, model_name, normalized) }.first ||
67
+ live.lazy.filter_map { |source| fallback_match(source, normalized) }.first
53
68
  end
54
69
 
55
- def match_in_source(source, provider_model, model_name, normalized)
70
+ def exact_match(source, provider_model, model_name, normalized)
56
71
  table = source.prices
57
- return nil if table.empty?
58
-
59
72
  [[provider_model, :provider_model], [model_name, :model], [normalized, :normalized_model]].each do |key, by|
60
73
  return build_match(source, key, by) if table.key?(key)
61
74
  end
62
75
 
63
- scan = native_keys(table)
64
- if (key = unique_in(scan) { |native| normalize_model_name(native) == normalized })
65
- return build_match(source, key, :unique_providerless_model)
76
+ dated = native_keys(table).find do |native|
77
+ snapshot_variant?(provider_model, native) || snapshot_variant?(normalized, native)
66
78
  end
79
+ build_match(source, dated, :dated_snapshot) if dated
80
+ end
67
81
 
68
- dated = scan.find do |native|
69
- snapshot_variant?(provider_model, native) || snapshot_variant?(normalized, native)
82
+ def fallback_match(source, normalized)
83
+ scan = native_keys(source.prices)
84
+ if (key = unique_in(scan) { |native| normalize_model_name(native) == normalized })
85
+ return build_match(source, key, :unique_providerless_model)
70
86
  end
71
- return build_match(source, dated, :dated_snapshot) if dated
72
87
 
73
88
  unique_dated = unique_in(scan) { |native| snapshot_variant?(normalized, normalize_model_name(native)) }
74
- return build_match(source, unique_dated, :unique_providerless_dated_snapshot) if unique_dated
75
-
76
- nil
89
+ build_match(source, unique_dated, :unique_providerless_dated_snapshot) if unique_dated
77
90
  end
78
91
 
79
92
  def unique_in(keys, &)
@@ -82,7 +95,8 @@ module LlmCostTracker
82
95
  end
83
96
 
84
97
  def normalize_model_name(model)
85
- model.to_s.split("/").last
98
+ name = model.to_s.split("/").last
99
+ name.match(BEDROCK_ANTHROPIC_ID)&.[](1) || name
86
100
  end
87
101
 
88
102
  def native_keys(table)
@@ -9,11 +9,8 @@ module LlmCostTracker
9
9
  MAX_PERMUTED_MODIFIERS = 6
10
10
 
11
11
  def self.normalize(value)
12
- return nil if value.nil?
13
-
14
- mode = normalize_string(value.to_s)
15
- return nil unless mode
16
- return nil if STANDARD_MODE_VALUES.include?(mode)
12
+ mode = value.to_s.strip.downcase.tr("-", "_")
13
+ return nil if mode.empty? || STANDARD_MODE_VALUES.include?(mode)
17
14
 
18
15
  warn_unknown_tokens(mode)
19
16
  mode
@@ -65,14 +62,6 @@ module LlmCostTracker
65
62
  modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
66
63
  end
67
64
 
68
- def self.normalize_string(value)
69
- normalized = value.strip
70
- return nil if normalized.empty?
71
-
72
- normalized.downcase.tr("-", "_")
73
- end
74
- private_class_method :normalize_string
75
-
76
65
  def self.warn_unknown_tokens(mode)
77
66
  unknown = tokenize(mode) - KNOWN_MODIFIERS - STANDARD_MODE_VALUES
78
67
  return if unknown.empty?
@@ -7,6 +7,7 @@ module LlmCostTracker
7
7
  module Pricing
8
8
  module PriceKey
9
9
  ABOVE_CONTEXT_PREFIX = "above_context_"
10
+ SCHEDULED_SUFFIX = /_from_(\d{4}-\d{2}-\d{2})\z/
10
11
 
11
12
  class << self
12
13
  def build(dimension_key, mode: nil, above_context: false)
@@ -16,12 +17,13 @@ module LlmCostTracker
16
17
 
17
18
  def price_key_for(key)
18
19
  key = key.to_s
19
- dimension_key = strip_mode_prefix(key.delete_prefix(ABOVE_CONTEXT_PREFIX))
20
+ base = key.sub(SCHEDULED_SUFFIX, "")
21
+ dimension_key = strip_mode_prefix(base.delete_prefix(ABOVE_CONTEXT_PREFIX))
20
22
  dimension = Usage::Catalog[dimension_key]
21
23
  return nil unless dimension
22
- return key if key == dimension_key
24
+ return nil if dimension.token_key.nil? && base.start_with?(ABOVE_CONTEXT_PREFIX)
23
25
 
24
- dimension.token_key ? key : nil
26
+ key
25
27
  end
26
28
 
27
29
  def parse_dimension_key(key)