llm_cost_tracker 0.14.2 → 0.15.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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -5,14 +5,17 @@ require "bigdecimal/util"
5
5
  require_relative "../usage/catalog"
6
6
  require_relative "../charges/line_item"
7
7
  require_relative "rate"
8
+ require_relative "calculation/iterations"
9
+ require_relative "calculation/quantities"
10
+ require_relative "calculation/snapshot"
11
+ require_relative "calculation/totals"
8
12
 
9
13
  module LlmCostTracker
10
14
  module Pricing
11
15
  class Calculation
12
16
  RATE_DENOMINATOR_TOKENS = Pricing::RATE_BASIS_QUANTITIES.fetch("per_million_tokens")
13
- SNAPSHOT_SCHEMA_VERSION = 1
14
17
  CACHE_INPUT_KEYS = %w[cache_read_input cache_write_input].freeze
15
- private_constant :RATE_DENOMINATOR_TOKENS, :SNAPSHOT_SCHEMA_VERSION, :CACHE_INPUT_KEYS
18
+ private_constant :RATE_DENOMINATOR_TOKENS, :CACHE_INPUT_KEYS
16
19
 
17
20
  def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil, at: Time.now)
18
21
  new(provider: provider,
@@ -29,12 +32,18 @@ module LlmCostTracker
29
32
  @model = model
30
33
  @token_usage = token_usage
31
34
  @line_items = line_items
32
- @mode = mode
35
+ @requested_mode = mode
33
36
  @usage_source = usage_source
34
37
  @at = at
35
38
  end
36
39
 
37
- attr_reader :mode
40
+ def mode
41
+ return @mode if defined?(@mode)
42
+
43
+ tokens = Mode.tokenize(@requested_mode) - ["off_peak"]
44
+ tokens << "off_peak" if off_peak?
45
+ @mode = Mode.compose(tokens)
46
+ end
38
47
 
39
48
  def match
40
49
  return @match if defined?(@match)
@@ -49,7 +58,7 @@ module LlmCostTracker
49
58
  usage: @token_usage,
50
59
  quantities: quantities,
51
60
  prices: match.prices,
52
- pricing_mode: @mode,
61
+ pricing_mode: mode,
53
62
  cache_at_input_rate: cache_keys_at_input_rate
54
63
  )
55
64
  end
@@ -57,8 +66,8 @@ module LlmCostTracker
57
66
  def token_cost
58
67
  return @token_cost if defined?(@token_cost)
59
68
 
60
- known = priceable? && @usage_source != Usage::Source::UNKNOWN && !only_unpriced_lines?
61
- @token_cost = known ? build_token_cost : nil
69
+ known = priceable? && @usage_source != Usage::Source::UNKNOWN && !only_unpriced_lines? && !unsplit_total?
70
+ @token_cost = known ? Totals.token_cost(priced_token_line_items, match.source.currency) : nil
62
71
  end
63
72
 
64
73
  def priced_line_items
@@ -72,34 +81,39 @@ module LlmCostTracker
72
81
 
73
82
  @snapshot =
74
83
  if priceable?
75
- build_snapshot
76
- elsif kept_service_lines.any?
77
- build_service_snapshot
84
+ Snapshot.for_match(match, counted_service_lines, priced_token_line_items)
85
+ elsif counted_service_lines.any?
86
+ Snapshot.for_service_charges(counted_service_lines, cost.currency)
78
87
  end
79
88
  end
80
89
 
81
90
  def cost
82
91
  return @cost if defined?(@cost)
83
92
 
84
- @cost = combine_service_lines
93
+ @cost = Totals.with_service_lines(token_cost, counted_service_lines)
85
94
  end
86
95
 
87
96
  def cost_status
88
97
  @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
+ status = billed_status || priced_status
99
+ @iterations&.partial? && status != Charges::CostStatus::UNKNOWN ? Charges::CostStatus::PARTIAL : status
98
100
  end
99
101
  end
100
102
 
103
+ def unsplit_total?
104
+ return false unless match && @token_usage.total_tokens.to_i.positive?
105
+
106
+ @token_usage.priced_quantities.values.none?(&:positive?) &&
107
+ match.prices.values_at("input", "output").any? { |rate| rate.to_f.positive? }
108
+ end
109
+
101
110
  private
102
111
 
112
+ def off_peak?
113
+ windows = match&.prices&.[](Registry::OFF_PEAK_WINDOWS_KEY)
114
+ windows && OffPeak.cover?(windows, @at)
115
+ end
116
+
103
117
  def cache_keys_at_input_rate
104
118
  return [] if match.source.name == "pricing_overrides" || !match.key.start_with?("openai/")
105
119
 
@@ -113,14 +127,7 @@ module LlmCostTracker
113
127
  end
114
128
 
115
129
  def 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
130
+ @quantities ||= Quantities.new(@token_usage, @line_items, match&.prices || {}).to_h
124
131
  end
125
132
 
126
133
  def unpriced_line_items
@@ -135,188 +142,90 @@ module LlmCostTracker
135
142
  @line_items.find { |line_item| line_item.kind == "billed_request" }
136
143
  end
137
144
 
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
145
  def all_billable_unpriced?
153
146
  billable = quantities.select { |_key, quantity| quantity.positive? }.keys
154
147
  billable.any? && billable.none? { |key| effective[key] }
155
148
  end
156
149
 
157
- def priced_token_line_items
158
- @priced_token_line_items ||= priced_line_items.select(&:token?)
159
- end
160
-
161
- def build_token_cost
162
- by_component = priced_token_line_items.group_by { |item| item.dimension.parent || item.dimension.key }
163
- components = Usage::Catalog.token_priced.each_with_object({}) do |dimension, result|
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) }
168
- end
169
- Charges::Cost.new(
170
- components: components.freeze,
171
- total: priced_token_line_items.sum(BigDecimal("0")) { |line_item| line_item.cost_value.round(8) },
172
- currency: match.source.currency
173
- )
174
- end
175
-
176
- def build_snapshot
177
- {
178
- "schema_version" => SNAPSHOT_SCHEMA_VERSION,
179
- "source" => match.source.name,
180
- "source_key" => match.key,
181
- "source_version" => match.source.version,
182
- "matched_by" => match.matched_by.to_s,
183
- "currency" => match.source.currency,
184
- "rates" => charge_rates(kept_service_lines).merge(charge_rates(priced_token_line_items))
185
- }
186
- end
187
-
188
- def build_service_snapshot
189
- primary = kept_service_lines.first
190
- {
191
- "schema_version" => SNAPSHOT_SCHEMA_VERSION,
192
- "source" => primary.price_source,
193
- "source_version" => primary.price_source_version,
194
- "matched_by" => "service_charges",
195
- "currency" => cost.currency,
196
- "rates" => charge_rates(kept_service_lines)
197
- }
198
- end
199
-
200
- def charge_rates(line_items)
201
- line_items.each_with_object({}) do |line_item, rates|
202
- next if line_item.price_key.nil? || line_item.rate_amount.nil?
203
-
204
- rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
205
- end
206
- end
207
-
208
- def rate_entry(amount, quantity)
209
- { "amount" => amount.to_d.to_s("F"), "quantity" => Integer(quantity) }
150
+ def only_unpriced_lines?
151
+ billable = priced_line_items.select(&:billable?)
152
+ billable.any? && billable.none?(&:priced?)
210
153
  end
211
154
 
212
155
  def price_token(line_item)
213
156
  price = priceable? && effective[line_item.dimension.key]
214
- price ? line_item.with_rate(token_rate(price)) : line_item
215
- end
157
+ return line_item unless price
216
158
 
217
- def token_rate(price)
218
- Pricing::Rate.new(
219
- amount: price.amount.to_d,
220
- quantity: RATE_DENOMINATOR_TOKENS.to_d,
221
- currency: match.source.currency,
222
- source: match.source.name,
223
- source_key: price.key,
224
- source_version: match.source.version
225
- )
159
+ line_item.with_rate(match.rate(price.amount.to_d, RATE_DENOMINATOR_TOKENS.to_d, price.key))
226
160
  end
227
161
 
228
162
  def price_service(line_item)
229
- return price_iteration(line_item) if line_item.kind == "model_iteration"
163
+ return iterations.price(line_item) if line_item.kind == "model_iteration"
230
164
  return line_item if line_item.priced? || !line_item.billable? || billed_line
231
165
 
232
166
  rate = model_rate(line_item) ||
233
- ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: @mode)
167
+ ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: mode)
234
168
  return line_item unless rate
235
169
 
236
- line_item.with_rate(rate)
170
+ billed_minimum(line_item).with_rate(rate)
237
171
  end
238
172
 
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
173
+ def model_rate(line_item)
174
+ return unless priceable?
251
175
 
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)
176
+ key = model_price_key(line_item.kind)
177
+ return unless key
178
+
179
+ quantity = Pricing::RATE_BASIS_QUANTITIES.fetch(Usage::Catalog[line_item.kind].rate_basis)
180
+ match.rate(match.prices[key].to_d, quantity.to_d, "#{match.key}.#{key}")
255
181
  end
256
182
 
257
- def iteration_mode(model)
258
- return @mode if Matcher.modifier_priced?(provider: @provider, model: model, modifier: "fast")
183
+ def model_price_key(kind)
184
+ modes = mode ? Mode.permutations_for(mode) : []
185
+ [*modes.map { |permutation| PriceKey.build(kind, mode: permutation) }, kind]
186
+ .find { |key| match.prices[key].is_a?(Numeric) }
187
+ end
259
188
 
260
- Mode.compose(Mode.tokenize(@mode) - ["fast"])
189
+ def billed_minimum(line_item)
190
+ seconds = match&.prices&.[](Registry::MINIMUM_BILLED_SECONDS_KEY) if line_item.kind == "transcription_minute"
191
+ seconds ? line_item.with(quantity: [line_item.quantity, BigDecimal(seconds) / 60].max) : line_item
261
192
  end
262
193
 
263
- def model_rate(line_item)
264
- return nil unless priceable?
265
-
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
270
-
271
- dimension = Usage::Catalog[line_item.kind]
272
- Pricing::Rate.new(
273
- amount: match.prices[key].to_d,
274
- quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
275
- currency: match.source.currency,
276
- source: match.source.name,
277
- source_key: "#{match.key}.#{key}",
278
- source_version: match.source.version
279
- )
194
+ def iterations
195
+ @iterations ||= Iterations.new(provider: @provider, requested_mode: @requested_mode, at: @at)
280
196
  end
281
197
 
282
- def kept_service_lines
283
- return @kept_service_lines if defined?(@kept_service_lines)
198
+ def priced_token_line_items
199
+ @priced_token_line_items ||= priced_line_items.select(&:token?)
200
+ end
284
201
 
285
- priced_services = priced_line_items.reject(&:token?).select(&:priced?)
286
- return @kept_service_lines = [] if priced_services.empty?
202
+ def priced_service_line_items
203
+ @priced_service_line_items ||= priced_line_items.reject(&:token?)
204
+ end
287
205
 
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
206
+ def counted_service_lines
207
+ @counted_service_lines ||= Totals.in_call_currency(priced_service_line_items.select(&:priced?), token_cost)
292
208
  end
293
209
 
294
- def combine_service_lines
295
- cost = token_cost
296
- return cost if kept_service_lines.empty?
210
+ def billed_status
211
+ return unless billed_line
212
+
213
+ unpriced_attempt = priced_line_items.any? { |item| item.kind == "model_iteration" && item.unpriced? }
214
+ return Charges::CostStatus::PARTIAL if unpriced_attempt
297
215
 
298
- service_total = kept_service_lines.sum(BigDecimal("0")) { |line| line.cost_value.round(8) }
299
- Charges::Cost.new(
300
- components: cost ? cost.components : {}.freeze,
301
- total: (cost&.total || BigDecimal("0")) + service_total,
302
- currency: (cost&.currency || kept_service_lines.first.currency).to_s
303
- )
216
+ billed_line.priced? && cost.total.positive? ? Charges::CostStatus::COMPLETE : billed_line.cost_status
304
217
  end
305
218
 
306
- def warn_currency_mismatch(lines, base_currency)
307
- currencies = lines.map { |line| line.currency.to_s }.uniq.sort
308
- Logging.warn(
309
- "Service line currency mismatch: header is #{base_currency}, dropping " \
310
- "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
311
- "Per-line costs are still recorded; header total reflects #{base_currency} only."
219
+ def priced_status
220
+ Charges::CostStatus.call(
221
+ token_usage: @token_usage,
222
+ usage_source: @usage_source,
223
+ token_cost: token_cost,
224
+ token_pricing_partial: !token_cost.nil? && priced_token_line_items.any?(&:unpriced?),
225
+ service_line_items: priced_service_line_items,
226
+ total_cost: cost&.total
312
227
  )
313
228
  end
314
-
315
- def token_pricing_partial?
316
- return false unless token_cost
317
-
318
- priced_token_line_items.any?(&:unpriced?)
319
- end
320
229
  end
321
230
  end
322
231
  end
@@ -8,11 +8,23 @@ require_relative "registry"
8
8
  module LlmCostTracker
9
9
  module Pricing
10
10
  module Matcher
11
- Match = Data.define(:source, :key, :prices, :matched_by)
11
+ Match = Data.define(:source, :key, :prices, :matched_by) do
12
+ def rate(amount, quantity, source_key)
13
+ Rate.new(
14
+ amount: amount,
15
+ quantity: quantity,
16
+ currency: source.currency,
17
+ source: source.name,
18
+ source_key: source_key,
19
+ source_version: source.version
20
+ )
21
+ end
22
+ end
12
23
 
13
24
  CACHE_LIMIT = 2048
14
25
  BEDROCK_ANTHROPIC_ID = /\A(?:[a-z]+\.)?anthropic\.(claude-.+?)(?:-v\d+(?::\d+)?)?\z/
15
- private_constant :CACHE_LIMIT, :BEDROCK_ANTHROPIC_ID
26
+ VERTEX_VERSION = /@(?=\d{8}\z)/
27
+ private_constant :CACHE_LIMIT, :BEDROCK_ANTHROPIC_ID, :VERTEX_VERSION
16
28
 
17
29
  class << self
18
30
  def lookup(provider:, model:, at: Time.now)
@@ -21,14 +33,11 @@ module LlmCostTracker
21
33
  return nil if model_name.empty?
22
34
 
23
35
  sources = Registry.sources
24
- reset_cache(sources) unless @cache_sources.equal?(sources)
25
36
  date = at.getutc.to_date.iso8601
26
- key = [provider_name, model_name, date].freeze
27
- return @cache[key] if @cache.key?(key)
28
-
29
- @cache.clear if @cache.size >= CACHE_LIMIT
30
- @cache[key] = lookup_match(sources, provider_name, model_name)&.then do |match|
31
- match.with(prices: prices_on(match.prices, date))
37
+ cached(sources, [provider_name, model_name, date].freeze) do
38
+ lookup_match(sources, provider_name, model_name)&.then do |match|
39
+ match.with(prices: prices_on(match.prices, date))
40
+ end
32
41
  end
33
42
  end
34
43
 
@@ -44,15 +53,23 @@ module LlmCostTracker
44
53
  current.freeze
45
54
  end
46
55
 
47
- def modifier_priced?(provider:, model:, modifier:)
48
- prices = lookup(provider: provider, model: model)&.prices
49
- return false unless prices
56
+ def modifier_priced?(provider:, model:, modifier:, owners: nil)
57
+ match = lookup(provider: provider, model: model)
58
+ return false unless match && (owners.nil? || match.key.start_with?(*owners))
50
59
 
51
- prices.any? { |key, _| key.to_s.include?(modifier) }
60
+ match.prices.any? { |key, _| key.to_s.match?(/(?:\A|_)#{modifier}_/) }
52
61
  end
53
62
 
54
63
  private
55
64
 
65
+ def cached(sources, key)
66
+ reset_cache(sources) unless @cache_sources.equal?(sources)
67
+ return @cache[key] if @cache.key?(key)
68
+
69
+ @cache.clear if @cache.size >= CACHE_LIMIT
70
+ @cache[key] = yield
71
+ end
72
+
56
73
  def reset_cache(sources)
57
74
  @cache_sources = sources
58
75
  @cache = {}
@@ -95,7 +112,7 @@ module LlmCostTracker
95
112
  end
96
113
 
97
114
  def normalize_model_name(model)
98
- name = model.to_s.split("/").last
115
+ name = model.to_s.split("/").last.sub(VERTEX_VERSION, "-")
99
116
  name.match(BEDROCK_ANTHROPIC_ID)&.[](1) || name
100
117
  end
101
118
 
@@ -4,9 +4,13 @@ module LlmCostTracker
4
4
  module Pricing
5
5
  module Mode
6
6
  STANDARD_MODE_VALUES = %w[auto default standard standard_only unspecified].freeze
7
- KNOWN_MODIFIERS = %w[batch flex priority scale fast on_demand data_residency].freeze
7
+ KNOWN_MODIFIERS = %w[
8
+ batch flex priority scale fast ultrafast on_demand data_residency off_peak reserved provisioned_throughput
9
+ ].freeze
8
10
  HOST_DERIVED_MODIFIERS = %w[data_residency].freeze
9
11
  MAX_PERMUTED_MODIFIERS = 6
12
+ TOKEN = /#{Regexp.union(KNOWN_MODIFIERS)}(?=_|\z)|[^_]+/
13
+ private_constant :TOKEN
10
14
 
11
15
  def self.normalize(value)
12
16
  mode = value.to_s.strip.downcase.tr("-", "_")
@@ -33,24 +37,7 @@ module LlmCostTracker
33
37
  end
34
38
 
35
39
  def self.tokenize(value)
36
- remaining = value.to_s.downcase.tr("-", "_")
37
- tokens = []
38
- loop do
39
- break if remaining.empty?
40
-
41
- known = KNOWN_MODIFIERS.find do |modifier|
42
- remaining == modifier || remaining.start_with?("#{modifier}_")
43
- end
44
- if known
45
- tokens << known
46
- remaining = remaining.delete_prefix(known).delete_prefix("_")
47
- else
48
- first, _, rest = remaining.partition("_")
49
- tokens << first unless first.empty?
50
- remaining = rest
51
- end
52
- end
53
- tokens
40
+ value.to_s.downcase.tr("-", "_").scan(TOKEN)
54
41
  end
55
42
 
56
43
  def self.permutations_for(value)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ module OffPeak
6
+ HOURS = /\A((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d|24:00)\z/
7
+ KEYS = %w[hours_utc weekdays].freeze
8
+
9
+ class << self
10
+ def windows(value, label:)
11
+ windows = Array(value).map { |window| window.is_a?(Hash) ? window.transform_keys(&:to_s) : {} }
12
+ unless value.is_a?(Array) && list?(windows) { |window| window?(window) }
13
+ raise ArgumentError,
14
+ "#{label} must be a list of windows like " \
15
+ '{"weekdays" => [1, 2, 3, 4, 5], "hours_utc" => ["00:00-01:00", "10:00-24:00"]} ' \
16
+ "(ISO weekdays, UTC hours, start inclusive, end exclusive), got #{value.inspect}"
17
+ end
18
+
19
+ windows.map(&:freeze).freeze
20
+ end
21
+
22
+ def cover?(windows, at)
23
+ time = at.getutc
24
+ weekday = time.wday.zero? ? 7 : time.wday
25
+ minute = (time.hour * 60) + time.min
26
+ windows.any? do |window|
27
+ window["weekdays"].include?(weekday) &&
28
+ window["hours_utc"].any? { |hours| minutes(hours).then { |from, to| minute >= from && minute < to } }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def window?(window)
35
+ weekdays, hours = window.values_at("weekdays", "hours_utc")
36
+ window.keys.sort == KEYS && list?(weekdays) { |day| day.is_a?(Integer) && day.between?(1, 7) } &&
37
+ list?(hours) { |range| minutes(range)&.then { |from, to| from < to } }
38
+ end
39
+
40
+ def list?(value, &)
41
+ value.is_a?(Array) && value.any? && value.all?(&)
42
+ end
43
+
44
+ def minutes(range)
45
+ range.to_s.match(HOURS)&.captures&.map { |clock| (clock[0, 2].to_i * 60) + clock[3, 2].to_i }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -8,6 +8,7 @@ module LlmCostTracker
8
8
  "per_million_token_hours" => 1_000_000,
9
9
  "per_request" => 1,
10
10
  "per_1k_requests" => 1_000,
11
+ "per_1k_pages" => 1_000,
11
12
  "per_session" => 1,
12
13
  "per_hour" => 1,
13
14
  "per_minute" => 1
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal/util"
4
+
5
+ require_relative "../../logging"
6
+ require_relative "../off_peak"
7
+ require_relative "../price_key"
8
+ require_relative "../rate"
9
+
10
+ module LlmCostTracker
11
+ module Pricing
12
+ module Registry
13
+ class Normalizer
14
+ def initialize(context)
15
+ @context = context
16
+ end
17
+
18
+ def prices(table)
19
+ table = {} if table.nil?
20
+ raise ArgumentError, "#{@context} must be a hash of models" unless table.is_a?(Hash)
21
+
22
+ table.each_with_object({}) { |(model, entry), prices| prices[model.to_s] = price_entry(model, entry) }
23
+ end
24
+
25
+ def rates(registry)
26
+ charges = registry.fetch("service_charges", {})
27
+ raise ArgumentError, "#{@context} service_charges must be a hash" unless charges.is_a?(Hash)
28
+
29
+ currency = (registry.dig("metadata", "currency") || LlmCostTracker::DEFAULT_CURRENCY).upcase
30
+ charges.to_h do |provider, entries|
31
+ [provider, provider_rates(entries, currency, "#{@context} service_charges.#{provider}")]
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def price_entry(model, entry)
38
+ fields = hash_entry(model, entry).map { |key, value| [key, price_field(model, key, value)] }
39
+ unknown = fields.reject { |key, field| field || METADATA_KEYS.include?(key) }.map(&:first)
40
+ warn_unknown_keys(model, unknown) unless unknown.empty?
41
+ fields.filter_map(&:last).to_h
42
+ end
43
+
44
+ def hash_entry(model, entry)
45
+ return {} if entry.nil?
46
+ return entry if entry.is_a?(Hash)
47
+
48
+ raise ArgumentError, "price entry for #{model.inspect} in #{@context} must be a hash"
49
+ end
50
+
51
+ def price_field(model, key, value)
52
+ name = key.to_s
53
+ case name
54
+ when CONTEXT_THRESHOLD_KEY, MINIMUM_BILLED_SECONDS_KEY then [name, Integer(value)]
55
+ when OFF_PEAK_WINDOWS_KEY then [name, OffPeak.windows(value, label: "#{name} for #{model.inspect}")]
56
+ else
57
+ price_key = PriceKey.price_key_for(name)
58
+ [price_key, amount(value, "price for #{price_key.inspect}")] if price_key
59
+ end
60
+ end
61
+
62
+ def warn_unknown_keys(model, keys)
63
+ Logging.warn(
64
+ "Unknown price keys #{keys.inspect} for #{model.inspect} in #{@context}; " \
65
+ "ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
66
+ )
67
+ end
68
+
69
+ def provider_rates(entries, currency, context)
70
+ raise ArgumentError, "#{context} must be a hash" unless entries.is_a?(Hash)
71
+
72
+ entries.each_with_object({}) do |(key, value), rates|
73
+ dimension, tier = dimension_and_tier(key.to_s, context)
74
+ rate = service_rate(key.to_s, value, dimension, currency, context)
75
+ dimension_rates = rates[dimension.key] ||= { tiers: {} }
76
+ if tier
77
+ dimension_rates[:tiers][tier] = rate
78
+ else
79
+ dimension_rates[:default] = rate
80
+ end
81
+ end
82
+ end
83
+
84
+ def dimension_and_tier(key, context)
85
+ dimension, tier = PriceKey.parse_dimension_key(key)
86
+ return [dimension, tier] if dimension && dimension.token_key.nil?
87
+
88
+ raise ArgumentError, "service charge price key #{key.inspect} in #{context} uses unknown billing dimension"
89
+ end
90
+
91
+ def service_rate(key, value, dimension, currency, context)
92
+ {
93
+ amount: amount(value, "service charge price amount for #{key.inspect} in #{context}"),
94
+ quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
95
+ currency: currency,
96
+ source_key: key
97
+ }
98
+ end
99
+
100
+ def amount(value, label)
101
+ decimal = BigDecimal(value.to_s)
102
+ raise ArgumentError, "#{label} must be finite (got #{value})" unless decimal.finite?
103
+ raise ArgumentError, "#{label} must be non-negative (got #{value})" if decimal.negative?
104
+
105
+ decimal
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end