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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- 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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|