llm_cost_tracker 0.7.2 → 0.8.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 +72 -1
- data/README.md +58 -221
- data/app/assets/llm_cost_tracker/application.css +218 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
- 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/token_usage_helper.rb +20 -7
- data/app/models/llm_cost_tracker/call.rb +169 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
- data/app/models/llm_cost_tracker/call_tag.rb +16 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
- 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/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
- 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/shared/_filters.html.erb +63 -0
- 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 +5 -37
- data/lib/llm_cost_tracker/billing/components.rb +53 -0
- data/lib/llm_cost_tracker/billing/components.yml +117 -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 +23 -35
- data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
- data/lib/llm_cost_tracker/configuration.rb +36 -19
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
- 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 +31 -0
- data/lib/llm_cost_tracker/doctor.rb +43 -45
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +10 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
- data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
- data/lib/llm_cost_tracker/ingestion.rb +28 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
- data/lib/llm_cost_tracker/integrations/base.rb +36 -29
- data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
- data/lib/llm_cost_tracker/integrations.rb +2 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +110 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
- data/lib/llm_cost_tracker/ledger.rb +4 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
- data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
- data/lib/llm_cost_tracker/parsers/base.rb +8 -3
- data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
- 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 +105 -20
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
- data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -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.rb +57 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +190 -26
- data/lib/llm_cost_tracker/railtie.rb +0 -8
- data/lib/llm_cost_tracker/report/data.rb +16 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +8 -8
- data/lib/llm_cost_tracker/tags/context.rb +2 -4
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +56 -42
- data/lib/llm_cost_tracker/tracker.rb +67 -24
- 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 +36 -35
- data/lib/tasks/llm_cost_tracker.rake +22 -17
- metadata +36 -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_provider_response_id_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_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,19 @@ 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
|
+
YAML.safe_load_file(path, aliases: false) || {}
|
|
154
|
+
rescue Errno::ENOENT, Psych::Exception, ArgumentError, TypeError => e
|
|
155
|
+
raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
|
|
156
|
+
end
|
|
157
|
+
|
|
137
158
|
def parse_registry(body)
|
|
138
159
|
registry = JSON.parse(body.to_s)
|
|
139
160
|
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
@@ -148,11 +169,37 @@ module LlmCostTracker
|
|
|
148
169
|
path: path,
|
|
149
170
|
source_url: url,
|
|
150
171
|
source_version: response.source_version,
|
|
151
|
-
changes:
|
|
172
|
+
changes: registry_changes(current, remote),
|
|
152
173
|
written: written,
|
|
153
174
|
not_modified: not_modified
|
|
154
175
|
)
|
|
155
176
|
end
|
|
177
|
+
|
|
178
|
+
def registry_changes(current, remote)
|
|
179
|
+
model_changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
180
|
+
charge_changes = service_charges_diff(
|
|
181
|
+
current.fetch("service_charges", {}),
|
|
182
|
+
remote.fetch("service_charges", {})
|
|
183
|
+
)
|
|
184
|
+
return model_changes if charge_changes.empty?
|
|
185
|
+
|
|
186
|
+
model_changes.merge("service_charges" => charge_changes)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def service_charges_diff(current, remote)
|
|
190
|
+
(current.keys | remote.keys).sort.each_with_object({}) do |provider, changes|
|
|
191
|
+
current_rates = (current[provider] || {}).transform_keys(&:to_s)
|
|
192
|
+
remote_rates = (remote[provider] || {}).transform_keys(&:to_s)
|
|
193
|
+
(current_rates.keys | remote_rates.keys).sort.each_with_object(changes) do |component, _|
|
|
194
|
+
from = current_rates[component]
|
|
195
|
+
to = remote_rates[component]
|
|
196
|
+
next if from == to
|
|
197
|
+
|
|
198
|
+
changes[provider] ||= {}
|
|
199
|
+
changes[provider][component] = { "from" => from, "to" => to }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
156
203
|
end
|
|
157
204
|
end
|
|
158
205
|
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,239 @@
|
|
|
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"
|
|
7
9
|
require_relative "pricing/registry"
|
|
8
10
|
require_relative "pricing/lookup"
|
|
9
11
|
require_relative "pricing/effective_prices"
|
|
10
12
|
require_relative "pricing/explainer"
|
|
13
|
+
require_relative "pricing/service_charges"
|
|
11
14
|
|
|
12
15
|
module LlmCostTracker
|
|
13
16
|
module Pricing
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
extend ServiceCharges
|
|
18
|
+
|
|
19
|
+
STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
|
|
20
|
+
RATE_DENOMINATOR_TOKENS = 1_000_000
|
|
21
|
+
private_constant :STANDARD_MODE_VALUES, :RATE_DENOMINATOR_TOKENS
|
|
16
22
|
|
|
17
23
|
class << self
|
|
18
24
|
def normalize_mode(value)
|
|
19
|
-
|
|
25
|
+
return nil if value.nil?
|
|
26
|
+
|
|
27
|
+
mode = value.is_a?(Symbol) ? value.downcase : normalize_string_mode(value)
|
|
20
28
|
return nil unless mode
|
|
21
29
|
|
|
22
|
-
mode = mode.tr("-", "_")
|
|
23
30
|
STANDARD_MODE_VALUES.include?(mode) ? nil : mode
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def cost_for(provider:, model:,
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
def cost_for(provider:, model:, tokens:, pricing_mode: nil)
|
|
34
|
+
calculation = calculation_for(
|
|
35
|
+
provider: provider,
|
|
36
|
+
model: model,
|
|
37
|
+
tokens: tokens,
|
|
38
|
+
pricing_mode: pricing_mode
|
|
39
|
+
)
|
|
40
|
+
return nil unless calculation
|
|
41
|
+
|
|
42
|
+
cost_from(calculation)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cost_and_snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
|
|
46
|
+
calculation = calculation_for(
|
|
47
|
+
provider: provider,
|
|
48
|
+
model: model,
|
|
49
|
+
tokens: tokens,
|
|
50
|
+
pricing_mode: pricing_mode
|
|
51
|
+
)
|
|
52
|
+
return [nil, nil] unless calculation
|
|
29
53
|
|
|
30
|
-
|
|
31
|
-
|
|
54
|
+
[cost_from(calculation), snapshot_from(calculation)]
|
|
55
|
+
end
|
|
32
56
|
|
|
33
|
-
|
|
34
|
-
|
|
57
|
+
def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
|
|
58
|
+
token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
|
|
59
|
+
calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
|
|
60
|
+
snapshot = calculation && snapshot_from(calculation)
|
|
61
|
+
|
|
62
|
+
priced = line_items.map do |line_item|
|
|
63
|
+
next price_token_line_item(line_item, calculation) if line_item.unit == :token
|
|
64
|
+
|
|
65
|
+
price_service_charge_line_item(line_item, provider: provider, pricing_mode: pricing_mode)
|
|
35
66
|
end
|
|
36
67
|
|
|
37
|
-
|
|
68
|
+
[priced, snapshot]
|
|
38
69
|
end
|
|
39
70
|
|
|
40
|
-
def
|
|
41
|
-
|
|
71
|
+
def snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
|
|
72
|
+
calculation = calculation_for(
|
|
73
|
+
provider: provider,
|
|
74
|
+
model: model,
|
|
75
|
+
tokens: tokens,
|
|
76
|
+
pricing_mode: pricing_mode
|
|
77
|
+
)
|
|
78
|
+
return nil unless calculation
|
|
79
|
+
|
|
80
|
+
snapshot_from(calculation)
|
|
42
81
|
end
|
|
43
82
|
|
|
44
|
-
def explain(provider:, model:,
|
|
83
|
+
def explain(provider:, model:, tokens:, pricing_mode: nil)
|
|
45
84
|
Explainer.call(
|
|
46
85
|
provider: provider,
|
|
47
86
|
model: model,
|
|
48
|
-
|
|
87
|
+
tokens: tokens,
|
|
49
88
|
pricing_mode: pricing_mode
|
|
50
89
|
)
|
|
51
90
|
end
|
|
52
91
|
|
|
53
92
|
def stored_cost_attributes(attributes)
|
|
54
|
-
attributes.to_h
|
|
93
|
+
value = attributes.to_h[:total_cost]
|
|
94
|
+
value.nil? ? {} : { total_cost: value }
|
|
55
95
|
end
|
|
56
96
|
|
|
57
97
|
private
|
|
58
98
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
return nil if
|
|
99
|
+
def normalize_string_mode(value)
|
|
100
|
+
normalized = value.strip
|
|
101
|
+
return nil if normalized.empty?
|
|
102
|
+
|
|
103
|
+
normalized.downcase.tr("-", "_").to_sym
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def cost_from(calculation)
|
|
107
|
+
costs = calculation[:costs]
|
|
108
|
+
values = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, result|
|
|
109
|
+
cost = costs[component.key]
|
|
110
|
+
result[component.cost_key] = cost.round(8) unless cost.nil?
|
|
111
|
+
end
|
|
112
|
+
values[:total_cost] = costs.values.compact.sum.round(8)
|
|
113
|
+
values
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def snapshot_from(calculation)
|
|
117
|
+
match = calculation[:match]
|
|
118
|
+
effective = calculation[:effective]
|
|
119
|
+
token_usage = calculation[:token_usage]
|
|
120
|
+
rates = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, values|
|
|
121
|
+
quantity = token_usage.public_send(component.token_key)
|
|
122
|
+
price = effective[component.key]
|
|
123
|
+
next if quantity.zero? || price.nil?
|
|
124
|
+
|
|
125
|
+
values[component.key] = {
|
|
126
|
+
amount: price,
|
|
127
|
+
quantity: RATE_DENOMINATOR_TOKENS
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
schema_version: 1,
|
|
133
|
+
source: match.source,
|
|
134
|
+
source_key: match.key,
|
|
135
|
+
source_version: source_version_for(match.source),
|
|
136
|
+
matched_by: match.matched_by,
|
|
137
|
+
currency: "USD",
|
|
138
|
+
rates: rates
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def calculation_for(provider:, model:, tokens:, pricing_mode:)
|
|
143
|
+
match = Lookup.call(provider: provider, model: model)
|
|
144
|
+
return nil unless match
|
|
145
|
+
|
|
146
|
+
token_usage = TokenUsage.build_from_tokens(tokens)
|
|
147
|
+
mode = normalize_mode(pricing_mode)
|
|
148
|
+
effective = EffectivePrices.call(usage: token_usage, prices: match.prices, pricing_mode: mode)
|
|
149
|
+
return nil unless any_billable_priced?(token_usage, effective)
|
|
150
|
+
|
|
151
|
+
{ match: match, effective: effective, token_usage: token_usage, costs: costs_for(token_usage, effective) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def any_billable_priced?(token_usage, effective)
|
|
155
|
+
billable = Billing::Components::TOKEN_PRICED.select { |c| token_usage.public_send(c.token_key).positive? }
|
|
156
|
+
billable.empty? || billable.any? { |c| effective[c.key] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def costs_for(usage, effective)
|
|
160
|
+
Billing::Components::TOKEN_PRICED.to_h do |component|
|
|
161
|
+
tokens = usage.public_send(component.token_key)
|
|
162
|
+
[component.key, token_cost(tokens, effective[component.key])]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def token_attributes_from(line_items)
|
|
167
|
+
line_items.each_with_object({}) do |line_item, totals|
|
|
168
|
+
next unless line_item.unit == :token
|
|
169
|
+
|
|
170
|
+
component = component_for_line_item(line_item)
|
|
171
|
+
next unless component
|
|
172
|
+
|
|
173
|
+
totals[component.key] = (totals[component.key] || 0) + line_item.quantity.to_i
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def price_token_line_item(line_item, calculation)
|
|
178
|
+
component = component_for_line_item(line_item)
|
|
179
|
+
return line_item unless component
|
|
180
|
+
return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) unless calculation
|
|
181
|
+
|
|
182
|
+
effective_price = calculation[:effective][component.key]
|
|
183
|
+
return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) if effective_price.nil?
|
|
184
|
+
|
|
185
|
+
cost = (line_item.quantity * BigDecimal(effective_price.to_s)) / RATE_DENOMINATOR_TOKENS
|
|
186
|
+
match = calculation[:match]
|
|
187
|
+
line_item.with(
|
|
188
|
+
rate_amount: BigDecimal(effective_price.to_s),
|
|
189
|
+
rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
|
|
190
|
+
cost: cost,
|
|
191
|
+
cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
|
|
192
|
+
price_key: component.key,
|
|
193
|
+
price_source: match.source,
|
|
194
|
+
price_source_version: source_version_for(match.source)
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def price_service_charge_line_item(line_item, provider:, pricing_mode:)
|
|
199
|
+
return line_item if line_item.priced?
|
|
200
|
+
return line_item unless line_item.billable?
|
|
201
|
+
|
|
202
|
+
rate = charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
|
|
203
|
+
return line_item unless rate
|
|
204
|
+
|
|
205
|
+
line_item.apply_rate(rate)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def component_for_line_item(line_item)
|
|
209
|
+
Billing::Components::REGISTRY.find do |component|
|
|
210
|
+
component.kind == line_item.kind &&
|
|
211
|
+
component.direction == line_item.direction &&
|
|
212
|
+
component.modality == line_item.modality &&
|
|
213
|
+
component.cache_state == line_item.cache_state &&
|
|
214
|
+
component.unit == line_item.unit
|
|
215
|
+
end
|
|
216
|
+
end
|
|
62
217
|
|
|
63
|
-
|
|
64
|
-
|
|
218
|
+
def source_version_for(source)
|
|
219
|
+
case source
|
|
220
|
+
when :bundled
|
|
221
|
+
LlmCostTracker::VERSION
|
|
222
|
+
when :prices_file
|
|
223
|
+
path = LlmCostTracker.configuration.prices_file
|
|
224
|
+
path ? File.mtime(path).utc.iso8601 : nil
|
|
225
|
+
when :pricing_overrides
|
|
226
|
+
"configuration"
|
|
65
227
|
end
|
|
228
|
+
rescue Errno::ENOENT
|
|
229
|
+
nil
|
|
66
230
|
end
|
|
67
231
|
|
|
68
232
|
def token_cost(tokens, per_million_price)
|
|
69
|
-
return 0.0 if tokens.
|
|
233
|
+
return 0.0 if tokens.zero?
|
|
70
234
|
return nil if per_million_price.nil?
|
|
71
235
|
|
|
72
|
-
(tokens
|
|
236
|
+
(tokens * per_million_price) / RATE_DENOMINATOR_TOKENS
|
|
73
237
|
end
|
|
74
238
|
end
|
|
75
239
|
end
|
|
@@ -9,16 +9,8 @@ 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/upgrade_cost_precision_generator"
|
|
21
|
-
require_relative "generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator"
|
|
22
14
|
end
|
|
23
15
|
|
|
24
16
|
rake_tasks do
|
|
@@ -32,17 +32,18 @@ module LlmCostTracker
|
|
|
32
32
|
breakdown_limit = nil unless breakdown_limit.positive?
|
|
33
33
|
end
|
|
34
34
|
from = now - days.days
|
|
35
|
-
scope =
|
|
35
|
+
scope = LlmCostTracker::Call.where(tracked_at: from..now)
|
|
36
36
|
tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
|
|
37
|
+
aggregate = totals(scope)
|
|
37
38
|
|
|
38
39
|
new(
|
|
39
40
|
days: days,
|
|
40
41
|
from_time: from,
|
|
41
42
|
to_time: now,
|
|
42
|
-
total_cost:
|
|
43
|
-
requests_count:
|
|
44
|
-
average_latency_ms: average_latency_ms
|
|
45
|
-
unknown_pricing_count:
|
|
43
|
+
total_cost: aggregate.total_cost.to_f,
|
|
44
|
+
requests_count: aggregate.requests_count.to_i,
|
|
45
|
+
average_latency_ms: aggregate.average_latency_ms&.to_f,
|
|
46
|
+
unknown_pricing_count: aggregate.unknown_pricing_count.to_i,
|
|
46
47
|
cost_by_provider: scope.cost_by_provider(limit: breakdown_limit).to_a,
|
|
47
48
|
cost_by_model: scope.cost_by_model(limit: breakdown_limit).to_a,
|
|
48
49
|
cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
|
|
@@ -50,8 +51,15 @@ module LlmCostTracker
|
|
|
50
51
|
)
|
|
51
52
|
end
|
|
52
53
|
|
|
53
|
-
def self.
|
|
54
|
-
scope
|
|
54
|
+
def self.totals(scope)
|
|
55
|
+
scope
|
|
56
|
+
.select(
|
|
57
|
+
"COALESCE(SUM(total_cost), 0) AS total_cost, " \
|
|
58
|
+
"COUNT(*) AS requests_count, " \
|
|
59
|
+
"AVG(latency_ms) AS average_latency_ms, " \
|
|
60
|
+
"COALESCE(SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
|
|
61
|
+
)
|
|
62
|
+
.take
|
|
55
63
|
end
|
|
56
64
|
|
|
57
65
|
def self.cost_by_tags(scope, keys, limit:)
|
|
@@ -66,7 +74,7 @@ module LlmCostTracker
|
|
|
66
74
|
.to_a
|
|
67
75
|
end
|
|
68
76
|
|
|
69
|
-
private_class_method :
|
|
77
|
+
private_class_method :cost_by_tags, :top_calls, :totals
|
|
70
78
|
end
|
|
71
79
|
end
|
|
72
80
|
end
|
|
@@ -20,10 +20,6 @@ module LlmCostTracker
|
|
|
20
20
|
rescue StandardError => e
|
|
21
21
|
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
22
22
|
end
|
|
23
|
-
|
|
24
|
-
def data(days: Data::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
25
|
-
Data.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
|
|
26
|
-
end
|
|
27
23
|
end
|
|
28
24
|
end
|
|
29
25
|
end
|
|
@@ -49,20 +49,20 @@ module LlmCostTracker
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def prune_batch(cutoff, batch_size)
|
|
52
|
-
LlmCostTracker::
|
|
53
|
-
rows =
|
|
54
|
-
.where(tracked_at: ...cutoff)
|
|
55
|
-
.order(:id)
|
|
56
|
-
.limit(batch_size)
|
|
57
|
-
.lock
|
|
58
|
-
.pluck(:id, :tracked_at, :total_cost)
|
|
52
|
+
LlmCostTracker::Call.transaction do
|
|
53
|
+
rows = pluck_prunable(cutoff, batch_size)
|
|
59
54
|
next 0 if rows.empty?
|
|
60
55
|
|
|
61
|
-
deleted = LlmCostTracker::
|
|
56
|
+
deleted = LlmCostTracker::Call.where(id: rows.map(&:first)).delete_all
|
|
62
57
|
LlmCostTracker::Ledger::Rollups.decrement!(rows) if deleted.positive?
|
|
63
58
|
deleted
|
|
64
59
|
end
|
|
65
60
|
end
|
|
61
|
+
|
|
62
|
+
def pluck_prunable(cutoff, batch_size)
|
|
63
|
+
LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
64
|
+
.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
|
|
65
|
+
end
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -19,11 +19,9 @@ module LlmCostTracker
|
|
|
19
19
|
|
|
20
20
|
def tags
|
|
21
21
|
default_tags = LlmCostTracker.configuration.default_tags
|
|
22
|
-
default_tags = default_tags.call if default_tags.respond_to?(:call)
|
|
22
|
+
default_tags = default_tags.call.deep_dup if default_tags.respond_to?(:call)
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
(ActiveSupport::IsolatedExecutionState[KEY] || []).reduce({}) { |merged, tags| merged.merge(tags) }
|
|
26
|
-
)
|
|
24
|
+
default_tags.to_h.merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
|
|
27
25
|
end
|
|
28
26
|
|
|
29
27
|
def clear!
|
|
@@ -4,10 +4,14 @@ module LlmCostTracker
|
|
|
4
4
|
module Tags
|
|
5
5
|
module Key
|
|
6
6
|
PATTERN = /\A[\w.-]+\z/
|
|
7
|
+
MAX_BYTESIZE = 64
|
|
7
8
|
|
|
8
9
|
class << self
|
|
9
10
|
def validate!(key, error_class: ArgumentError)
|
|
10
11
|
key = key.to_s
|
|
12
|
+
if key.bytesize > MAX_BYTESIZE
|
|
13
|
+
raise error_class, "tag key exceeds #{MAX_BYTESIZE} bytes: #{key[0, 16].inspect}..."
|
|
14
|
+
end
|
|
11
15
|
return key if key.match?(PATTERN)
|
|
12
16
|
|
|
13
17
|
raise error_class, "invalid tag key: #{key.inspect}"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
3
4
|
require "json"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
@@ -10,39 +11,33 @@ module LlmCostTracker
|
|
|
10
11
|
class << self
|
|
11
12
|
def call(tags, config: LlmCostTracker.configuration)
|
|
12
13
|
tags = (tags || {}).to_h
|
|
14
|
+
redacted = Array(config.redacted_tag_keys).map { |key| normalized_key(key) }
|
|
15
|
+
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
13
16
|
tags.first([config.max_tag_count.to_i, 0].max).each_with_object({}) do |(key, value), sanitized|
|
|
14
|
-
sanitized[key] = sanitized_value(key, value,
|
|
17
|
+
sanitized[key] = sanitized_value(key, value, redacted, limit)
|
|
15
18
|
end
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
private
|
|
19
22
|
|
|
20
|
-
def sanitized_value(key, value,
|
|
21
|
-
return REDACTED_VALUE if redacted_key?(key,
|
|
23
|
+
def sanitized_value(key, value, redacted, limit)
|
|
24
|
+
return REDACTED_VALUE if redacted_key?(key, redacted)
|
|
22
25
|
|
|
23
26
|
string = value_string(value)
|
|
24
|
-
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
25
27
|
return value if string.bytesize <= limit
|
|
26
28
|
|
|
27
|
-
string.byteslice(0, limit).
|
|
29
|
+
string.byteslice(0, limit).encode("UTF-8", invalid: :replace, undef: :replace)
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
def redacted_key?(key,
|
|
32
|
+
def redacted_key?(key, redacted)
|
|
33
|
+
return false if redacted.empty?
|
|
34
|
+
|
|
31
35
|
normalized = normalized_key(key)
|
|
32
|
-
|
|
33
|
-
redacted_key_component?(normalized, candidate)
|
|
34
|
-
end
|
|
36
|
+
redacted.any? { |candidate| redacted_key_component?(normalized, candidate) }
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def normalized_key(key)
|
|
38
|
-
key.to_s
|
|
39
|
-
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
40
|
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
41
|
-
.downcase
|
|
42
|
-
.gsub(/[^a-z0-9]+/, "_")
|
|
43
|
-
.gsub(/_+/, "_")
|
|
44
|
-
.delete_prefix("_")
|
|
45
|
-
.delete_suffix("_")
|
|
40
|
+
key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
46
41
|
end
|
|
47
42
|
|
|
48
43
|
def redacted_key_component?(key, candidate)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Timing
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def now_monotonic
|
|
8
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def elapsed_ms(started_at)
|
|
12
|
+
((now_monotonic - started_at) * 1000).round
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|