llm_cost_tracker 0.10.0 → 0.12.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 +82 -0
- data/README.md +11 -5
- data/app/assets/llm_cost_tracker/application.css +784 -802
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
- data/config/routes.rb +3 -3
- data/lib/llm_cost_tracker/budget.rb +25 -28
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -61
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +66 -64
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +14 -11
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
- data/lib/llm_cost_tracker/parsers.rb +140 -29
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -295
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -7
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +33 -20
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +92 -58
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../pricing"
|
|
4
|
-
require_relative "../
|
|
5
|
-
require_relative "../billing/line_item"
|
|
4
|
+
require_relative "../charges/line_item"
|
|
6
5
|
require_relative "../ledger/rollups"
|
|
7
|
-
require_relative "../token_usage"
|
|
6
|
+
require_relative "../usage/token_usage"
|
|
8
7
|
|
|
9
8
|
module LlmCostTracker
|
|
10
9
|
module Pricing
|
|
11
|
-
|
|
10
|
+
module Backfill
|
|
12
11
|
Result = Data.define(:examined, :recomputed, :still_unknown)
|
|
13
12
|
RollupEvent = Data.define(:provider, :tracked_at, :pricing_snapshot, :total_cost)
|
|
14
13
|
|
|
@@ -24,14 +23,14 @@ module LlmCostTracker
|
|
|
24
23
|
LlmCostTracker::Call.transaction do
|
|
25
24
|
batch.each do |call|
|
|
26
25
|
examined += 1
|
|
27
|
-
|
|
28
|
-
next unless
|
|
26
|
+
calculation = recompute_for(call)
|
|
27
|
+
next unless calculation
|
|
29
28
|
|
|
30
|
-
persist!(call,
|
|
31
|
-
rollup_events << rollup_event_for(call,
|
|
29
|
+
persist!(call, calculation)
|
|
30
|
+
rollup_events << rollup_event_for(call, calculation)
|
|
32
31
|
recomputed += 1
|
|
33
32
|
end
|
|
34
|
-
Ledger::Rollups.
|
|
33
|
+
Ledger::Rollups.increment!(rollup_events) if rollup_events.any?
|
|
35
34
|
end
|
|
36
35
|
end
|
|
37
36
|
|
|
@@ -45,93 +44,66 @@ module LlmCostTracker
|
|
|
45
44
|
private
|
|
46
45
|
|
|
47
46
|
def recompute_for(call)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
pricing_mode: call.pricing_mode
|
|
47
|
+
calculation = Pricing::Calculation.for(
|
|
48
|
+
provider: call.provider,
|
|
49
|
+
model: call.model,
|
|
50
|
+
tokens: token_usage_from(call),
|
|
51
|
+
line_items: service_line_items_from(call),
|
|
52
|
+
pricing_mode: call.pricing_mode,
|
|
53
|
+
usage_source: call.usage_source
|
|
54
54
|
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
full_cost = Pricing.combine_with_service_lines(cost_data, priced)
|
|
58
|
-
total_cost = full_cost[:total_cost]
|
|
59
|
-
return nil if total_cost.nil?
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
snapshot: snapshot,
|
|
63
|
-
priced_line_items: priced,
|
|
64
|
-
total_cost: total_cost,
|
|
65
|
-
cost_status: Billing::CostStatus.call(
|
|
66
|
-
token_usage: token_usage,
|
|
67
|
-
usage_source: call.usage_source&.to_sym,
|
|
68
|
-
token_cost: cost_data,
|
|
69
|
-
token_pricing_partial: Pricing.token_pricing_partial?(token_usage, cost_data),
|
|
70
|
-
service_line_items: priced.reject(&:token?),
|
|
71
|
-
total_cost: total_cost
|
|
72
|
-
)
|
|
73
|
-
}
|
|
55
|
+
calculation if calculation.token_cost
|
|
74
56
|
end
|
|
75
57
|
|
|
76
|
-
def persist!(call,
|
|
58
|
+
def persist!(call, calculation)
|
|
77
59
|
call.update!(
|
|
78
|
-
total_cost:
|
|
79
|
-
pricing_snapshot:
|
|
80
|
-
cost_status:
|
|
60
|
+
total_cost: calculation.cost.total,
|
|
61
|
+
pricing_snapshot: calculation.snapshot,
|
|
62
|
+
cost_status: calculation.cost_status
|
|
81
63
|
)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
cost: priced.cost,
|
|
89
|
-
currency: priced.currency,
|
|
90
|
-
cost_status: priced.cost_status,
|
|
91
|
-
price_key: priced.price_key,
|
|
92
|
-
price_source: priced.price_source&.to_s,
|
|
93
|
-
price_source_version: priced.price_source_version
|
|
94
|
-
)
|
|
95
|
-
end
|
|
64
|
+
token_priced = calculation.priced_line_items.select(&:token?).index_by { |item| dimension_key(item) }
|
|
65
|
+
service_priced = calculation.priced_line_items.reject(&:token?)
|
|
66
|
+
token_records, service_records = call.line_items.partition { |record| record.unit == "token" }
|
|
67
|
+
|
|
68
|
+
token_records.each { |record| apply_rate(record, token_priced[dimension_key(record)]) }
|
|
69
|
+
service_records.sort_by(&:position).zip(service_priced).each { |record, priced| apply_rate(record, priced) }
|
|
96
70
|
end
|
|
97
71
|
|
|
98
|
-
def
|
|
72
|
+
def apply_rate(record, priced)
|
|
73
|
+
return unless priced
|
|
74
|
+
|
|
75
|
+
record.update!(
|
|
76
|
+
rate_amount: priced.rate_amount,
|
|
77
|
+
rate_quantity: priced.rate_quantity,
|
|
78
|
+
cost: priced.cost,
|
|
79
|
+
currency: priced.currency,
|
|
80
|
+
cost_status: priced.cost_status,
|
|
81
|
+
price_key: priced.price_key,
|
|
82
|
+
price_source: priced.price_source,
|
|
83
|
+
price_source_version: priced.price_source_version
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def dimension_key(item)
|
|
88
|
+
[item.kind, item.direction, item.modality, item.cache_state]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def rollup_event_for(call, calculation)
|
|
99
92
|
RollupEvent.new(
|
|
100
93
|
provider: call.provider,
|
|
101
94
|
tracked_at: call.tracked_at,
|
|
102
|
-
pricing_snapshot:
|
|
103
|
-
total_cost:
|
|
95
|
+
pricing_snapshot: calculation.snapshot,
|
|
96
|
+
total_cost: calculation.cost.total
|
|
104
97
|
)
|
|
105
98
|
end
|
|
106
99
|
|
|
107
100
|
def token_usage_from(call)
|
|
108
|
-
TokenUsage.build(
|
|
109
|
-
input_tokens: call.input_tokens,
|
|
110
|
-
output_tokens: call.output_tokens,
|
|
111
|
-
cache_read_input_tokens: call.cache_read_input_tokens,
|
|
112
|
-
cache_write_input_tokens: call.cache_write_input_tokens,
|
|
113
|
-
cache_write_extended_input_tokens: call.cache_write_extended_input_tokens,
|
|
114
|
-
audio_input_tokens: call.audio_input_tokens,
|
|
115
|
-
audio_output_tokens: call.audio_output_tokens,
|
|
116
|
-
image_input_tokens: call.image_input_tokens,
|
|
117
|
-
image_output_tokens: call.image_output_tokens,
|
|
118
|
-
hidden_output_tokens: call.hidden_output_tokens,
|
|
119
|
-
total_tokens: call.total_tokens
|
|
120
|
-
)
|
|
101
|
+
Usage::TokenUsage.build(**call.attributes.transform_keys(&:to_sym).slice(*Usage::TokenUsage.members))
|
|
121
102
|
end
|
|
122
103
|
|
|
123
|
-
def
|
|
124
|
-
call.line_items.map do |record|
|
|
125
|
-
|
|
126
|
-
kind: record.kind, direction: record.direction, modality: record.modality,
|
|
127
|
-
cache_state: record.cache_state, quantity: record.quantity, unit: record.unit,
|
|
128
|
-
rate_amount: record.rate_amount, rate_quantity: record.rate_quantity,
|
|
129
|
-
cost: record.cost, currency: record.currency, cost_status: record.cost_status,
|
|
130
|
-
pricing_basis: record.pricing_basis, price_key: record.price_key,
|
|
131
|
-
price_source: record.price_source, price_source_version: record.price_source_version,
|
|
132
|
-
provider_field: record.provider_field, provider_item_id: record.provider_item_id,
|
|
133
|
-
details: record.details
|
|
134
|
-
)
|
|
104
|
+
def service_line_items_from(call)
|
|
105
|
+
call.line_items.reject { |record| record.unit == "token" }.sort_by(&:position).map do |record|
|
|
106
|
+
Charges::LineItem.build(record.attributes.transform_keys(&:to_sym).slice(*Charges::LineItem.members))
|
|
135
107
|
end
|
|
136
108
|
end
|
|
137
109
|
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal/util"
|
|
4
|
+
|
|
5
|
+
require_relative "../usage/catalog"
|
|
6
|
+
require_relative "../charges/line_item"
|
|
7
|
+
require_relative "rate"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
module Pricing
|
|
11
|
+
class Calculation
|
|
12
|
+
RATE_DENOMINATOR_TOKENS = Pricing::RATE_BASIS_QUANTITIES.fetch("per_million_tokens")
|
|
13
|
+
private_constant :RATE_DENOMINATOR_TOKENS
|
|
14
|
+
|
|
15
|
+
def self.for(provider:, model:, tokens:, pricing_mode:, line_items: [], usage_source: nil)
|
|
16
|
+
new(provider: provider,
|
|
17
|
+
model: model,
|
|
18
|
+
token_usage: Usage::TokenUsage.build_from_tokens(tokens),
|
|
19
|
+
line_items: line_items,
|
|
20
|
+
mode: Mode.normalize(pricing_mode),
|
|
21
|
+
usage_source: usage_source)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(provider:, model:, token_usage:, line_items:, mode:, usage_source: nil)
|
|
25
|
+
@provider = provider
|
|
26
|
+
@model = model
|
|
27
|
+
@token_usage = token_usage
|
|
28
|
+
@line_items = line_items
|
|
29
|
+
@mode = mode
|
|
30
|
+
@usage_source = usage_source
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :mode
|
|
34
|
+
|
|
35
|
+
def match
|
|
36
|
+
return @match if defined?(@match)
|
|
37
|
+
|
|
38
|
+
@match = Matcher.lookup(provider: @provider, model: @model)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def effective
|
|
42
|
+
return @effective if defined?(@effective)
|
|
43
|
+
|
|
44
|
+
@effective = match && EffectivePrices.call(
|
|
45
|
+
usage: @token_usage, quantities: quantities, prices: match.prices, pricing_mode: @mode
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def token_cost
|
|
50
|
+
return @token_cost if defined?(@token_cost)
|
|
51
|
+
|
|
52
|
+
@token_cost = priceable? ? build_token_cost : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def priced_line_items
|
|
56
|
+
@priced_line_items ||= unpriced_line_items.map do |line_item|
|
|
57
|
+
line_item.token? ? price_token(line_item) : price_service(line_item)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def snapshot
|
|
62
|
+
return @snapshot if defined?(@snapshot)
|
|
63
|
+
|
|
64
|
+
@snapshot = priceable? ? build_snapshot : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cost
|
|
68
|
+
return @cost if defined?(@cost)
|
|
69
|
+
|
|
70
|
+
@cost = combine_service_lines
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cost_status
|
|
74
|
+
@cost_status ||= Charges::CostStatus.call(
|
|
75
|
+
token_usage: @token_usage,
|
|
76
|
+
usage_source: @usage_source,
|
|
77
|
+
token_cost: token_cost,
|
|
78
|
+
token_pricing_partial: token_pricing_partial?,
|
|
79
|
+
service_line_items: priced_line_items.reject(&:token?),
|
|
80
|
+
total_cost: cost&.total
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def quantities
|
|
87
|
+
@quantities ||= @token_usage.priced_quantities
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def unpriced_line_items
|
|
91
|
+
Charges::LineItem.from_token_usage(@token_usage) + @line_items.reject(&:token?)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def priceable?
|
|
95
|
+
!match.nil? && !all_billable_unpriced?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def all_billable_unpriced?
|
|
99
|
+
any_billable = false
|
|
100
|
+
quantities.each_pair do |key, quantity|
|
|
101
|
+
next unless quantity.positive?
|
|
102
|
+
return false if effective[key]
|
|
103
|
+
|
|
104
|
+
any_billable = true
|
|
105
|
+
end
|
|
106
|
+
any_billable
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def priced_token_line_items
|
|
110
|
+
@priced_token_line_items ||= priced_line_items.select(&:token?)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_token_cost
|
|
114
|
+
by_dimension = priced_token_line_items.to_h { |line_item| [line_item.dimension, line_item] }
|
|
115
|
+
components = Usage::Catalog.token_priced.each_with_object({}) do |dimension, result|
|
|
116
|
+
cost = token_dimension_cost(dimension, by_dimension[dimension])
|
|
117
|
+
result[dimension.cost_key] = cost.round(8) unless cost.nil?
|
|
118
|
+
end
|
|
119
|
+
Charges::Cost.new(
|
|
120
|
+
components: components.freeze,
|
|
121
|
+
total: priced_token_line_items.sum(BigDecimal("0")) { |line_item| line_item.cost_value.round(8) },
|
|
122
|
+
currency: match.source.currency
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def token_dimension_cost(dimension, line_item)
|
|
127
|
+
return BigDecimal("0") if quantities[dimension.key].zero?
|
|
128
|
+
|
|
129
|
+
line_item&.cost
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_snapshot
|
|
133
|
+
{
|
|
134
|
+
"schema_version" => 1,
|
|
135
|
+
"source" => match.source.name,
|
|
136
|
+
"source_key" => match.key,
|
|
137
|
+
"source_version" => match.source.version,
|
|
138
|
+
"matched_by" => match.matched_by.to_s,
|
|
139
|
+
"currency" => match.source.currency,
|
|
140
|
+
"rates" => service_charge_rates.merge(token_charge_rates)
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def token_charge_rates
|
|
145
|
+
priced_token_line_items.each_with_object({}) do |line_item, rates|
|
|
146
|
+
next if line_item.price_key.nil? || line_item.rate_amount.nil?
|
|
147
|
+
|
|
148
|
+
rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def service_charge_rates
|
|
153
|
+
priced_line_items.each_with_object({}) do |line_item, rates|
|
|
154
|
+
next if line_item.token? || line_item.price_key.nil? || line_item.rate_amount.nil?
|
|
155
|
+
|
|
156
|
+
rates[line_item.price_key] ||= rate_entry(line_item.rate_amount, line_item.rate_quantity)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def rate_entry(amount, quantity)
|
|
161
|
+
{ "amount" => amount.to_d.to_s("F"), "quantity" => Integer(quantity) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def price_token(line_item)
|
|
165
|
+
dimension = dimension_for(line_item)
|
|
166
|
+
return line_item unless dimension
|
|
167
|
+
return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) unless priceable?
|
|
168
|
+
|
|
169
|
+
price = effective[dimension.key]
|
|
170
|
+
return line_item.with(cost_status: Charges::CostStatus::UNKNOWN) if price.nil?
|
|
171
|
+
|
|
172
|
+
line_item.with_rate(token_rate(dimension, price))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def token_rate(dimension, price)
|
|
176
|
+
Pricing::Rate.new(
|
|
177
|
+
amount: price.to_d,
|
|
178
|
+
quantity: RATE_DENOMINATOR_TOKENS.to_d,
|
|
179
|
+
currency: match.source.currency,
|
|
180
|
+
source: match.source.name,
|
|
181
|
+
source_key: dimension.key,
|
|
182
|
+
source_version: match.source.version
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def price_service(line_item)
|
|
187
|
+
return line_item if line_item.priced? || !line_item.billable?
|
|
188
|
+
|
|
189
|
+
rate = model_rate(line_item) ||
|
|
190
|
+
ServiceRates.charge_rate(provider: @provider, dimension: line_item.kind, pricing_mode: @mode)
|
|
191
|
+
return line_item unless rate
|
|
192
|
+
|
|
193
|
+
line_item.with_rate(rate)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def model_rate(line_item)
|
|
197
|
+
return nil unless priceable?
|
|
198
|
+
|
|
199
|
+
amount = match.prices[line_item.kind]
|
|
200
|
+
return nil unless amount.is_a?(Numeric)
|
|
201
|
+
|
|
202
|
+
dimension = Usage::Catalog[line_item.kind]
|
|
203
|
+
Pricing::Rate.new(
|
|
204
|
+
amount: amount.to_d,
|
|
205
|
+
quantity: Pricing::RATE_BASIS_QUANTITIES.fetch(dimension.rate_basis).to_d,
|
|
206
|
+
currency: match.source.currency,
|
|
207
|
+
source: match.source.name,
|
|
208
|
+
source_key: "#{match.key}.#{line_item.kind}",
|
|
209
|
+
source_version: match.source.version
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def dimension_for(line_item)
|
|
214
|
+
Usage::Catalog.all.find do |dimension|
|
|
215
|
+
dimension.kind == line_item.kind &&
|
|
216
|
+
dimension.direction == line_item.direction &&
|
|
217
|
+
dimension.modality == line_item.modality &&
|
|
218
|
+
dimension.cache_state == line_item.cache_state &&
|
|
219
|
+
dimension.unit == line_item.unit
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def combine_service_lines
|
|
224
|
+
cost = token_cost
|
|
225
|
+
priced_services = priced_line_items.reject(&:token?).select(&:priced?)
|
|
226
|
+
return cost if priced_services.empty?
|
|
227
|
+
|
|
228
|
+
base_currency = base_currency_for(cost, priced_services)
|
|
229
|
+
matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
|
|
230
|
+
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
231
|
+
|
|
232
|
+
service_total = matching.sum(BigDecimal("0")) { |line| line.cost_value.round(8) }
|
|
233
|
+
Charges::Cost.new(
|
|
234
|
+
components: cost ? cost.components : {}.freeze,
|
|
235
|
+
total: (cost&.total || BigDecimal("0")) + service_total,
|
|
236
|
+
currency: (cost&.currency || base_currency).to_s
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def base_currency_for(cost, priced_services)
|
|
241
|
+
cost&.currency || priced_services.first.currency || LlmCostTracker::DEFAULT_CURRENCY
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def warn_currency_mismatch(lines, base_currency)
|
|
245
|
+
currencies = lines.map { |line| line.currency.to_s }.uniq.sort
|
|
246
|
+
Logging.warn(
|
|
247
|
+
"Service line currency mismatch: header is #{base_currency}, dropping " \
|
|
248
|
+
"#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
|
|
249
|
+
"Per-line costs are still recorded; header total reflects #{base_currency} only."
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def token_pricing_partial?
|
|
254
|
+
return false unless token_cost
|
|
255
|
+
|
|
256
|
+
priced_token_line_items.any?(&:unpriced?)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "bigdecimal/util"
|
|
4
|
+
|
|
4
5
|
require_relative "mode"
|
|
6
|
+
require_relative "price_key"
|
|
5
7
|
|
|
6
8
|
module LlmCostTracker
|
|
7
9
|
module Pricing
|
|
@@ -9,7 +11,7 @@ module LlmCostTracker
|
|
|
9
11
|
class << self
|
|
10
12
|
def call(usage:, quantities:, prices:, pricing_mode:)
|
|
11
13
|
context_tier = context_tier?(usage: usage, prices: prices)
|
|
12
|
-
orderings = pricing_mode && Mode.
|
|
14
|
+
orderings = pricing_mode && Mode.permutations_for(pricing_mode)
|
|
13
15
|
|
|
14
16
|
quantities.to_h do |price_key, tokens|
|
|
15
17
|
price = if tokens.positive?
|
|
@@ -20,7 +22,7 @@ module LlmCostTracker
|
|
|
20
22
|
context_tier: context_tier
|
|
21
23
|
)
|
|
22
24
|
else
|
|
23
|
-
0
|
|
25
|
+
BigDecimal("0")
|
|
24
26
|
end
|
|
25
27
|
[price_key, price]
|
|
26
28
|
end
|
|
@@ -29,45 +31,42 @@ module LlmCostTracker
|
|
|
29
31
|
private
|
|
30
32
|
|
|
31
33
|
def price_for(prices:, key:, orderings:, context_tier:)
|
|
32
|
-
return
|
|
34
|
+
return prices[PriceKey.build(key, above_context: context_tier)] unless orderings
|
|
33
35
|
|
|
34
36
|
orderings.each do |mode|
|
|
35
|
-
direct =
|
|
37
|
+
direct = prices[PriceKey.build(key, mode: mode, above_context: context_tier)]
|
|
36
38
|
return direct if direct
|
|
37
39
|
end
|
|
38
|
-
return nil if %
|
|
40
|
+
return nil if %w[input output].include?(key)
|
|
39
41
|
|
|
40
42
|
derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
|
|
41
43
|
end
|
|
42
44
|
|
|
43
|
-
def contextual_price(prices:, key:, context_tier:)
|
|
44
|
-
return prices[key] unless context_tier
|
|
45
|
-
|
|
46
|
-
prices[:"above_context_#{key}"]
|
|
47
|
-
end
|
|
48
|
-
|
|
49
45
|
def derived_mode_price(prices:, key:, modes:, context_tier:)
|
|
50
|
-
standard_price =
|
|
51
|
-
base_price =
|
|
46
|
+
standard_price = prices[PriceKey.build(key, above_context: context_tier)]
|
|
47
|
+
base_price = prices[PriceKey.build("input", above_context: context_tier)]
|
|
52
48
|
return nil unless standard_price && base_price
|
|
53
49
|
return nil if base_price.zero?
|
|
54
50
|
|
|
55
51
|
modes.each do |mode|
|
|
56
|
-
mode_base_price =
|
|
57
|
-
|
|
52
|
+
mode_base_price = prices[PriceKey.build("input", mode: mode, above_context: context_tier)]
|
|
53
|
+
next unless mode_base_price
|
|
54
|
+
|
|
55
|
+
return standard_price.to_d * mode_base_price.to_d / base_price.to_d
|
|
58
56
|
end
|
|
59
57
|
nil
|
|
60
58
|
end
|
|
61
59
|
|
|
62
60
|
def context_tier?(usage:, prices:)
|
|
63
|
-
threshold = prices[
|
|
61
|
+
threshold = prices[Registry::CONTEXT_THRESHOLD_KEY]
|
|
64
62
|
return false unless threshold
|
|
65
63
|
|
|
66
64
|
input_tokens = usage.input_tokens +
|
|
67
65
|
usage.cache_read_input_tokens +
|
|
68
66
|
usage.cache_write_input_tokens +
|
|
69
67
|
usage.cache_write_extended_input_tokens +
|
|
70
|
-
usage.audio_input_tokens
|
|
68
|
+
usage.audio_input_tokens +
|
|
69
|
+
usage.image_input_tokens
|
|
71
70
|
input_tokens > threshold
|
|
72
71
|
end
|
|
73
72
|
end
|
|
@@ -15,9 +15,9 @@ module LlmCostTracker
|
|
|
15
15
|
cost_data = Pricing.cost_for(
|
|
16
16
|
provider: provider,
|
|
17
17
|
model: model,
|
|
18
|
-
tokens: {
|
|
18
|
+
tokens: { input_tokens: estimated_tokens }
|
|
19
19
|
)
|
|
20
|
-
cost_data
|
|
20
|
+
cost_data&.total
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def self.char_count(value)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Pricing
|
|
9
|
+
module Matcher
|
|
10
|
+
Match = Data.define(:source, :key, :prices, :matched_by)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def lookup(provider:, model:)
|
|
14
|
+
provider_name = provider.to_s.presence
|
|
15
|
+
model_name = model.to_s
|
|
16
|
+
return nil if model_name.empty?
|
|
17
|
+
|
|
18
|
+
lookup_match(provider_name: provider_name, model_name: model_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def lookup_match(provider_name:, model_name:)
|
|
24
|
+
provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
|
|
25
|
+
normalized = normalize_model_name(model_name)
|
|
26
|
+
|
|
27
|
+
Registry.sources.each do |source|
|
|
28
|
+
match = match_in_source(source, provider_model, model_name, normalized)
|
|
29
|
+
return match if match
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def match_in_source(source, provider_model, model_name, normalized)
|
|
35
|
+
table = source.prices
|
|
36
|
+
return nil if table.empty?
|
|
37
|
+
|
|
38
|
+
[[provider_model, :provider_model], [model_name, :model], [normalized, :normalized_model]].each do |key, by|
|
|
39
|
+
return build_match(source, key, by) if table.key?(key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
scan = native_keys(table)
|
|
43
|
+
if (key = unique_in(scan) { |native| normalize_model_name(native) == normalized })
|
|
44
|
+
return build_match(source, key, :unique_providerless_model)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
dated = scan.find do |native|
|
|
48
|
+
snapshot_variant?(provider_model, native) || snapshot_variant?(normalized, native)
|
|
49
|
+
end
|
|
50
|
+
return build_match(source, dated, :dated_snapshot) if dated
|
|
51
|
+
|
|
52
|
+
unique_dated = unique_in(scan) { |native| snapshot_variant?(normalized, normalize_model_name(native)) }
|
|
53
|
+
return build_match(source, unique_dated, :unique_providerless_dated_snapshot) if unique_dated
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def unique_in(keys, &)
|
|
59
|
+
matches = keys.select(&)
|
|
60
|
+
matches.first if matches.one?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_model_name(model)
|
|
64
|
+
model.to_s.split("/").last
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def native_keys(table)
|
|
68
|
+
Registry.sorted_price_keys(table).reject { |key| key.count("/") > 1 }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_match(source, key, matched_by)
|
|
72
|
+
Match.new(source: source, key: key, prices: source.prices[key], matched_by: matched_by)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def snapshot_variant?(model, key)
|
|
76
|
+
suffix = model.delete_prefix("#{key}-")
|
|
77
|
+
return false if suffix == model
|
|
78
|
+
|
|
79
|
+
suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8}|(?:preview|exp)-\d{2}-(?:\d{2}|\d{4}))\z/)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|