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
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "monitor"
|
|
5
|
-
require "yaml"
|
|
6
|
-
|
|
7
|
-
require_relative "logging"
|
|
8
|
-
|
|
9
|
-
module LlmCostTracker
|
|
10
|
-
module PriceRegistry
|
|
11
|
-
DEFAULT_PRICES_PATH = File.expand_path("prices.json", __dir__)
|
|
12
|
-
EMPTY_PRICES = {}.freeze
|
|
13
|
-
PRICE_KEYS = %w[input output cache_read_input cache_write_input].freeze
|
|
14
|
-
METADATA_KEYS = %w[_source _source_version _fetched_at _updated _notes _validator_override].freeze
|
|
15
|
-
MAX_FILE_BYTES = 2_097_152
|
|
16
|
-
MUTEX = Monitor.new
|
|
17
|
-
|
|
18
|
-
class << self
|
|
19
|
-
def builtin_prices
|
|
20
|
-
@builtin_prices ||= MUTEX.synchronize do
|
|
21
|
-
@builtin_prices || normalize_price_table(raw_registry.fetch("models", {})).freeze
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def metadata
|
|
26
|
-
@metadata ||= MUTEX.synchronize { @metadata || raw_registry.fetch("metadata", {}).freeze }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def file_metadata(path)
|
|
30
|
-
return {} unless path
|
|
31
|
-
|
|
32
|
-
registry = load_price_file(path.to_s)
|
|
33
|
-
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
34
|
-
|
|
35
|
-
metadata = registry.fetch("metadata", {})
|
|
36
|
-
raise ArgumentError, "prices_file metadata must be a hash" unless metadata.is_a?(Hash)
|
|
37
|
-
|
|
38
|
-
metadata
|
|
39
|
-
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
40
|
-
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def normalize_price_table(table)
|
|
44
|
-
normalize_price_entries(table, context: "price table")
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def file_prices(path)
|
|
48
|
-
return EMPTY_PRICES unless path
|
|
49
|
-
|
|
50
|
-
path = path.to_s
|
|
51
|
-
cache_key = [path, File.mtime(path).to_f]
|
|
52
|
-
cached = @file_prices_cache
|
|
53
|
-
return cached[:value] if cached && cached[:key] == cache_key
|
|
54
|
-
|
|
55
|
-
MUTEX.synchronize do
|
|
56
|
-
cached = @file_prices_cache
|
|
57
|
-
return cached[:value] if cached && cached[:key] == cache_key
|
|
58
|
-
|
|
59
|
-
value = normalize_file_prices(price_file_models(load_price_file(path)), path: path).freeze
|
|
60
|
-
@file_prices_cache = { key: cache_key, value: value }.freeze
|
|
61
|
-
value
|
|
62
|
-
end
|
|
63
|
-
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
64
|
-
raise Error, "Unable to load prices_file #{path.inspect}: #{e.message}"
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
|
-
def raw_registry
|
|
70
|
-
@raw_registry ||= MUTEX.synchronize do
|
|
71
|
-
@raw_registry || JSON.parse(File.read(DEFAULT_PRICES_PATH)).freeze
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def normalize_price_entry(price)
|
|
76
|
-
price.each_with_object({}) do |(key, value), normalized|
|
|
77
|
-
key = key.to_s
|
|
78
|
-
normalized[key.to_sym] = Float(value) if price_key?(key)
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def normalize_file_prices(table, path:)
|
|
83
|
-
normalize_price_entries(table, context: path)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def normalize_price_entries(table, context:)
|
|
87
|
-
table = {} if table.nil?
|
|
88
|
-
raise ArgumentError, "#{context} must be a hash of models" unless table.is_a?(Hash)
|
|
89
|
-
|
|
90
|
-
table.each_with_object({}) do |(model, price), normalized|
|
|
91
|
-
price = validate_price_entry(price, model: model, context: context)
|
|
92
|
-
warn_unknown_keys(model, price, context)
|
|
93
|
-
normalized[model.to_s] = normalize_price_entry(price)
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def warn_unknown_keys(model, price, path)
|
|
98
|
-
unknown_keys = price.keys.map(&:to_s).reject do |key|
|
|
99
|
-
price_key?(key) || METADATA_KEYS.include?(key)
|
|
100
|
-
end
|
|
101
|
-
return if unknown_keys.empty?
|
|
102
|
-
|
|
103
|
-
Logging.warn(
|
|
104
|
-
"Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
|
|
105
|
-
"ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
|
|
106
|
-
)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def price_key?(key)
|
|
110
|
-
return true if PRICE_KEYS.include?(key)
|
|
111
|
-
|
|
112
|
-
PRICE_KEYS.any? do |base_key|
|
|
113
|
-
key.end_with?("_#{base_key}") && key.delete_suffix("_#{base_key}") != ""
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def load_price_file(path)
|
|
118
|
-
raise ArgumentError, "prices_file exceeds #{MAX_FILE_BYTES} bytes" if File.size(path) > MAX_FILE_BYTES
|
|
119
|
-
|
|
120
|
-
contents = File.read(path)
|
|
121
|
-
return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
|
|
122
|
-
|
|
123
|
-
JSON.parse(contents)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def yaml_file?(path)
|
|
127
|
-
%w[.yaml .yml].include?(File.extname(path).downcase)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def price_file_models(registry)
|
|
131
|
-
raise ArgumentError, "prices_file must be a hash" unless registry.is_a?(Hash)
|
|
132
|
-
|
|
133
|
-
registry.fetch("models", registry)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def validate_price_entry(price, model:, context:)
|
|
137
|
-
return {} if price.nil?
|
|
138
|
-
return price if price.is_a?(Hash)
|
|
139
|
-
|
|
140
|
-
raise ArgumentError, "price entry for #{model.inspect} in #{context} must be a hash"
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
require "net/http"
|
|
5
|
-
require "openssl"
|
|
6
|
-
require "time"
|
|
7
|
-
require "uri"
|
|
8
|
-
|
|
9
|
-
module LlmCostTracker
|
|
10
|
-
module PriceSync
|
|
11
|
-
class Fetcher
|
|
12
|
-
Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
|
|
13
|
-
def source_version
|
|
14
|
-
etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
USER_AGENT = "llm_cost_tracker price refresh"
|
|
19
|
-
MAX_REDIRECTS = 5
|
|
20
|
-
MAX_BODY_BYTES = 2_097_152
|
|
21
|
-
OPEN_TIMEOUT = 5
|
|
22
|
-
READ_TIMEOUT = 10
|
|
23
|
-
WRITE_TIMEOUT = 10
|
|
24
|
-
|
|
25
|
-
def get(url, etag: nil, redirects: 0)
|
|
26
|
-
raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
|
|
27
|
-
|
|
28
|
-
uri = URI.parse(url)
|
|
29
|
-
raise Error, "Pricing snapshot URL must use https" unless uri.scheme == "https"
|
|
30
|
-
|
|
31
|
-
request = Net::HTTP::Get.new(uri)
|
|
32
|
-
request["User-Agent"] = USER_AGENT
|
|
33
|
-
request["If-None-Match"] = etag if etag
|
|
34
|
-
|
|
35
|
-
response, body = fetch_response(uri, request)
|
|
36
|
-
|
|
37
|
-
case response
|
|
38
|
-
when Net::HTTPSuccess
|
|
39
|
-
build_response(response, body: body || limited_body(response), not_modified: false)
|
|
40
|
-
when Net::HTTPNotModified
|
|
41
|
-
build_response(response, body: nil, not_modified: true)
|
|
42
|
-
when Net::HTTPRedirection
|
|
43
|
-
location = response["location"]
|
|
44
|
-
raise Error, "Redirect without location while fetching #{url}" if location.nil? || location.empty?
|
|
45
|
-
|
|
46
|
-
get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
|
|
47
|
-
else
|
|
48
|
-
raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
|
|
49
|
-
end
|
|
50
|
-
rescue OpenSSL::SSL::SSLError, SocketError, SystemCallError, Timeout::Error => e
|
|
51
|
-
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def fetch_response(uri, request)
|
|
57
|
-
body = nil
|
|
58
|
-
response = Net::HTTP.start(
|
|
59
|
-
uri.host,
|
|
60
|
-
uri.port,
|
|
61
|
-
use_ssl: uri.scheme == "https",
|
|
62
|
-
open_timeout: OPEN_TIMEOUT,
|
|
63
|
-
read_timeout: READ_TIMEOUT,
|
|
64
|
-
write_timeout: WRITE_TIMEOUT
|
|
65
|
-
) do |http|
|
|
66
|
-
http.request(request) do |streamed_response|
|
|
67
|
-
body = limited_body(streamed_response) if streamed_response.is_a?(Net::HTTPSuccess)
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
[response, body]
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def limited_body(response)
|
|
75
|
-
body = +""
|
|
76
|
-
if response.respond_to?(:read_body)
|
|
77
|
-
response.read_body do |chunk|
|
|
78
|
-
chunk = chunk.to_s
|
|
79
|
-
if body.bytesize + chunk.bytesize > MAX_BODY_BYTES
|
|
80
|
-
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes"
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
body << chunk
|
|
84
|
-
end
|
|
85
|
-
else
|
|
86
|
-
body = response.body.to_s
|
|
87
|
-
end
|
|
88
|
-
raise Error, "Pricing snapshot response exceeds #{MAX_BODY_BYTES} bytes" if body.bytesize > MAX_BODY_BYTES
|
|
89
|
-
|
|
90
|
-
body
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def build_response(response, not_modified:, body: response.body)
|
|
94
|
-
Response.new(
|
|
95
|
-
body: body,
|
|
96
|
-
etag: response["etag"],
|
|
97
|
-
last_modified: response["last-modified"],
|
|
98
|
-
not_modified: not_modified,
|
|
99
|
-
fetched_at: Time.now.utc.iso8601
|
|
100
|
-
)
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LlmCostTracker
|
|
4
|
-
module PriceSync
|
|
5
|
-
module RegistryDiff
|
|
6
|
-
class << self
|
|
7
|
-
def call(current_models, updated_models)
|
|
8
|
-
current_models = normalize_models(current_models)
|
|
9
|
-
updated_models = normalize_models(updated_models)
|
|
10
|
-
|
|
11
|
-
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
12
|
-
fields = price_field_changes(current_models[model], updated_models[model])
|
|
13
|
-
changes[model] = fields if fields.any?
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
private
|
|
18
|
-
|
|
19
|
-
def price_field_changes(current_entry, updated_entry)
|
|
20
|
-
current_price = comparable_price(current_entry)
|
|
21
|
-
updated_price = comparable_price(updated_entry)
|
|
22
|
-
|
|
23
|
-
(current_price.keys | updated_price.keys).sort.each_with_object({}) do |field, changes|
|
|
24
|
-
from = current_price[field]
|
|
25
|
-
to = updated_price[field]
|
|
26
|
-
next if from == to
|
|
27
|
-
|
|
28
|
-
changes[field] = { "from" => from, "to" => to }
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def comparable_price(entry)
|
|
33
|
-
normalize_hash(entry).slice(*PriceRegistry::PRICE_KEYS)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def normalize_models(models)
|
|
37
|
-
normalize_hash(models).transform_values { |entry| normalize_hash(entry) }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def normalize_hash(hash)
|
|
41
|
-
return {} if hash.nil?
|
|
42
|
-
raise Error, "pricing entries must be hashes" unless hash.is_a?(Hash)
|
|
43
|
-
|
|
44
|
-
hash.each_with_object({}) do |(key, value), normalized|
|
|
45
|
-
normalized[key.to_s] = value
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "yaml"
|
|
5
|
-
|
|
6
|
-
require_relative "../price_registry"
|
|
7
|
-
|
|
8
|
-
module LlmCostTracker
|
|
9
|
-
module PriceSync
|
|
10
|
-
class RegistryLoader
|
|
11
|
-
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
12
|
-
|
|
13
|
-
def call(path:, seed_path:)
|
|
14
|
-
source_path = File.exist?(path.to_s) ? path.to_s : seed_path.to_s
|
|
15
|
-
normalize_registry(load_registry_file(source_path))
|
|
16
|
-
rescue Errno::ENOENT, JSON::ParserError, Psych::Exception, ArgumentError, TypeError => e
|
|
17
|
-
raise Error, "Unable to load pricing registry #{source_path.inspect}: #{e.message}"
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def load_registry_file(path)
|
|
23
|
-
if File.size(path) > PriceRegistry::MAX_FILE_BYTES
|
|
24
|
-
raise ArgumentError, "pricing registry exceeds #{PriceRegistry::MAX_FILE_BYTES} bytes"
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
contents = File.read(path)
|
|
28
|
-
registry = yaml_file?(path) ? (YAML.safe_load(contents, aliases: false) || {}) : JSON.parse(contents)
|
|
29
|
-
raise ArgumentError, "pricing registry must be a hash" unless registry.is_a?(Hash)
|
|
30
|
-
|
|
31
|
-
registry
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def normalize_registry(registry)
|
|
35
|
-
{
|
|
36
|
-
"metadata" => normalize_hash(registry.fetch("metadata", {}), label: "pricing metadata"),
|
|
37
|
-
"models" => normalize_models(registry.fetch("models", {}))
|
|
38
|
-
}
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def normalize_models(models)
|
|
42
|
-
normalize_hash(models, label: "pricing models").each_with_object({}) do |(model, entry), normalized|
|
|
43
|
-
normalized[model.to_s] = normalize_hash(entry, label: "pricing model entry")
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def normalize_hash(hash, label:)
|
|
48
|
-
return {} if hash.nil?
|
|
49
|
-
raise ArgumentError, "#{label} must be a hash" unless hash.is_a?(Hash)
|
|
50
|
-
|
|
51
|
-
hash.each_with_object({}) do |(key, value), normalized|
|
|
52
|
-
normalized[key.to_s] = value
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def yaml_file?(path)
|
|
57
|
-
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "json"
|
|
5
|
-
require "yaml"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module PriceSync
|
|
9
|
-
class RegistryWriter
|
|
10
|
-
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
11
|
-
|
|
12
|
-
def call(path:, registry:)
|
|
13
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
14
|
-
payload = yaml_file?(path) ? YAML.dump(registry) : "#{JSON.pretty_generate(registry)}\n"
|
|
15
|
-
temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
16
|
-
File.write(temp_path, payload)
|
|
17
|
-
File.rename(temp_path, path)
|
|
18
|
-
ensure
|
|
19
|
-
FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def yaml_file?(path)
|
|
25
|
-
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "date"
|
|
4
|
-
require "json"
|
|
5
|
-
require "rubygems"
|
|
6
|
-
|
|
7
|
-
require_relative "price_sync/fetcher"
|
|
8
|
-
require_relative "price_sync/registry_diff"
|
|
9
|
-
require_relative "price_sync/registry_loader"
|
|
10
|
-
require_relative "price_sync/registry_writer"
|
|
11
|
-
|
|
12
|
-
module LlmCostTracker
|
|
13
|
-
module PriceSync
|
|
14
|
-
DEFAULT_OUTPUT_PATH = "config/llm_cost_tracker_prices.yml"
|
|
15
|
-
DEFAULT_REMOTE_URL =
|
|
16
|
-
"https://raw.githubusercontent.com/sergey-homenko/llm_cost_tracker/main/lib/llm_cost_tracker/prices.json"
|
|
17
|
-
SUPPORTED_SCHEMA_VERSION = 1
|
|
18
|
-
|
|
19
|
-
RefreshResult = Data.define(:path, :source_url, :source_version, :changes, :written, :not_modified)
|
|
20
|
-
CheckResult = Data.define(:path, :source_url, :source_version, :changes, :up_to_date)
|
|
21
|
-
|
|
22
|
-
class << self
|
|
23
|
-
def configured_output_path(env: ENV, config: LlmCostTracker.configuration)
|
|
24
|
-
output = env["OUTPUT"].to_s.strip
|
|
25
|
-
return output unless output.empty?
|
|
26
|
-
|
|
27
|
-
prices_file = config.prices_file
|
|
28
|
-
return prices_file.to_s if prices_file
|
|
29
|
-
|
|
30
|
-
default_output_path
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def configured_remote_url(env: ENV)
|
|
34
|
-
url = env["URL"].to_s.strip
|
|
35
|
-
url.empty? ? DEFAULT_REMOTE_URL : url
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def refresh(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, preview: false, fetcher: Fetcher.new,
|
|
39
|
-
today: Date.today)
|
|
40
|
-
current = load_current_registry(path)
|
|
41
|
-
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
42
|
-
|
|
43
|
-
if response.not_modified
|
|
44
|
-
return refresh_result(path, url, response, current, current, written: false, not_modified: true)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
48
|
-
RegistryWriter.new.call(path: path, registry: remote) unless preview
|
|
49
|
-
refresh_result(path, url, response, current, remote, written: !preview, not_modified: false)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
53
|
-
current = load_current_registry(path)
|
|
54
|
-
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
55
|
-
|
|
56
|
-
if response.not_modified
|
|
57
|
-
return CheckResult.new(
|
|
58
|
-
path: path,
|
|
59
|
-
source_url: url,
|
|
60
|
-
source_version: response.source_version,
|
|
61
|
-
changes: {},
|
|
62
|
-
up_to_date: true
|
|
63
|
-
)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
67
|
-
changes = RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {}))
|
|
68
|
-
|
|
69
|
-
CheckResult.new(
|
|
70
|
-
path: path,
|
|
71
|
-
source_url: url,
|
|
72
|
-
source_version: response.source_version,
|
|
73
|
-
changes: changes,
|
|
74
|
-
up_to_date: changes.empty?
|
|
75
|
-
)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
private
|
|
79
|
-
|
|
80
|
-
def default_output_path
|
|
81
|
-
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
82
|
-
Rails.root.join(DEFAULT_OUTPUT_PATH).to_s
|
|
83
|
-
else
|
|
84
|
-
DEFAULT_OUTPUT_PATH
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def load_current_registry(path)
|
|
89
|
-
RegistryLoader.new.call(path: path, seed_path: PriceRegistry::DEFAULT_PRICES_PATH)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def normalize_remote_registry(body, url:, response:, today:)
|
|
93
|
-
registry = parse_registry(body)
|
|
94
|
-
metadata = registry.fetch("metadata", {})
|
|
95
|
-
raise Error, "remote pricing metadata must be a hash" unless metadata.is_a?(Hash)
|
|
96
|
-
|
|
97
|
-
schema_version = Integer(metadata.fetch("schema_version", 1))
|
|
98
|
-
if schema_version > SUPPORTED_SCHEMA_VERSION
|
|
99
|
-
raise Error, "remote pricing schema_version=#{schema_version} requires a newer llm_cost_tracker"
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
min_gem_version = metadata["min_gem_version"]
|
|
103
|
-
if min_gem_version && Gem::Version.new(min_gem_version) > Gem::Version.new(LlmCostTracker::VERSION)
|
|
104
|
-
raise Error, "remote pricing snapshot requires llm_cost_tracker >= #{min_gem_version}"
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
models = registry.fetch("models", {})
|
|
108
|
-
PriceRegistry.normalize_price_table(models)
|
|
109
|
-
|
|
110
|
-
registry.merge(
|
|
111
|
-
"metadata" => metadata.merge(
|
|
112
|
-
"schema_version" => schema_version,
|
|
113
|
-
"updated_at" => metadata["updated_at"] || today.iso8601,
|
|
114
|
-
"source_url" => url,
|
|
115
|
-
"source_version" => response.source_version
|
|
116
|
-
),
|
|
117
|
-
"models" => models
|
|
118
|
-
)
|
|
119
|
-
rescue ArgumentError, TypeError => e
|
|
120
|
-
raise Error, "Unable to load remote pricing snapshot: #{e.message}"
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def parse_registry(body)
|
|
124
|
-
registry = JSON.parse(body.to_s)
|
|
125
|
-
raise Error, "remote pricing snapshot must be a JSON object" unless registry.is_a?(Hash)
|
|
126
|
-
|
|
127
|
-
registry
|
|
128
|
-
rescue JSON::ParserError => e
|
|
129
|
-
raise Error, "Unable to parse remote pricing snapshot: #{e.message}"
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def refresh_result(path, url, response, current, remote, written:, not_modified:)
|
|
133
|
-
RefreshResult.new(
|
|
134
|
-
path: path,
|
|
135
|
-
source_url: url,
|
|
136
|
-
source_version: response.source_version,
|
|
137
|
-
changes: RegistryDiff.call(current.fetch("models", {}), remote.fetch("models", {})),
|
|
138
|
-
written: written,
|
|
139
|
-
not_modified: not_modified
|
|
140
|
-
)
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "active_support/core_ext/integer/time"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
TopCall = Data.define(:provider, :model, :total_cost)
|
|
7
|
-
|
|
8
|
-
ReportData = Data.define(
|
|
9
|
-
:days,
|
|
10
|
-
:from_time,
|
|
11
|
-
:to_time,
|
|
12
|
-
:total_cost,
|
|
13
|
-
:requests_count,
|
|
14
|
-
:average_latency_ms,
|
|
15
|
-
:unknown_pricing_count,
|
|
16
|
-
:cost_by_provider,
|
|
17
|
-
:cost_by_model,
|
|
18
|
-
:cost_by_tags,
|
|
19
|
-
:top_calls
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
class ReportData
|
|
23
|
-
DEFAULT_DAYS = 30
|
|
24
|
-
TOP_LIMIT = 5
|
|
25
|
-
|
|
26
|
-
def self.build(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil, breakdown_limit: nil)
|
|
27
|
-
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
28
|
-
|
|
29
|
-
days = normalized_days(days)
|
|
30
|
-
breakdown_limit = normalized_limit(breakdown_limit)
|
|
31
|
-
from = now - days.days
|
|
32
|
-
scope = LlmApiCall.where(tracked_at: from..now)
|
|
33
|
-
tag_breakdowns ||= LlmCostTracker.configuration.report_tag_breakdowns || []
|
|
34
|
-
|
|
35
|
-
new(
|
|
36
|
-
days: days,
|
|
37
|
-
from_time: from,
|
|
38
|
-
to_time: now,
|
|
39
|
-
total_cost: scope.sum(:total_cost).to_f,
|
|
40
|
-
requests_count: scope.count,
|
|
41
|
-
average_latency_ms: average_latency_ms(scope),
|
|
42
|
-
unknown_pricing_count: scope.where(total_cost: nil).count,
|
|
43
|
-
cost_by_provider: cost_by(scope, :provider, limit: breakdown_limit),
|
|
44
|
-
cost_by_model: cost_by(scope, :model, limit: breakdown_limit),
|
|
45
|
-
cost_by_tags: cost_by_tags(scope, tag_breakdowns, limit: breakdown_limit),
|
|
46
|
-
top_calls: top_calls(scope)
|
|
47
|
-
)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def self.normalized_days(days)
|
|
51
|
-
days = days.to_i
|
|
52
|
-
days.positive? ? days : DEFAULT_DAYS
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def self.normalized_limit(limit)
|
|
56
|
-
return nil if limit.nil?
|
|
57
|
-
|
|
58
|
-
limit = limit.to_i
|
|
59
|
-
limit.positive? ? limit : nil
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def self.average_latency_ms(scope)
|
|
63
|
-
return nil unless LlmApiCall.latency_column?
|
|
64
|
-
|
|
65
|
-
scope.average(:latency_ms)&.to_f
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def self.cost_by(scope, column, limit:)
|
|
69
|
-
relation = scope.group(column)
|
|
70
|
-
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
71
|
-
|
|
72
|
-
relation = relation.limit(limit) if limit
|
|
73
|
-
|
|
74
|
-
relation
|
|
75
|
-
.sum(:total_cost)
|
|
76
|
-
.transform_values(&:to_f)
|
|
77
|
-
.sort_by { |_name, cost| -cost }
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def self.cost_by_tags(scope, keys, limit:)
|
|
81
|
-
keys.to_h { |key| [key, scope.cost_by_tag(key, limit: limit).to_a] }
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def self.top_calls(scope)
|
|
85
|
-
scope
|
|
86
|
-
.where.not(total_cost: nil)
|
|
87
|
-
.order(total_cost: :desc)
|
|
88
|
-
.limit(TOP_LIMIT)
|
|
89
|
-
.map { |call| TopCall.new(provider: call.provider, model: call.model, total_cost: call.total_cost.to_f) }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
private_class_method :normalized_days, :normalized_limit, :average_latency_ms, :cost_by, :cost_by_tags, :top_calls
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LlmCostTracker
|
|
4
|
-
class ReportFormatter
|
|
5
|
-
TOP_LIMIT = 5
|
|
6
|
-
NAME_COLUMN_WIDTH = 28
|
|
7
|
-
TOP_CALL_COLUMN_WIDTH = 32
|
|
8
|
-
|
|
9
|
-
def initialize(data)
|
|
10
|
-
@data = data
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def to_s
|
|
14
|
-
lines = ["LLM Cost Report (last #{@data.days} days)", ""]
|
|
15
|
-
append_summary(lines)
|
|
16
|
-
append_cost_section(lines, "By provider", @data.cost_by_provider)
|
|
17
|
-
append_cost_section(lines, "By model", @data.cost_by_model)
|
|
18
|
-
append_tag_sections(lines)
|
|
19
|
-
append_top_calls(lines)
|
|
20
|
-
lines.join("\n")
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def append_summary(lines)
|
|
26
|
-
lines << "Total cost: #{money(@data.total_cost)}"
|
|
27
|
-
lines << "Requests: #{@data.requests_count}"
|
|
28
|
-
lines << "Avg latency: #{average_latency}"
|
|
29
|
-
lines << "Unknown pricing: #{@data.unknown_pricing_count}"
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def append_cost_section(lines, title, rows)
|
|
33
|
-
lines << ""
|
|
34
|
-
lines << "#{title}:"
|
|
35
|
-
return lines << " none" if rows.empty?
|
|
36
|
-
|
|
37
|
-
rows.first(TOP_LIMIT).each do |name, cost|
|
|
38
|
-
lines << " #{name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(cost)}"
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def append_tag_sections(lines)
|
|
43
|
-
@data.cost_by_tags.each do |tag_key, rows|
|
|
44
|
-
append_cost_section(lines, "By tag (#{tag_key})", rows)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def append_top_calls(lines)
|
|
49
|
-
lines << ""
|
|
50
|
-
lines << "Top expensive calls:"
|
|
51
|
-
return lines << " none" if @data.top_calls.empty?
|
|
52
|
-
|
|
53
|
-
@data.top_calls.first(TOP_LIMIT).each do |call|
|
|
54
|
-
label = "#{call.provider}/#{call.model}"
|
|
55
|
-
lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def average_latency
|
|
60
|
-
@data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def money(value)
|
|
64
|
-
"$#{format('%.6f', value.to_f)}"
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|