llm_cost_tracker 0.7.0 → 0.7.2
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 +31 -0
- data/README.md +21 -16
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/lib/llm_cost_tracker/budget.rb +8 -20
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +33 -36
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +63 -71
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
- data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
- data/lib/llm_cost_tracker/parsers/base.rb +12 -21
- data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
- data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +361 -36
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
- data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -8
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -8
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +39 -69
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -78
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +54 -58
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -67
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/writer.rb +0 -35
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -105
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
module Sync
|
|
6
|
+
module RegistryDiff
|
|
7
|
+
class << self
|
|
8
|
+
def call(current_models, updated_models)
|
|
9
|
+
current_models = normalize_models(current_models)
|
|
10
|
+
updated_models = normalize_models(updated_models)
|
|
11
|
+
|
|
12
|
+
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
13
|
+
fields = price_field_changes(current_models[model], updated_models[model])
|
|
14
|
+
changes[model] = fields if fields.any?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def price_field_changes(current_entry, updated_entry)
|
|
21
|
+
current_price = comparable_price(current_entry)
|
|
22
|
+
updated_price = comparable_price(updated_entry)
|
|
23
|
+
|
|
24
|
+
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
25
|
+
from = current_price[field]
|
|
26
|
+
to = updated_price[field]
|
|
27
|
+
next if from == to
|
|
28
|
+
|
|
29
|
+
changes[field] = { "from" => from, "to" => to }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def comparable_price(entry)
|
|
34
|
+
normalize_hash(entry).slice(*Registry::PRICE_KEYS)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def normalize_models(models)
|
|
38
|
+
normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_hash(hash)
|
|
42
|
+
return {} if hash.nil?
|
|
43
|
+
raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
46
|
+
normalized[key.to_s] = value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
require_relative "../registry"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Pricing
|
|
10
|
+
module Sync
|
|
11
|
+
class RegistryLoader
|
|
12
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
13
|
+
|
|
14
|
+
def call(path:, seed_path:)
|
|
15
|
+
source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
|
|
16
|
+
normalize_registry(load_registry_file(source_path))
|
|
17
|
+
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
18
|
+
raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def load_registry_file(path)
|
|
24
|
+
if File.size(path) > Registry::MAX_FILE_BYTES
|
|
25
|
+
raise ArgumentError, "pricing registry exceeds #{Registry::MAX_FILE_BYTES} bytes"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
contents = File.read(path)
|
|
29
|
+
registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
|
|
30
|
+
raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
registry
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_registry(registry)
|
|
36
|
+
{
|
|
37
|
+
"metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
|
|
38
|
+
"models" => normalize_models(registry.fetch("models", {}))
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalize_models(models)
|
|
43
|
+
normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
|
|
44
|
+
normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalize_hash(hash, label:)
|
|
49
|
+
return {} if hash.nil?
|
|
50
|
+
raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
53
|
+
normalized[key.to_s] = value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def yaml_file?(path)
|
|
58
|
+
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Pricing
|
|
9
|
+
module Sync
|
|
10
|
+
class RegistryWriter
|
|
11
|
+
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
12
|
+
|
|
13
|
+
def call(path:, registry:)
|
|
14
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
15
|
+
payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
|
|
16
|
+
temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
17
|
+
File.write(temp_path, payload)
|
|
18
|
+
File.rename(temp_path, path)
|
|
19
|
+
ensure
|
|
20
|
+
FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def yaml_file?(path)
|
|
26
|
+
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "date"
|
|
5
|
+
require "json"
|
|
6
|
+
require "rubygems"
|
|
7
|
+
|
|
8
|
+
require_relative "registry"
|
|
9
|
+
require_relative "sync/fetcher"
|
|
10
|
+
require_relative "sync/registry_diff"
|
|
11
|
+
require_relative "sync/registry_loader"
|
|
12
|
+
require_relative "sync/registry_writer"
|
|
13
|
+
|
|
14
|
+
module LlmCostTracker
|
|
15
|
+
module Pricing
|
|
16
|
+
module Sync
|
|
17
|
+
DEFAULT_OUTPUT_PATH = "config/llm_cost_tracker_prices.yml"
|
|
18
|
+
DEFAULT_REMOTE_URL =
|
|
19
|
+
"https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
|
|
20
|
+
SUPPORTED_SCHEMA_VERSION = 1
|
|
21
|
+
|
|
22
|
+
RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
|
|
23
|
+
CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
|
|
27
|
+
output = env["OUTPUT"].to_s.strip.presence
|
|
28
|
+
return output if output
|
|
29
|
+
|
|
30
|
+
prices_file = config.prices_file
|
|
31
|
+
return prices_file.to_s if prices_file
|
|
32
|
+
|
|
33
|
+
default_output_path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def configured_remote_url(env: ENV)
|
|
37
|
+
env["URL"].to_s.strip.presence || DEFAULT_REMOTE_URL
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
|
|
41
|
+
today: Date.today)
|
|
42
|
+
current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
|
|
43
|
+
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
44
|
+
|
|
45
|
+
if response.not_modified
|
|
46
|
+
return refresh_result(
|
|
47
|
+
path: path,
|
|
48
|
+
url: url,
|
|
49
|
+
response: response,
|
|
50
|
+
current: current,
|
|
51
|
+
remote: current,
|
|
52
|
+
written: false,
|
|
53
|
+
not_modified: true
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
58
|
+
RegistryWriter.new.call(path: path, registry: remote) unless preview
|
|
59
|
+
refresh_result(
|
|
60
|
+
path: path,
|
|
61
|
+
url: url,
|
|
62
|
+
response: response,
|
|
63
|
+
current: current,
|
|
64
|
+
remote: remote,
|
|
65
|
+
written: !preview,
|
|
66
|
+
not_modified: false
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
71
|
+
current = RegistryLoader.new.call(path: path, seed_path: Registry::DEFAULT_PRICES_PATH)
|
|
72
|
+
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
73
|
+
|
|
74
|
+
if response.not_modified
|
|
75
|
+
return CheckResult.new(
|
|
76
|
+
path: path,
|
|
77
|
+
source_url: url,
|
|
78
|
+
source_version: response.source_version,
|
|
79
|
+
changes: {},
|
|
80
|
+
up_to_date: true
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
85
|
+
changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
86
|
+
|
|
87
|
+
CheckResult.new(
|
|
88
|
+
path: path,
|
|
89
|
+
source_url: url,
|
|
90
|
+
source_version: response.source_version,
|
|
91
|
+
changes: changes,
|
|
92
|
+
up_to_date: changes.empty?
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def default_output_path
|
|
99
|
+
if Rails.root
|
|
100
|
+
Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
|
|
101
|
+
else
|
|
102
|
+
DEFAULT_OUTPUT_PATH
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_remote_registry(body, url:, response:, today:)
|
|
107
|
+
registry = parse_registry(body)
|
|
108
|
+
metadata = registry.fetch("metadata", {})
|
|
109
|
+
raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
schema_version = Integer(metadata.fetch("schema_version", 1))
|
|
112
|
+
if schema_version > SUPPORTED_SCHEMA_VERSION
|
|
113
|
+
raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
min_gem_version = metadata["min_gem_version"]
|
|
117
|
+
if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
|
|
118
|
+
raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
models = registry.fetch("models", {})
|
|
122
|
+
Registry.normalize_price_table(models)
|
|
123
|
+
|
|
124
|
+
registry.merge(
|
|
125
|
+
"metadata" => metadata.merge(
|
|
126
|
+
"schema_version" => schema_version,
|
|
127
|
+
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
128
|
+
"source_url" => url,
|
|
129
|
+
"source_version" => response.source_version
|
|
130
|
+
),
|
|
131
|
+
"models" => models
|
|
132
|
+
)
|
|
133
|
+
rescue ArgumentError, TypeError => e
|
|
134
|
+
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parse_registry(body)
|
|
138
|
+
registry = JSON.parse(body.to_s)
|
|
139
|
+
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
registry
|
|
142
|
+
rescue JSON::ParserError => e
|
|
143
|
+
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def refresh_result(path:, url:, response:, current:, remote:, written:, not_modified:)
|
|
147
|
+
RefreshResult.new(
|
|
148
|
+
path: path,
|
|
149
|
+
source_url: url,
|
|
150
|
+
source_version: response.source_version,
|
|
151
|
+
changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
|
|
152
|
+
written: written,
|
|
153
|
+
not_modified: not_modified
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../logging"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Pricing
|
|
7
|
+
class Unknown
|
|
8
|
+
MUTEX = Mutex.new
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def handle!(model)
|
|
12
|
+
model = model.to_s.presence || "unknown"
|
|
13
|
+
|
|
14
|
+
case LlmCostTracker.configuration.unknown_pricing_behavior
|
|
15
|
+
when :ignore
|
|
16
|
+
nil
|
|
17
|
+
when :warn
|
|
18
|
+
warn_missing(model)
|
|
19
|
+
when :raise
|
|
20
|
+
raise UnknownPricingError.new(model: model)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reset!
|
|
25
|
+
MUTEX.synchronize { @warned_models = Set.new }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def warn_missing(model)
|
|
31
|
+
should_warn = MUTEX.synchronize do
|
|
32
|
+
@warned_models ||= Set.new
|
|
33
|
+
@warned_models.add?(model)
|
|
34
|
+
end
|
|
35
|
+
return unless should_warn
|
|
36
|
+
|
|
37
|
+
Logging.warn(
|
|
38
|
+
"No pricing configured for model #{model.inspect}. " \
|
|
39
|
+
"Cost and budget guardrails will be skipped for this event. " \
|
|
40
|
+
"Add a pricing_overrides entry or set unknown_pricing_behavior."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -1,67 +1,68 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/hash/keys"
|
|
4
|
+
require "active_support/core_ext/object/blank"
|
|
5
|
+
|
|
6
|
+
require_relative "pricing/components"
|
|
7
|
+
require_relative "pricing/registry"
|
|
3
8
|
require_relative "pricing/lookup"
|
|
4
9
|
require_relative "pricing/effective_prices"
|
|
5
10
|
require_relative "pricing/explainer"
|
|
6
11
|
|
|
7
12
|
module LlmCostTracker
|
|
8
13
|
module Pricing
|
|
9
|
-
|
|
14
|
+
STANDARD_MODE_VALUES = %w[auto default standard standard_only].freeze
|
|
15
|
+
private_constant :STANDARD_MODE_VALUES
|
|
10
16
|
|
|
11
17
|
class << self
|
|
12
|
-
def
|
|
13
|
-
|
|
18
|
+
def normalize_mode(value)
|
|
19
|
+
mode = value.to_s.strip.presence
|
|
20
|
+
return nil unless mode
|
|
21
|
+
|
|
22
|
+
mode = mode.tr("-", "_")
|
|
23
|
+
STANDARD_MODE_VALUES.include?(mode) ? nil : mode
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cost_for(provider:, model:, token_usage:, pricing_mode: nil)
|
|
14
27
|
prices = lookup(provider: provider, model: model)
|
|
15
28
|
return nil unless prices
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
input_tokens: input_tokens,
|
|
19
|
-
output_tokens: output_tokens,
|
|
20
|
-
cache_read_input_tokens: cache_read_input_tokens,
|
|
21
|
-
cache_write_input_tokens: cache_write_input_tokens
|
|
22
|
-
)
|
|
23
|
-
costs = calculate_costs(usage, prices, pricing_mode: pricing_mode)
|
|
30
|
+
costs = calculate_costs(token_usage, prices, pricing_mode: pricing_mode)
|
|
24
31
|
return nil unless costs
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
total_cost: costs.values.sum.round(8),
|
|
32
|
-
currency: "USD"
|
|
33
|
-
)
|
|
33
|
+
values = COMPONENTS.to_h do |component|
|
|
34
|
+
[component.cost_key, costs.fetch(component.price_key).round(8)]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
values.merge(total_cost: costs.values.sum.round(8))
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def lookup(provider:, model:)
|
|
37
41
|
Lookup.call(provider: provider, model: model)&.prices
|
|
38
42
|
end
|
|
39
43
|
|
|
40
|
-
def explain(provider:, model:,
|
|
41
|
-
cache_write_input_tokens: 0, pricing_mode: nil)
|
|
44
|
+
def explain(provider:, model:, token_usage:, pricing_mode: nil)
|
|
42
45
|
Explainer.call(
|
|
43
46
|
provider: provider,
|
|
44
47
|
model: model,
|
|
45
|
-
|
|
46
|
-
output_tokens: output_tokens,
|
|
47
|
-
cache_read_input_tokens: cache_read_input_tokens,
|
|
48
|
-
cache_write_input_tokens: cache_write_input_tokens,
|
|
48
|
+
token_usage: token_usage,
|
|
49
49
|
pricing_mode: pricing_mode
|
|
50
50
|
)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def stored_cost_attributes(attributes)
|
|
54
|
+
attributes.to_h.symbolize_keys.slice(*COST_KEYS).compact
|
|
55
|
+
end
|
|
56
|
+
|
|
53
57
|
private
|
|
54
58
|
|
|
55
59
|
def calculate_costs(usage, prices, pricing_mode:)
|
|
56
60
|
effective = EffectivePrices.call(usage: usage, prices: prices, pricing_mode: pricing_mode)
|
|
57
|
-
return nil
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
cache_write_input: token_cost(usage.cache_write_input_tokens, effective.cache_write_input),
|
|
63
|
-
output: token_cost(usage.output_tokens, effective.output)
|
|
64
|
-
}
|
|
61
|
+
return nil if effective.value?(nil)
|
|
62
|
+
|
|
63
|
+
usage.price_quantities.to_h do |key, tokens|
|
|
64
|
+
[key, token_cost(tokens, effective.fetch(key))]
|
|
65
|
+
end
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
def token_cost(tokens, per_million_price)
|
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "llm_cost_tracker.app_models_autoload_paths", before: :set_autoload_paths do |app|
|
|
6
|
+
models_path = File.expand_path("../../app/models", __dir__)
|
|
7
|
+
app.config.autoload_paths << models_path unless app.config.autoload_paths.include?(models_path)
|
|
8
|
+
app.config.eager_load_paths << models_path unless app.config.eager_load_paths.include?(models_path)
|
|
9
|
+
end
|
|
10
|
+
|
|
5
11
|
generators do
|
|
6
12
|
require_relative "generators/llm_cost_tracker/add_ingestion_generator"
|
|
7
13
|
require_relative "generators/llm_cost_tracker/add_period_totals_generator"
|
|
8
14
|
require_relative "generators/llm_cost_tracker/add_latency_ms_generator"
|
|
9
15
|
require_relative "generators/llm_cost_tracker/add_provider_response_id_generator"
|
|
10
16
|
require_relative "generators/llm_cost_tracker/add_streaming_generator"
|
|
11
|
-
require_relative "generators/llm_cost_tracker/
|
|
17
|
+
require_relative "generators/llm_cost_tracker/add_token_usage_generator"
|
|
12
18
|
require_relative "generators/llm_cost_tracker/install_generator"
|
|
13
19
|
require_relative "generators/llm_cost_tracker/prices_generator"
|
|
14
20
|
require_relative "generators/llm_cost_tracker/upgrade_cost_precision_generator"
|
|
@@ -18,12 +24,5 @@ module LlmCostTracker
|
|
|
18
24
|
rake_tasks do
|
|
19
25
|
load File.expand_path("../tasks/llm_cost_tracker.rake", __dir__)
|
|
20
26
|
end
|
|
21
|
-
|
|
22
|
-
initializer "llm_cost_tracker.configure" do
|
|
23
|
-
ActiveSupport.on_load(:active_record) do
|
|
24
|
-
require_relative "llm_api_call"
|
|
25
|
-
require_relative "storage/active_record_store"
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
27
|
end
|
|
29
28
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/integer/time"
|
|
4
|
+
|
|
5
|
+
require_relative "../ledger"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
class Report
|
|
9
|
+
Data = ::Data.define(
|
|
10
|
+
:days,
|
|
11
|
+
:from_time,
|
|
12
|
+
:to_time,
|
|
13
|
+
:total_cost,
|
|
14
|
+
:requests_count,
|
|
15
|
+
:average_latency_ms,
|
|
16
|
+
:unknown_pricing_count,
|
|
17
|
+
:cost_by_provider,
|
|
18
|
+
:cost_by_model,
|
|
19
|
+
:cost_by_tags,
|
|
20
|
+
:top_calls
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
class Data
|
|
24
|
+
DEFAULT_DAYS = 30
|
|
25
|
+
TOP_LIMIT = 5
|
|
26
|
+
|
|
27
|
+
def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
|
|
28
|
+
days = days.to_i
|
|
29
|
+
days = DEFAULT_DAYS unless days.positive?
|
|
30
|
+
unless breakdown_limit.nil?
|
|
31
|
+
breakdown_limit = breakdown_limit.to_i
|
|
32
|
+
breakdown_limit = nil unless breakdown_limit.positive?
|
|
33
|
+
end
|
|
34
|
+
from = now - days.days
|
|
35
|
+
scope = Ledger::Call.where(tracked_at: from..now)
|
|
36
|
+
tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
days: days,
|
|
40
|
+
from_time: from,
|
|
41
|
+
to_time: now,
|
|
42
|
+
total_cost: scope.sum(:total_cost).to_f,
|
|
43
|
+
requests_count: scope.count,
|
|
44
|
+
average_latency_ms: average_latency_ms(scope),
|
|
45
|
+
unknown_pricing_count: scope.where(total_cost: nil).count,
|
|
46
|
+
cost_by_provider: scope.cost_by_provider(limit: breakdown_limit).to_a,
|
|
47
|
+
cost_by_model: scope.cost_by_model(limit: breakdown_limit).to_a,
|
|
48
|
+
cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
|
|
49
|
+
top_calls: top_calls(scope)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.average_latency_ms(scope)
|
|
54
|
+
scope.average(:latency_ms)&.to_f
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.cost_by_tags(scope, keys, limit:)
|
|
58
|
+
keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.top_calls(scope)
|
|
62
|
+
scope
|
|
63
|
+
.where.not(total_cost: nil)
|
|
64
|
+
.order(total_cost: :desc)
|
|
65
|
+
.limit(TOP_LIMIT)
|
|
66
|
+
.to_a
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_class_method :average_latency_ms, :cost_by_tags, :top_calls
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Report
|
|
5
|
+
class Formatter
|
|
6
|
+
TOP_LIMIT = 5
|
|
7
|
+
NAME_COLUMN_WIDTH = 28
|
|
8
|
+
TOP_CALL_COLUMN_WIDTH = 32
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@data = data
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_s
|
|
15
|
+
lines = ["LLM Cost Report (last #{@data.days} days)", ""]
|
|
16
|
+
append_summary(lines)
|
|
17
|
+
append_cost_section(lines, "By provider", @data.cost_by_provider)
|
|
18
|
+
append_cost_section(lines, "By model", @data.cost_by_model)
|
|
19
|
+
append_tag_sections(lines)
|
|
20
|
+
append_top_calls(lines)
|
|
21
|
+
lines.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def append_summary(lines)
|
|
27
|
+
lines << "Total cost: #{money(@data.total_cost)}"
|
|
28
|
+
lines << "Requests: #{@data.requests_count}"
|
|
29
|
+
lines << "Avg latency: #{average_latency}"
|
|
30
|
+
lines << "Unknown pricing: #{@data.unknown_pricing_count}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def append_cost_section(lines, title, rows)
|
|
34
|
+
lines << ""
|
|
35
|
+
lines << "#{title}:"
|
|
36
|
+
return lines << " none" if rows.empty?
|
|
37
|
+
|
|
38
|
+
rows.first(TOP_LIMIT).each do |row|
|
|
39
|
+
lines << " #{row.name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(row.total_cost)}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def append_tag_sections(lines)
|
|
44
|
+
@data.cost_by_tags.each do |tag_key, rows|
|
|
45
|
+
append_cost_section(lines, "By tag (#{tag_key})", rows)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def append_top_calls(lines)
|
|
50
|
+
lines << ""
|
|
51
|
+
lines << "Top expensive calls:"
|
|
52
|
+
return lines << " none" if @data.top_calls.empty?
|
|
53
|
+
|
|
54
|
+
@data.top_calls.first(TOP_LIMIT).each do |call|
|
|
55
|
+
label = "#{call.provider}/#{call.model}"
|
|
56
|
+
lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def average_latency
|
|
61
|
+
@data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def money(value)
|
|
65
|
+
"$#{format('%.6f', value.to_f)}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
4
|
-
require_relative "
|
|
3
|
+
require_relative "report/data"
|
|
4
|
+
require_relative "report/formatter"
|
|
5
5
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
class Report
|
|
8
8
|
class << self
|
|
9
|
-
def generate(days:
|
|
10
|
-
report_data =
|
|
9
|
+
def generate(days: Data::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
10
|
+
report_data = Data.build(
|
|
11
11
|
days: days,
|
|
12
12
|
now: now,
|
|
13
13
|
tag_breakdowns: tag_breakdowns,
|
|
14
|
-
breakdown_limit:
|
|
14
|
+
breakdown_limit: Formatter::TOP_LIMIT
|
|
15
15
|
)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Formatter.new(report_data).to_s
|
|
18
18
|
rescue LoadError => e
|
|
19
19
|
"Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
|
|
20
20
|
rescue StandardError => e
|
|
21
21
|
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def data(days:
|
|
25
|
-
|
|
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
26
|
end
|
|
27
27
|
end
|
|
28
28
|
end
|