llm_cost_tracker 0.7.3 → 0.9.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/.ruby-version +1 -0
- data/CHANGELOG.md +173 -0
- data/README.md +60 -220
- data/app/assets/llm_cost_tracker/application.css +282 -45
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
- data/app/models/llm_cost_tracker/call.rb +166 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
- data/app/models/llm_cost_tracker/call_tag.rb +12 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +95 -0
- data/lib/llm_cost_tracker/billing/components.yml +188 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +26 -36
- data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +86 -17
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
- data/lib/llm_cost_tracker/doctor.rb +111 -44
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +11 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
- data/lib/llm_cost_tracker/ingestion.rb +66 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
- data/lib/llm_cost_tracker/integrations/base.rb +56 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
- data/lib/llm_cost_tracker/integrations.rb +21 -3
- data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +103 -20
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +5 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
- data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
- data/lib/llm_cost_tracker/parsers/base.rb +13 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +198 -22
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +220 -28
- data/lib/llm_cost_tracker/railtie.rb +6 -8
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +19 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +22 -9
- data/lib/llm_cost_tracker/tags/context.rb +2 -5
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +64 -42
- data/lib/llm_cost_tracker/tracker.rb +97 -27
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +45 -35
- data/lib/tasks/llm_cost_tracker.rake +45 -17
- metadata +71 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -8,7 +8,6 @@ require "rubygems"
|
|
|
8
8
|
require_relative "registry"
|
|
9
9
|
require_relative "sync/fetcher"
|
|
10
10
|
require_relative "sync/registry_diff"
|
|
11
|
-
require_relative "sync/registry_loader"
|
|
12
11
|
require_relative "sync/registry_writer"
|
|
13
12
|
|
|
14
13
|
module LlmCostTracker
|
|
@@ -39,7 +38,7 @@ module LlmCostTracker
|
|
|
39
38
|
|
|
40
39
|
def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
|
|
41
40
|
today: Date.today)
|
|
42
|
-
current =
|
|
41
|
+
current = load_registry(path)
|
|
43
42
|
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
44
43
|
|
|
45
44
|
if response.not_modified
|
|
@@ -55,7 +54,10 @@ module LlmCostTracker
|
|
|
55
54
|
end
|
|
56
55
|
|
|
57
56
|
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
58
|
-
|
|
57
|
+
unless preview
|
|
58
|
+
RegistryWriter.new.call(path: path, registry: remote)
|
|
59
|
+
invalidate_pricing_caches!
|
|
60
|
+
end
|
|
59
61
|
refresh_result(
|
|
60
62
|
path: path,
|
|
61
63
|
url: url,
|
|
@@ -67,8 +69,14 @@ module LlmCostTracker
|
|
|
67
69
|
)
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
def invalidate_pricing_caches!
|
|
73
|
+
Pricing::Lookup.reset!
|
|
74
|
+
Pricing::Registry.reset!
|
|
75
|
+
Pricing::ServiceCharges.reset!
|
|
76
|
+
end
|
|
77
|
+
|
|
70
78
|
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
71
|
-
current =
|
|
79
|
+
current = load_registry(path)
|
|
72
80
|
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
73
81
|
|
|
74
82
|
if response.not_modified
|
|
@@ -82,7 +90,7 @@ module LlmCostTracker
|
|
|
82
90
|
end
|
|
83
91
|
|
|
84
92
|
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
85
|
-
changes =
|
|
93
|
+
changes = registry_changes(current, remote)
|
|
86
94
|
|
|
87
95
|
CheckResult.new(
|
|
88
96
|
path: path,
|
|
@@ -118,10 +126,15 @@ module LlmCostTracker
|
|
|
118
126
|
raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
|
|
119
127
|
end
|
|
120
128
|
|
|
121
|
-
|
|
122
|
-
Registry.normalize_price_table(
|
|
129
|
+
raw_models = registry.fetch("models", {})
|
|
130
|
+
models = Registry.normalize_price_table(raw_models).each_with_object({}) do |(model, prices), normalized|
|
|
131
|
+
model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
|
|
132
|
+
normalized[model] = model_metadata.merge(prices.to_h { |key, value| [key.name, value] })
|
|
133
|
+
end
|
|
134
|
+
service_charges = registry["service_charges"]
|
|
135
|
+
ServiceCharges.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
|
|
123
136
|
|
|
124
|
-
|
|
137
|
+
normalized = {
|
|
125
138
|
"metadata" => metadata.merge(
|
|
126
139
|
"schema_version" => schema_version,
|
|
127
140
|
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
@@ -129,11 +142,21 @@ module LlmCostTracker
|
|
|
129
142
|
"source_version" => response.source_version
|
|
130
143
|
),
|
|
131
144
|
"models" => models
|
|
132
|
-
|
|
145
|
+
}
|
|
146
|
+
normalized["service_charges"] = service_charges if service_charges.present?
|
|
147
|
+
normalized
|
|
133
148
|
rescue ArgumentError, TypeError => e
|
|
134
149
|
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
135
150
|
end
|
|
136
151
|
|
|
152
|
+
def load_registry(path)
|
|
153
|
+
return {} unless File.exist?(path)
|
|
154
|
+
|
|
155
|
+
YAML.safe_load_file(path, aliases: false) || {}
|
|
156
|
+
rescue Psych::Exception, ArgumentError, TypeError => e
|
|
157
|
+
raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
|
|
137
160
|
def parse_registry(body)
|
|
138
161
|
registry = JSON.parse(body.to_s)
|
|
139
162
|
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
@@ -148,11 +171,37 @@ module LlmCostTracker
|
|
|
148
171
|
path: path,
|
|
149
172
|
source_url: url,
|
|
150
173
|
source_version: response.source_version,
|
|
151
|
-
changes:
|
|
174
|
+
changes: registry_changes(current, remote),
|
|
152
175
|
written: written,
|
|
153
176
|
not_modified: not_modified
|
|
154
177
|
)
|
|
155
178
|
end
|
|
179
|
+
|
|
180
|
+
def registry_changes(current, remote)
|
|
181
|
+
model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
182
|
+
charge_changes = service_charges_diff(
|
|
183
|
+
current.fetch("service_charges", {}),
|
|
184
|
+
remote.fetch("service_charges", {})
|
|
185
|
+
)
|
|
186
|
+
return model_changes if charge_changes.empty?
|
|
187
|
+
|
|
188
|
+
model_changes.merge("service_charges" => charge_changes)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def service_charges_diff(current, remote)
|
|
192
|
+
(current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
|
|
193
|
+
current_rates = (current[provider] || {}).transform_keys(&:to_s)
|
|
194
|
+
remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
|
|
195
|
+
(current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
|
|
196
|
+
from = current_rates[component]
|
|
197
|
+
to = remote_rates[component]
|
|
198
|
+
next if from == to
|
|
199
|
+
|
|
200
|
+
changes[provider] ||= {}
|
|
201
|
+
changes[provider][component] = { "from" => from, "to" => to }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
156
205
|
end
|
|
157
206
|
end
|
|
158
207
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
module SyncChangePrinter
|
|
6
|
+
class << self
|
|
7
|
+
def call(changes, output: $stdout)
|
|
8
|
+
service_changes = changes["service_charges"]
|
|
9
|
+
model_changes = changes.except("service_charges")
|
|
10
|
+
|
|
11
|
+
output.puts " changed models: #{model_changes.size}"
|
|
12
|
+
model_changes.each do |model, fields|
|
|
13
|
+
output.puts " - #{model}"
|
|
14
|
+
fields.each do |field, values|
|
|
15
|
+
output.puts " #{field}: #{values['from'].inspect} -> #{values['to'].inspect}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return if service_changes.nil? || service_changes.empty?
|
|
20
|
+
|
|
21
|
+
output.puts " changed service charges: #{service_changes.values.sum(&:size)}"
|
|
22
|
+
service_changes.each do |provider, components|
|
|
23
|
+
components.each do |component, values|
|
|
24
|
+
output.puts " - #{provider}.#{component}: " \
|
|
25
|
+
"#{values['from'].inspect} -> #{values['to'].inspect}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,75 +1,267 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "active_support/core_ext/hash/keys"
|
|
4
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "time"
|
|
5
5
|
|
|
6
|
-
require_relative "
|
|
6
|
+
require_relative "version"
|
|
7
|
+
require_relative "token_usage"
|
|
8
|
+
require_relative "billing/components"
|
|
9
|
+
require_relative "pricing/mode"
|
|
7
10
|
require_relative "pricing/registry"
|
|
8
11
|
require_relative "pricing/lookup"
|
|
9
12
|
require_relative "pricing/effective_prices"
|
|
10
13
|
require_relative "pricing/explainer"
|
|
14
|
+
require_relative "pricing/service_charges"
|
|
11
15
|
|
|
12
16
|
module LlmCostTracker
|
|
13
|
-
module Pricing
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
module Pricing # rubocop:disable Metrics/ModuleLength
|
|
18
|
+
extend ServiceCharges
|
|
19
|
+
|
|
20
|
+
STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
|
|
21
|
+
RATE_DENOMINATOR_TOKENS = 1_000_000
|
|
22
|
+
private_constant :STANDARD_MODE_VALUES, :RATE_DENOMINATOR_TOKENS
|
|
16
23
|
|
|
17
24
|
class << self
|
|
18
25
|
def normalize_mode(value)
|
|
19
|
-
|
|
26
|
+
return nil if value.nil?
|
|
27
|
+
|
|
28
|
+
mode = normalize_string_mode(value.to_s)
|
|
20
29
|
return nil unless mode
|
|
21
30
|
|
|
22
|
-
mode = mode.tr("-", "_")
|
|
23
31
|
STANDARD_MODE_VALUES.include?(mode) ? nil : mode
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
def cost_for(provider:, model:,
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
def cost_for(provider:, model:, tokens:, pricing_mode: nil)
|
|
35
|
+
calculation = calculation_for(
|
|
36
|
+
provider: provider,
|
|
37
|
+
model: model,
|
|
38
|
+
tokens: tokens,
|
|
39
|
+
pricing_mode: pricing_mode
|
|
40
|
+
)
|
|
41
|
+
return nil unless calculation
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
cost_from(calculation)
|
|
44
|
+
end
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
def calculate(provider:, model:, tokens:, line_items:, pricing_mode: nil)
|
|
47
|
+
calculation = calculation_for(
|
|
48
|
+
provider: provider,
|
|
49
|
+
model: model,
|
|
50
|
+
tokens: tokens,
|
|
51
|
+
pricing_mode: pricing_mode
|
|
52
|
+
)
|
|
53
|
+
cost_data = calculation && cost_from(calculation)
|
|
54
|
+
snapshot = calculation && snapshot_from(calculation)
|
|
55
|
+
priced = apply_calculation_to_line_items(line_items, calculation,
|
|
56
|
+
provider: provider, pricing_mode: pricing_mode)
|
|
57
|
+
[cost_data, snapshot, priced]
|
|
58
|
+
end
|
|
36
59
|
|
|
37
|
-
|
|
60
|
+
def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
|
|
61
|
+
token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
|
|
62
|
+
calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
|
|
63
|
+
snapshot = calculation && snapshot_from(calculation)
|
|
64
|
+
priced = apply_calculation_to_line_items(line_items, calculation,
|
|
65
|
+
provider: provider, pricing_mode: pricing_mode)
|
|
66
|
+
[priced, snapshot]
|
|
38
67
|
end
|
|
39
68
|
|
|
40
|
-
def
|
|
41
|
-
|
|
69
|
+
def snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
|
|
70
|
+
calculation = calculation_for(
|
|
71
|
+
provider: provider,
|
|
72
|
+
model: model,
|
|
73
|
+
tokens: tokens,
|
|
74
|
+
pricing_mode: pricing_mode
|
|
75
|
+
)
|
|
76
|
+
return nil unless calculation
|
|
77
|
+
|
|
78
|
+
snapshot_from(calculation)
|
|
42
79
|
end
|
|
43
80
|
|
|
44
|
-
def explain(provider:, model:,
|
|
81
|
+
def explain(provider:, model:, tokens:, pricing_mode: nil)
|
|
45
82
|
Explainer.call(
|
|
46
83
|
provider: provider,
|
|
47
84
|
model: model,
|
|
48
|
-
|
|
85
|
+
tokens: tokens,
|
|
49
86
|
pricing_mode: pricing_mode
|
|
50
87
|
)
|
|
51
88
|
end
|
|
52
89
|
|
|
53
90
|
def stored_cost_attributes(attributes)
|
|
54
|
-
attributes.to_h
|
|
91
|
+
value = attributes.to_h[:total_cost]
|
|
92
|
+
value ? { total_cost: value } : {}
|
|
55
93
|
end
|
|
56
94
|
|
|
57
95
|
private
|
|
58
96
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
return nil if
|
|
97
|
+
def normalize_string_mode(value)
|
|
98
|
+
normalized = value.strip
|
|
99
|
+
return nil if normalized.empty?
|
|
100
|
+
|
|
101
|
+
normalized.downcase.tr("-", "_").to_sym
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cost_from(calculation)
|
|
105
|
+
costs = calculation[:costs]
|
|
106
|
+
values = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, result|
|
|
107
|
+
cost = costs[component.key]
|
|
108
|
+
result[component.cost_key] = cost.round(8) unless cost.nil?
|
|
109
|
+
end
|
|
110
|
+
values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
|
|
111
|
+
values
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def snapshot_from(calculation)
|
|
115
|
+
match = calculation[:match]
|
|
116
|
+
effective = calculation[:effective]
|
|
117
|
+
token_usage = calculation[:token_usage]
|
|
118
|
+
rates = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, values|
|
|
119
|
+
quantity = token_usage.public_send(component.token_key)
|
|
120
|
+
price = effective[component.key]
|
|
121
|
+
next if quantity.zero? || price.nil?
|
|
122
|
+
|
|
123
|
+
values[component.key] = {
|
|
124
|
+
amount: price,
|
|
125
|
+
quantity: RATE_DENOMINATOR_TOKENS
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
schema_version: 1,
|
|
131
|
+
source: match.source,
|
|
132
|
+
source_key: match.key,
|
|
133
|
+
source_version: source_version_for(match.source),
|
|
134
|
+
matched_by: match.matched_by,
|
|
135
|
+
currency: "USD",
|
|
136
|
+
rates: rates
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def calculation_for(provider:, model:, tokens:, pricing_mode:)
|
|
141
|
+
match = Lookup.call(provider: provider, model: model)
|
|
142
|
+
return nil unless match
|
|
143
|
+
|
|
144
|
+
token_usage = TokenUsage.build_from_tokens(tokens)
|
|
145
|
+
mode = normalize_mode(pricing_mode)
|
|
146
|
+
effective = EffectivePrices.call(usage: token_usage, prices: match.prices, pricing_mode: mode)
|
|
147
|
+
return nil unless any_billable_priced?(token_usage, effective)
|
|
148
|
+
|
|
149
|
+
{ match: match, effective: effective, token_usage: token_usage, costs: costs_for(token_usage, effective) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def any_billable_priced?(token_usage, effective)
|
|
153
|
+
billable = Billing::Components::TOKEN_PRICED.select { |c| token_usage.public_send(c.token_key).positive? }
|
|
154
|
+
billable.empty? || billable.any? { |c| effective[c.key] }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def costs_for(usage, effective)
|
|
158
|
+
Billing::Components::TOKEN_PRICED.to_h do |component|
|
|
159
|
+
tokens = usage.public_send(component.token_key)
|
|
160
|
+
[component.key, token_cost(tokens, effective[component.key])]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
|
|
165
|
+
line_items.map do |line_item|
|
|
166
|
+
next price_token_line_item(line_item, calculation) if line_item.unit == :token
|
|
167
|
+
|
|
168
|
+
price_service_charge_line_item(line_item,
|
|
169
|
+
provider: provider,
|
|
170
|
+
calculation: calculation,
|
|
171
|
+
pricing_mode: pricing_mode)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def token_attributes_from(line_items)
|
|
176
|
+
line_items.each_with_object({}) do |line_item, totals|
|
|
177
|
+
next unless line_item.unit == :token
|
|
178
|
+
|
|
179
|
+
component = component_for_line_item(line_item)
|
|
180
|
+
next unless component
|
|
181
|
+
|
|
182
|
+
totals[component.key] = (totals[component.key] || 0) + line_item.quantity.to_i
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def price_token_line_item(line_item, calculation)
|
|
187
|
+
component = component_for_line_item(line_item)
|
|
188
|
+
return line_item unless component
|
|
189
|
+
return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) unless calculation
|
|
190
|
+
|
|
191
|
+
effective_price = calculation[:effective][component.key]
|
|
192
|
+
return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) if effective_price.nil?
|
|
193
|
+
|
|
194
|
+
cost = (line_item.quantity * BigDecimal(effective_price.to_s)) / RATE_DENOMINATOR_TOKENS
|
|
195
|
+
match = calculation[:match]
|
|
196
|
+
line_item.with(
|
|
197
|
+
rate_amount: BigDecimal(effective_price.to_s),
|
|
198
|
+
rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
|
|
199
|
+
cost: cost,
|
|
200
|
+
cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
|
|
201
|
+
price_key: component.key,
|
|
202
|
+
price_source: match.source,
|
|
203
|
+
price_source_version: source_version_for(match.source)
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def price_service_charge_line_item(line_item, provider:, calculation:, pricing_mode:)
|
|
208
|
+
return line_item if line_item.priced?
|
|
209
|
+
return line_item unless line_item.billable?
|
|
210
|
+
|
|
211
|
+
rate = model_rate_for(line_item, calculation) ||
|
|
212
|
+
charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
|
|
213
|
+
return line_item unless rate
|
|
214
|
+
|
|
215
|
+
line_item.apply_rate(rate)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def model_rate_for(line_item, calculation)
|
|
219
|
+
return nil unless calculation
|
|
220
|
+
|
|
221
|
+
match = calculation[:match]
|
|
222
|
+
amount = match.prices[line_item.kind] || match.prices[line_item.kind.to_s]
|
|
223
|
+
return nil unless amount.is_a?(Numeric)
|
|
224
|
+
|
|
225
|
+
component = Billing::Components::BY_KEY[line_item.kind]
|
|
226
|
+
{
|
|
227
|
+
amount: BigDecimal(amount.to_s),
|
|
228
|
+
quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
|
|
229
|
+
currency: "USD",
|
|
230
|
+
source: match.source,
|
|
231
|
+
source_key: "#{match.key}.#{line_item.kind}",
|
|
232
|
+
source_version: source_version_for(match.source)
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def component_for_line_item(line_item)
|
|
237
|
+
Billing::Components::REGISTRY.find do |component|
|
|
238
|
+
component.kind == line_item.kind &&
|
|
239
|
+
component.direction == line_item.direction &&
|
|
240
|
+
component.modality == line_item.modality &&
|
|
241
|
+
component.cache_state == line_item.cache_state &&
|
|
242
|
+
component.unit == line_item.unit
|
|
243
|
+
end
|
|
244
|
+
end
|
|
62
245
|
|
|
63
|
-
|
|
64
|
-
|
|
246
|
+
def source_version_for(source)
|
|
247
|
+
case source
|
|
248
|
+
when :bundled
|
|
249
|
+
LlmCostTracker::VERSION
|
|
250
|
+
when :prices_file
|
|
251
|
+
path = LlmCostTracker.configuration.prices_file
|
|
252
|
+
path ? File.mtime(path).utc.iso8601 : nil
|
|
253
|
+
when :pricing_overrides
|
|
254
|
+
"configuration"
|
|
65
255
|
end
|
|
256
|
+
rescue Errno::ENOENT
|
|
257
|
+
nil
|
|
66
258
|
end
|
|
67
259
|
|
|
68
260
|
def token_cost(tokens, per_million_price)
|
|
69
|
-
return 0
|
|
261
|
+
return BigDecimal("0") if tokens.zero?
|
|
70
262
|
return nil if per_million_price.nil?
|
|
71
263
|
|
|
72
|
-
(tokens.
|
|
264
|
+
(BigDecimal(tokens.to_s) * BigDecimal(per_million_price.to_s)) / RATE_DENOMINATOR_TOKENS
|
|
73
265
|
end
|
|
74
266
|
end
|
|
75
267
|
end
|
|
@@ -9,16 +9,14 @@ module LlmCostTracker
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
generators do
|
|
12
|
-
require_relative "generators/llm_cost_tracker/add_ingestion_generator"
|
|
13
|
-
require_relative "generators/llm_cost_tracker/add_period_totals_generator"
|
|
14
|
-
require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
|
|
15
|
-
require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
|
|
16
|
-
require_relative "generators/llm_cost_tracker/add_streaming_generator"
|
|
17
|
-
require_relative "generators/llm_cost_tracker/add_token_usage_generator"
|
|
18
12
|
require_relative "generators/llm_cost_tracker/install_generator"
|
|
19
13
|
require_relative "generators/llm_cost_tracker/prices_generator"
|
|
20
|
-
require_relative "generators/llm_cost_tracker/
|
|
21
|
-
require_relative "generators/llm_cost_tracker/
|
|
14
|
+
require_relative "generators/llm_cost_tracker/call_rollups_generator"
|
|
15
|
+
require_relative "generators/llm_cost_tracker/durable_ingestion_generator"
|
|
16
|
+
require_relative "generators/llm_cost_tracker/reconciliation_generator"
|
|
17
|
+
require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
|
|
18
|
+
require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
|
|
19
|
+
require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
|
|
22
20
|
end
|
|
23
21
|
|
|
24
22
|
rake_tasks do
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "reconciliation"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module ReconcileTasks
|
|
9
|
+
SOURCE_PARSERS = {
|
|
10
|
+
"openai" => Reconciliation::Sources::OpenaiUsage,
|
|
11
|
+
"anthropic" => Reconciliation::Sources::AnthropicUsage
|
|
12
|
+
}.freeze
|
|
13
|
+
GENERIC_SOURCES = %w[csv].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run_import(env: ENV, output: $stdout, error_output: $stderr)
|
|
18
|
+
result = import_from_env(env: env)
|
|
19
|
+
output.puts "llm_cost_tracker: imported #{result.total_imported} rows " \
|
|
20
|
+
"(inserted=#{result.inserted}, updated=#{result.updated}, skipped=#{result.skipped})"
|
|
21
|
+
result.errors.each { |error| error_output.puts " error: #{error}" }
|
|
22
|
+
raise "llm_cost_tracker: reconcile import had errors" unless result.success?
|
|
23
|
+
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run_diff(env: ENV, output: $stdout)
|
|
28
|
+
diff = diff_from_env(env: env)
|
|
29
|
+
print_diff(diff, output: output)
|
|
30
|
+
diff
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def import_from_env(env: ENV)
|
|
34
|
+
source = required_env(env, "SOURCE")
|
|
35
|
+
input_path = required_env(env, "INPUT")
|
|
36
|
+
raise ArgumentError, "INPUT file not found: #{input_path}" unless File.exist?(input_path)
|
|
37
|
+
|
|
38
|
+
payload = JSON.parse(File.read(input_path))
|
|
39
|
+
rows = parse_rows(source: source, payload: payload)
|
|
40
|
+
Reconciliation.import(source: source.to_sym, rows: rows, provider: env["PROVIDER"])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def diff_from_env(env: ENV)
|
|
44
|
+
source = required_env(env, "SOURCE")
|
|
45
|
+
period_start = Date.parse(required_env(env, "PERIOD_START"))
|
|
46
|
+
period_end = Date.parse(required_env(env, "PERIOD_END"))
|
|
47
|
+
Reconciliation.diff(source: source.to_sym, period_start: period_start, period_end: period_end,
|
|
48
|
+
provider: env["PROVIDER"],
|
|
49
|
+
drilldown_limit: parse_drilldown_limit(env["DRILLDOWN_LIMIT"]))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_drilldown_limit(value)
|
|
53
|
+
return Reconciliation::Diff::DEFAULT_DRILLDOWN_LIMIT if value.nil? || value.to_s.empty?
|
|
54
|
+
return nil if value.to_s.downcase == "all"
|
|
55
|
+
|
|
56
|
+
Integer(value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def print_diff(diff, output: $stdout)
|
|
60
|
+
output.puts "llm_cost_tracker: reconciliation diff for #{diff.source} " \
|
|
61
|
+
"#{diff.period_start}..#{diff.period_end}"
|
|
62
|
+
output.puts " provider_total: #{diff.provider_total.to_s('F')} #{diff.currency}"
|
|
63
|
+
output.puts " local_total: #{diff.local_total.to_s('F')} #{diff.currency} " \
|
|
64
|
+
"(from #{diff.local_total_source})"
|
|
65
|
+
output.puts " delta: #{diff.delta_amount.to_s('F')} (#{diff.delta_percent || 'n/a'}%)"
|
|
66
|
+
print_unmatched_provider_rows(diff, output)
|
|
67
|
+
print_unmatched_local_calls(diff, output)
|
|
68
|
+
print_non_cost_rows(diff, output)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_rows(source:, payload:)
|
|
72
|
+
parser = SOURCE_PARSERS[source.to_s]
|
|
73
|
+
return parser.parse(payload) if parser
|
|
74
|
+
return Array(payload["rows"]) if GENERIC_SOURCES.include?(source.to_s)
|
|
75
|
+
|
|
76
|
+
known = (SOURCE_PARSERS.keys + GENERIC_SOURCES).join(", ")
|
|
77
|
+
raise ArgumentError, "unknown SOURCE #{source.inspect}; known sources: #{known}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def required_env(env, key)
|
|
81
|
+
value = env[key].to_s.strip
|
|
82
|
+
raise ArgumentError, "missing #{key}" if value.empty?
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def print_unmatched_provider_rows(diff, output)
|
|
88
|
+
return if diff.unmatched_provider_rows.empty?
|
|
89
|
+
|
|
90
|
+
output.puts " unmatched provider rows#{truncation_suffix(diff.unmatched_provider_rows.size,
|
|
91
|
+
diff.unmatched_provider_rows_total)}:"
|
|
92
|
+
diff.unmatched_provider_rows.each do |row|
|
|
93
|
+
output.puts " #{row[:external_id]} (#{row[:match_basis]}): " \
|
|
94
|
+
"#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def print_unmatched_local_calls(diff, output)
|
|
99
|
+
return if diff.unmatched_local_calls.empty?
|
|
100
|
+
|
|
101
|
+
output.puts " unmatched local calls#{truncation_suffix(diff.unmatched_local_calls.size,
|
|
102
|
+
diff.unmatched_local_calls_total)}:"
|
|
103
|
+
diff.unmatched_local_calls.each do |row|
|
|
104
|
+
output.puts " #{row[:count]} calls / #{row[:total_cost].to_s('F')} " \
|
|
105
|
+
"#{format_attribution(row[:attribution])}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def print_non_cost_rows(diff, output)
|
|
110
|
+
return if diff.non_cost_rows.empty?
|
|
111
|
+
|
|
112
|
+
output.puts " non-cost evidence#{truncation_suffix(diff.non_cost_rows.size,
|
|
113
|
+
diff.non_cost_rows_total)}:"
|
|
114
|
+
diff.non_cost_rows.each do |row|
|
|
115
|
+
output.puts " [#{row[:row_type]}/#{row[:meter]}] #{format_amount(row[:billed_amount])} " \
|
|
116
|
+
"#{format_attribution(row[:attribution])}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def truncation_suffix(shown, total)
|
|
121
|
+
return "" if shown >= total
|
|
122
|
+
|
|
123
|
+
" (showing #{shown} of #{total} — pass DRILLDOWN_LIMIT=all to see every row)"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def format_amount(value)
|
|
127
|
+
value.nil? ? "n/a" : value.to_s("F")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def format_attribution(attribution)
|
|
131
|
+
LlmCostTracker::Masking.format_attribution(attribution, separator: ",")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|