llm_cost_tracker 0.5.0 → 0.5.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 +38 -0
- data/README.md +116 -467
- data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
- data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
- data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
- data/lib/llm_cost_tracker/configuration.rb +22 -16
- data/lib/llm_cost_tracker/doctor.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
- data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
- data/lib/llm_cost_tracker/integrations/base.rb +77 -6
- data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
- data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
- data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
- data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
- data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
- data/lib/llm_cost_tracker/price_freshness.rb +3 -3
- data/lib/llm_cost_tracker/price_registry.rb +3 -0
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
- data/lib/llm_cost_tracker/price_sync.rb +103 -111
- data/lib/llm_cost_tracker/prices.json +225 -229
- data/lib/llm_cost_tracker/pricing.rb +27 -15
- data/lib/llm_cost_tracker/report.rb +8 -1
- data/lib/llm_cost_tracker/report_data.rb +25 -9
- data/lib/llm_cost_tracker/retention.rb +30 -7
- data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
- data/lib/llm_cost_tracker/stream_capture.rb +7 -0
- data/lib/llm_cost_tracker/stream_collector.rb +25 -1
- data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
- data/lib/llm_cost_tracker/tracker.rb +7 -59
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +1 -0
- data/lib/tasks/llm_cost_tracker.rake +24 -78
- metadata +26 -15
- data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
- data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
- data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
- data/lib/llm_cost_tracker/price_sync/validator.rb +0 -66
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module PriceSync
|
|
7
|
-
module Sources
|
|
8
|
-
class Litellm < Source
|
|
9
|
-
PER_TOKEN_TO_PER_MILLION = 1_000_000
|
|
10
|
-
SUPPORTED_MODES = %w[chat completion embedding responses].freeze
|
|
11
|
-
SUPPORTED_PROVIDERS = %w[openai anthropic gemini text-completion-openai].freeze
|
|
12
|
-
URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
|
|
13
|
-
|
|
14
|
-
def priority
|
|
15
|
-
10
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def url
|
|
19
|
-
URL
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def fetch(current_models:, fetcher:)
|
|
23
|
-
response = fetcher.get(url)
|
|
24
|
-
payload = JSON.parse(response.body.to_s)
|
|
25
|
-
|
|
26
|
-
prices = []
|
|
27
|
-
missing_models = []
|
|
28
|
-
|
|
29
|
-
current_models.each_key do |our_model|
|
|
30
|
-
entry_id = ModelCatalog.resolve_from_litellm(our_model, payload)
|
|
31
|
-
entry = entry_id && payload[entry_id]
|
|
32
|
-
|
|
33
|
-
if entry && supported_entry?(entry)
|
|
34
|
-
prices << build_raw_price(our_model, entry, response)
|
|
35
|
-
else
|
|
36
|
-
missing_models << our_model
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
SourceResult.new(
|
|
41
|
-
prices: prices,
|
|
42
|
-
missing_models: missing_models.sort,
|
|
43
|
-
source_version: response_version(response)
|
|
44
|
-
)
|
|
45
|
-
rescue JSON::ParserError => e
|
|
46
|
-
raise Error, "Unable to parse #{url}: #{e.message}"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def supported_entry?(entry)
|
|
52
|
-
SUPPORTED_PROVIDERS.include?(entry["litellm_provider"]) &&
|
|
53
|
-
SUPPORTED_MODES.include?(entry["mode"]) &&
|
|
54
|
-
entry.key?("input_cost_per_token") &&
|
|
55
|
-
entry.key?("output_cost_per_token")
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def build_raw_price(model, entry, response)
|
|
59
|
-
provider = normalize_provider(entry["litellm_provider"])
|
|
60
|
-
cache_read = price_per_million(entry["cache_read_input_token_cost"])
|
|
61
|
-
cache_write = price_per_million(entry["cache_creation_input_token_cost"])
|
|
62
|
-
|
|
63
|
-
RawPrice.new(
|
|
64
|
-
model: model,
|
|
65
|
-
provider: provider,
|
|
66
|
-
input: price_per_million(entry["input_cost_per_token"]),
|
|
67
|
-
output: price_per_million(entry["output_cost_per_token"]),
|
|
68
|
-
cache_read_input: cache_read,
|
|
69
|
-
cache_write_input: cache_write,
|
|
70
|
-
source: name,
|
|
71
|
-
source_version: response_version(response),
|
|
72
|
-
fetched_at: response.fetched_at
|
|
73
|
-
)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def normalize_provider(provider)
|
|
77
|
-
return "openai" if provider == "text-completion-openai"
|
|
78
|
-
|
|
79
|
-
provider
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def price_per_million(value)
|
|
83
|
-
return nil if value.nil?
|
|
84
|
-
|
|
85
|
-
value.to_f * PER_TOKEN_TO_PER_MILLION
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module PriceSync
|
|
7
|
-
module Sources
|
|
8
|
-
class OpenRouter < Source
|
|
9
|
-
PER_TOKEN_TO_PER_MILLION = 1_000_000
|
|
10
|
-
SUPPORTED_PREFIXES = %w[openai anthropic google].freeze
|
|
11
|
-
URL = "https://openrouter.ai/api/v1/models"
|
|
12
|
-
|
|
13
|
-
def priority
|
|
14
|
-
20
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def url
|
|
18
|
-
URL
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def fetch(current_models:, fetcher:)
|
|
22
|
-
response = fetcher.get(url)
|
|
23
|
-
payload = JSON.parse(response.body.to_s)
|
|
24
|
-
index = payload.fetch("data", []).to_h { |entry| [entry["id"], entry] }
|
|
25
|
-
|
|
26
|
-
prices = []
|
|
27
|
-
missing_models = []
|
|
28
|
-
|
|
29
|
-
current_models.each_key do |our_model|
|
|
30
|
-
entry_id = ModelCatalog.resolve_from_openrouter(our_model, index)
|
|
31
|
-
entry = entry_id && index[entry_id]
|
|
32
|
-
|
|
33
|
-
if entry && supported_entry?(entry)
|
|
34
|
-
prices << build_raw_price(our_model, entry, response)
|
|
35
|
-
else
|
|
36
|
-
missing_models << our_model
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
SourceResult.new(
|
|
41
|
-
prices: prices,
|
|
42
|
-
missing_models: missing_models.sort,
|
|
43
|
-
source_version: response_version(response)
|
|
44
|
-
)
|
|
45
|
-
rescue JSON::ParserError => e
|
|
46
|
-
raise Error, "Unable to parse #{url}: #{e.message}"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def supported_entry?(entry)
|
|
52
|
-
pricing = entry["pricing"] || {}
|
|
53
|
-
provider = entry["id"].to_s.split("/").first
|
|
54
|
-
|
|
55
|
-
SUPPORTED_PREFIXES.include?(provider) &&
|
|
56
|
-
pricing["prompt"] &&
|
|
57
|
-
pricing["completion"]
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def build_raw_price(model, entry, response)
|
|
61
|
-
pricing = entry.fetch("pricing", {})
|
|
62
|
-
provider = normalize_provider(entry.fetch("id").split("/").first)
|
|
63
|
-
cache_read = price_per_million(pricing["input_cache_read"])
|
|
64
|
-
cache_write = price_per_million(pricing["input_cache_write"])
|
|
65
|
-
|
|
66
|
-
RawPrice.new(
|
|
67
|
-
model: model,
|
|
68
|
-
provider: provider,
|
|
69
|
-
input: price_per_million(pricing["prompt"]),
|
|
70
|
-
output: price_per_million(pricing["completion"]),
|
|
71
|
-
cache_read_input: cache_read,
|
|
72
|
-
cache_write_input: cache_write,
|
|
73
|
-
source: name,
|
|
74
|
-
source_version: response_version(response),
|
|
75
|
-
fetched_at: response.fetched_at
|
|
76
|
-
)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def normalize_provider(provider)
|
|
80
|
-
return "gemini" if provider == "google"
|
|
81
|
-
|
|
82
|
-
provider
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def price_per_million(value)
|
|
86
|
-
return nil if value.nil?
|
|
87
|
-
|
|
88
|
-
value.to_f * PER_TOKEN_TO_PER_MILLION
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LlmCostTracker
|
|
4
|
-
module PriceSync
|
|
5
|
-
class Validator
|
|
6
|
-
Result = Data.define(:accepted, :rejected, :flagged)
|
|
7
|
-
Issue = Data.define(:model, :reason, :old_price, :new_price)
|
|
8
|
-
|
|
9
|
-
MAX_INPUT_PER_MILLION = 100.0
|
|
10
|
-
MAX_OUTPUT_PER_MILLION = 500.0
|
|
11
|
-
MAX_RELATIVE_CHANGE = 3.0
|
|
12
|
-
|
|
13
|
-
def validate_batch(merged_prices, existing_registry:)
|
|
14
|
-
merged_prices.each_with_object(Result.new(accepted: {}, rejected: [], flagged: [])) do |(model, price), result|
|
|
15
|
-
old_price = normalize_entry(existing_registry[model])
|
|
16
|
-
status, reason = validate(new_price: price, old_price: old_price)
|
|
17
|
-
|
|
18
|
-
case status
|
|
19
|
-
when :rejected
|
|
20
|
-
result.rejected << Issue.new(model: model, reason: reason, old_price: old_price, new_price: price)
|
|
21
|
-
when :flagged
|
|
22
|
-
result.flagged << Issue.new(model: model, reason: reason, old_price: old_price, new_price: price)
|
|
23
|
-
result.accepted[model] = price
|
|
24
|
-
else
|
|
25
|
-
result.accepted[model] = price
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
def validate(new_price:, old_price:)
|
|
33
|
-
overrides = Array(normalize_entry(old_price)["_validator_override"])
|
|
34
|
-
|
|
35
|
-
return [:rejected, "input > $#{MAX_INPUT_PER_MILLION}/1M"] if new_price.input > MAX_INPUT_PER_MILLION
|
|
36
|
-
return [:rejected, "output > $#{MAX_OUTPUT_PER_MILLION}/1M"] if new_price.output > MAX_OUTPUT_PER_MILLION
|
|
37
|
-
return [:ok, nil] if overrides.include?("skip_relative_change")
|
|
38
|
-
|
|
39
|
-
if old_price.any? && changed_too_much?(old_price, new_price)
|
|
40
|
-
return [:flagged, "price changed >#{MAX_RELATIVE_CHANGE}x"]
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
[:ok, nil]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def changed_too_much?(old_price, new_price)
|
|
47
|
-
%i[input output].any? do |field|
|
|
48
|
-
old_value = old_price[field.to_s].to_f
|
|
49
|
-
next false if old_value.zero?
|
|
50
|
-
|
|
51
|
-
new_value = new_price.public_send(field).to_f
|
|
52
|
-
next false if new_value.zero?
|
|
53
|
-
|
|
54
|
-
ratio = [new_value / old_value, old_value / new_value].max
|
|
55
|
-
ratio > MAX_RELATIVE_CHANGE
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def normalize_entry(entry)
|
|
60
|
-
(entry || {}).each_with_object({}) do |(key, value), normalized|
|
|
61
|
-
normalized[key.to_s] = value
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|