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
@@ -4,13 +4,13 @@ require_relative "../pricing"
4
4
  require_relative "../charges/line_item"
5
5
  require_relative "../ledger/rollups"
6
6
  require_relative "../usage/token_usage"
7
- require_relative "../budget/per_tag"
7
+ require_relative "../budget"
8
8
 
9
9
  module LlmCostTracker
10
10
  module Pricing
11
11
  module Backfill
12
12
  Result = Data.define(:examined, :recomputed, :still_unknown)
13
- RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost)
13
+ RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost, :tags)
14
14
 
15
15
  DEFAULT_BATCH_SIZE = 500
16
16
  REGISTRY_SOURCES = %w[pricing_overrides prices_file bundled].freeze
@@ -21,23 +21,12 @@ module LlmCostTracker
21
21
  def call(scope: default_scope, batch_size: DEFAULT_BATCH_SIZE, reprice: false)
22
22
  examined = 0
23
23
  recomputed = 0
24
-
25
- scope.includes(:line_items).find_in_batches(batch_size: batch_size) do |batch|
26
- rollup_events = []
27
- LlmCostTracker::Call.transaction do
28
- batch.each do |call|
29
- examined += 1
30
- calculation = recompute_for(call, reprice: reprice)
31
- next unless calculation
32
-
33
- rollup_events << rollup_event_for(call, calculation)
34
- persist!(call, calculation)
35
- recomputed += 1
36
- end
37
- Ledger::Rollups.increment!(rollup_events)
38
- end
24
+ scope.includes(:line_items, :tag_records).find_in_batches(batch_size: batch_size) do |batch|
25
+ batch_examined, rollup_events = recompute_batch(batch, reprice: reprice)
26
+ examined += batch_examined
27
+ recomputed += rollup_events.size
28
+ LlmCostTracker::Budget.notify_repriced_safely!(rollup_events)
39
29
  end
40
-
41
30
  Result.new(examined: examined, recomputed: recomputed, still_unknown: examined - recomputed)
42
31
  end
43
32
 
@@ -55,8 +44,32 @@ module LlmCostTracker
55
44
  calls.where(usage_source: nil).or(calls.where.not(usage_source: Usage::Source::UNKNOWN))
56
45
  end
57
46
 
47
+ def recompute_batch(batch, reprice:)
48
+ examined = 0
49
+ rollup_events = []
50
+ LlmCostTracker::Call.transaction do
51
+ batch.each do |call|
52
+ calculation = recompute_for(call, reprice: reprice)
53
+ next if calculation && changed_since_read?(call)
54
+
55
+ examined += 1
56
+ next unless calculation
57
+
58
+ rollup_events << rollup_event_for(call, calculation)
59
+ persist!(call, calculation)
60
+ end
61
+ Ledger::Rollups.increment!(rollup_events)
62
+ end
63
+ [examined, rollup_events]
64
+ end
65
+
58
66
  def recompute_for(call, reprice:)
59
- calculation = Pricing::Calculation.for(
67
+ calculation = calculation_for(call, reprice: reprice)
68
+ calculation if applicable?(call, calculation, reprice: reprice)
69
+ end
70
+
71
+ def calculation_for(call, reprice:)
72
+ Pricing::Calculation.for(
60
73
  provider: call.provider,
61
74
  model: call.model,
62
75
  tokens: token_usage_from(call),
@@ -65,13 +78,24 @@ module LlmCostTracker
65
78
  usage_source: call.usage_source,
66
79
  at: call.tracked_at
67
80
  )
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
81
+ end
71
82
 
83
+ def applicable?(call, calculation, reprice:)
84
+ return false unless calculation.token_cost || provider_billed?(calculation)
85
+ return false if [calculation.cost.total, calculation.cost_status] == [call.total_cost, call.cost_status]
86
+
87
+ reprice || recorded_rates_current?(call, calculation)
88
+ end
89
+
90
+ def recorded_rates_current?(call, calculation)
72
91
  rates = calculation.priced_line_items.to_h { |item| [dimension_key(item), item.rate_amount] }
73
92
  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)] }
93
+ recorded.all? { |record| record.rate_amount == rates[dimension_key(record)] }
94
+ end
95
+
96
+ def changed_since_read?(call)
97
+ stored = LlmCostTracker::Call.lock.where(id: call.id).pick(:total_cost, :cost_status)
98
+ stored != [call.total_cost, call.cost_status]
75
99
  end
76
100
 
77
101
  def provider_billed?(calculation)
@@ -88,14 +112,18 @@ module LlmCostTracker
88
112
  call.update!(
89
113
  total_cost: calculation.cost.total,
90
114
  pricing_snapshot: calculation.snapshot,
91
- cost_status: calculation.cost_status
115
+ cost_status: calculation.cost_status,
116
+ pricing_mode: calculation.mode
92
117
  )
93
118
  resync_tag_costs(call, calculation.cost.total)
94
- token_priced = calculation.priced_line_items.select(&:token?).index_by { |item| dimension_key(item) }
95
- service_priced = calculation.priced_line_items.reject(&:token?)
96
- token_records, service_records = call.line_items.partition { |record| record.unit == "token" }
119
+ apply_rates(call.line_items, calculation.priced_line_items)
120
+ end
97
121
 
98
- token_records.each { |record| apply_rate(record, token_priced[dimension_key(record)]) }
122
+ def apply_rates(records, priced_items)
123
+ token_records, service_records = records.partition { |record| record.unit == "token" }
124
+ token_priced, service_priced = priced_items.partition(&:token?)
125
+ by_dimension = token_priced.index_by { |item| dimension_key(item) }
126
+ token_records.each { |record| apply_rate(record, by_dimension[dimension_key(record)]) }
99
127
  service_records.sort_by(&:position).zip(service_priced).each { |record, priced| apply_rate(record, priced) }
100
128
  end
101
129
 
@@ -111,6 +139,7 @@ module LlmCostTracker
111
139
  return unless priced
112
140
 
113
141
  record.update!(
142
+ quantity: priced.quantity,
114
143
  rate_amount: priced.rate_amount,
115
144
  rate_quantity: priced.rate_quantity,
116
145
  cost: priced.cost,
@@ -131,7 +160,8 @@ module LlmCostTracker
131
160
  provider: call.provider,
132
161
  tracked_at: call.tracked_at,
133
162
  pricing_snapshot: calculation.snapshot,
134
- total_cost: calculation.cost.total - call.total_cost.to_d
163
+ total_cost: calculation.cost.total - call.total_cost.to_d,
164
+ tags: call.tag_pairs
135
165
  )
136
166
  end
137
167
 
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ class Calculation
6
+ class Iterations
7
+ def initialize(provider:, requested_mode:, at:)
8
+ @provider = provider
9
+ @requested_mode = requested_mode
10
+ @at = at
11
+ @partial = false
12
+ end
13
+
14
+ def price(line_item)
15
+ calculation = calculation_for(line_item.details.to_h.transform_keys(&:to_sym))
16
+ cost = calculation.token_cost
17
+ return line_item unless cost
18
+
19
+ @partial ||= calculation.cost_status == Charges::CostStatus::PARTIAL
20
+ status = cost.total.zero? ? Charges::CostStatus::FREE : Charges::CostStatus::COMPLETE
21
+ line_item.with(rate_amount: cost.total, cost: cost.total, currency: cost.currency, cost_status: status)
22
+ end
23
+
24
+ def partial?
25
+ @partial
26
+ end
27
+
28
+ private
29
+
30
+ def calculation_for(details)
31
+ model = details[:model].to_s
32
+ Calculation.for(
33
+ provider: @provider,
34
+ model: model,
35
+ tokens: details.slice(*Usage::TokenUsage.members),
36
+ pricing_mode: mode_for(model),
37
+ at: @at
38
+ )
39
+ end
40
+
41
+ def mode_for(model)
42
+ return @requested_mode if Matcher.modifier_priced?(provider: @provider, model: model, modifier: "fast")
43
+
44
+ Mode.compose(Mode.tokenize(@requested_mode) - ["fast"])
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Pricing
5
+ class Calculation
6
+ class Quantities
7
+ UNIT_BILLED_KINDS = %w[transcription_minute text_to_speech_character ocr_page rerank_search_unit].freeze
8
+
9
+ def initialize(token_usage, line_items, prices)
10
+ @token_usage = token_usage
11
+ @line_items = line_items
12
+ @price_keys = prices.keys
13
+ end
14
+
15
+ def to_h
16
+ @line_items.each_with_object(token_counts) do |line_item, quantities|
17
+ dimension = line_item.dimension
18
+ next unless dimension&.parent
19
+
20
+ quantity = [line_item.quantity.to_i, quantities.fetch(dimension.parent)].min
21
+ quantities[dimension.parent] -= quantity
22
+ quantities[dimension.key] = quantities.fetch(dimension.key, 0) + quantity
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def token_counts
29
+ counts = @token_usage.priced_quantities
30
+ billed_by_unit? ? counts.transform_values { 0 } : counts
31
+ end
32
+
33
+ def billed_by_unit?
34
+ !@price_keys.intersect?(Registry::PRICE_KEYS) &&
35
+ (@price_keys & UNIT_BILLED_KINDS).intersect?(@line_items.map(&:kind))
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal/util"
4
+
5
+ module LlmCostTracker
6
+ module Pricing
7
+ class Calculation
8
+ module Snapshot
9
+ SCHEMA_VERSION = 1
10
+
11
+ class << self
12
+ def for_match(match, service_lines, token_lines)
13
+ {
14
+ "schema_version" => SCHEMA_VERSION,
15
+ "source" => match.source.name,
16
+ "source_key" => match.key,
17
+ "source_version" => match.source.version,
18
+ "matched_by" => match.matched_by.to_s,
19
+ "currency" => match.source.currency,
20
+ "rates" => rates(service_lines).merge(rates(token_lines))
21
+ }
22
+ end
23
+
24
+ def for_service_charges(lines, currency)
25
+ {
26
+ "schema_version" => SCHEMA_VERSION,
27
+ "source" => lines.first.price_source,
28
+ "source_version" => lines.first.price_source_version,
29
+ "matched_by" => "service_charges",
30
+ "currency" => currency,
31
+ "rates" => rates(lines)
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def rates(line_items)
38
+ line_items.each_with_object({}) do |line_item, rates|
39
+ next if line_item.price_key.nil? || line_item.rate_amount.nil?
40
+
41
+ rates[line_item.price_key] ||= {
42
+ "amount" => line_item.rate_amount.to_d.to_s("F"),
43
+ "quantity" => Integer(line_item.rate_quantity)
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module LlmCostTracker
6
+ module Pricing
7
+ class Calculation
8
+ module Totals
9
+ class << self
10
+ def token_cost(token_lines, currency)
11
+ Charges::Cost.new(components: components(token_lines).freeze, total: sum(token_lines), currency: currency)
12
+ end
13
+
14
+ def in_call_currency(service_lines, token_cost)
15
+ return service_lines if service_lines.empty?
16
+
17
+ currency = token_cost&.currency || service_lines.first.currency || LlmCostTracker::DEFAULT_CURRENCY
18
+ counted, dropped = service_lines.partition { |line| line.currency.to_s == currency.to_s }
19
+ warn_currency_mismatch(dropped, currency) if dropped.any?
20
+ counted
21
+ end
22
+
23
+ def with_service_lines(token_cost, service_lines)
24
+ return token_cost if service_lines.empty?
25
+
26
+ Charges::Cost.new(
27
+ components: token_cost ? token_cost.components : {}.freeze,
28
+ total: (token_cost&.total || BigDecimal("0")) + sum(service_lines),
29
+ currency: (token_cost&.currency || service_lines.first.currency).to_s
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def components(token_lines)
36
+ by_component = token_lines.group_by { |line| line.dimension.parent || line.dimension.key }
37
+ Usage::Catalog.token_priced.each_with_object({}) do |dimension, components|
38
+ lines = by_component.fetch(dimension.key, [])
39
+ components[dimension.cost_key] = sum(lines) unless lines.any?(&:unpriced?)
40
+ end
41
+ end
42
+
43
+ def sum(lines)
44
+ lines.sum(BigDecimal("0")) { |line| line.cost_value.round(8) }
45
+ end
46
+
47
+ def warn_currency_mismatch(lines, base_currency)
48
+ currencies = lines.map { |line| line.currency.to_s }.uniq.sort
49
+ Logging.warn(
50
+ "Service line currency mismatch: header is #{base_currency}, dropping " \
51
+ "#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
52
+ "Per-line costs are still recorded; header total reflects #{base_currency} only."
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end