llm_cost_tracker 0.2.0.alpha2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -1
- data/README.md +114 -70
- data/Rakefile +2 -0
- data/app/assets/llm_cost_tracker/application.css +760 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
- data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
- data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
- data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
- data/app/services/llm_cost_tracker/pagination.rb +6 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
- data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
- data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
- data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
- data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
- data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
- data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
- data/config/routes.rb +3 -0
- data/lib/llm_cost_tracker/assets.rb +19 -0
- data/lib/llm_cost_tracker/configuration.rb +78 -42
- data/lib/llm_cost_tracker/engine.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
- data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
- data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
- data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
- data/lib/llm_cost_tracker/parsers/base.rb +17 -5
- data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
- data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
- data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
- data/lib/llm_cost_tracker/price_registry.rb +18 -7
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
- data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
- data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
- data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
- data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
- data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
- data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
- data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
- data/lib/llm_cost_tracker/price_sync.rb +310 -0
- data/lib/llm_cost_tracker/pricing.rb +19 -6
- data/lib/llm_cost_tracker/retention.rb +34 -0
- data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
- data/lib/llm_cost_tracker/stream_collector.rb +158 -0
- data/lib/llm_cost_tracker/tag_query.rb +7 -2
- data/lib/llm_cost_tracker/tags_column.rb +21 -1
- data/lib/llm_cost_tracker/tracker.rb +15 -12
- data/lib/llm_cost_tracker/value_helpers.rb +40 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +51 -29
- data/lib/tasks/llm_cost_tracker.rake +124 -0
- data/llm_cost_tracker.gemspec +9 -8
- metadata +40 -12
- data/PLAN_0.2.md +0 -488
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module PriceSync
|
|
10
|
+
class Fetcher
|
|
11
|
+
Response = Data.define(:body, :etag, :last_modified, :not_modified, :fetched_at) do
|
|
12
|
+
def source_version
|
|
13
|
+
etag || last_modified || Digest::SHA256.hexdigest(body.to_s)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
USER_AGENT = "llm_cost_tracker price sync"
|
|
18
|
+
MAX_REDIRECTS = 5
|
|
19
|
+
OPEN_TIMEOUT = 5
|
|
20
|
+
READ_TIMEOUT = 10
|
|
21
|
+
WRITE_TIMEOUT = 10
|
|
22
|
+
|
|
23
|
+
def get(url, etag: nil, redirects: 0)
|
|
24
|
+
raise Error, "Too many redirects while fetching #{url}" if redirects > MAX_REDIRECTS
|
|
25
|
+
|
|
26
|
+
uri = URI.parse(url)
|
|
27
|
+
request = Net::HTTP::Get.new(uri)
|
|
28
|
+
request["User-Agent"] = USER_AGENT
|
|
29
|
+
request["If-None-Match"] = etag if etag
|
|
30
|
+
|
|
31
|
+
response = Net::HTTP.start(
|
|
32
|
+
uri.host,
|
|
33
|
+
uri.port,
|
|
34
|
+
use_ssl: uri.scheme == "https",
|
|
35
|
+
open_timeout: OPEN_TIMEOUT,
|
|
36
|
+
read_timeout: READ_TIMEOUT,
|
|
37
|
+
write_timeout: WRITE_TIMEOUT
|
|
38
|
+
) do |http|
|
|
39
|
+
http.request(request)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
case response
|
|
43
|
+
when Net::HTTPSuccess
|
|
44
|
+
build_response(response, not_modified: false)
|
|
45
|
+
when Net::HTTPNotModified
|
|
46
|
+
build_response(response, body: nil, not_modified: true)
|
|
47
|
+
when Net::HTTPRedirection
|
|
48
|
+
location = response["location"]
|
|
49
|
+
raise Error, "Redirect without location while fetching #{url}" if location.nil? || location.empty?
|
|
50
|
+
|
|
51
|
+
get(URI.join(url, location).to_s, etag: etag, redirects: redirects + 1)
|
|
52
|
+
else
|
|
53
|
+
raise Error, "Unable to fetch #{url}: HTTP #{response.code}"
|
|
54
|
+
end
|
|
55
|
+
rescue SocketError, SystemCallError, Timeout::Error => e
|
|
56
|
+
raise Error, "Unable to fetch #{url}: #{e.class}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_response(response, not_modified:, body: response.body)
|
|
62
|
+
Response.new(
|
|
63
|
+
body: body,
|
|
64
|
+
etag: response["etag"],
|
|
65
|
+
last_modified: response["last-modified"],
|
|
66
|
+
not_modified: not_modified,
|
|
67
|
+
fetched_at: Time.now.utc.iso8601
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
class Merger
|
|
6
|
+
Discrepancy = Data.define(:model, :field, :values)
|
|
7
|
+
|
|
8
|
+
PRIORITY_ORDER = %i[litellm openrouter].freeze
|
|
9
|
+
SUPPLEMENTAL_FIELDS = %i[cached_input cache_read_input cache_creation_input].freeze
|
|
10
|
+
|
|
11
|
+
def merge(results_by_source)
|
|
12
|
+
prices = collect_prices(results_by_source)
|
|
13
|
+
discrepancies = []
|
|
14
|
+
|
|
15
|
+
merged = prices.group_by(&:model).sort.to_h.transform_values do |candidates|
|
|
16
|
+
sorted = sort_candidates(candidates)
|
|
17
|
+
discrepancies.concat(detect_discrepancies(sorted))
|
|
18
|
+
fill_missing_fields(sorted.first, sorted.drop(1))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
[merged, discrepancies]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def collect_prices(results_by_source)
|
|
27
|
+
results_by_source.flat_map do |source_name, result|
|
|
28
|
+
result.prices.map do |price|
|
|
29
|
+
price.with(source: source_name)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sort_candidates(candidates)
|
|
35
|
+
candidates.sort_by do |price|
|
|
36
|
+
PRIORITY_ORDER.index(price.source.to_sym) || PRIORITY_ORDER.length
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fill_missing_fields(primary, fallbacks)
|
|
41
|
+
SUPPLEMENTAL_FIELDS.reduce(primary) do |current, field|
|
|
42
|
+
next current if current.public_send(field)
|
|
43
|
+
|
|
44
|
+
fallback = fallbacks.find { |candidate| candidate.public_send(field) }
|
|
45
|
+
fallback ? current.with(field => fallback.public_send(field)) : current
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def detect_discrepancies(candidates)
|
|
50
|
+
return [] if candidates.length < 2
|
|
51
|
+
|
|
52
|
+
RawPrice::PRICE_FIELDS.filter_map do |field|
|
|
53
|
+
values = candidates.each_with_object({}) do |price, collected|
|
|
54
|
+
value = price.public_send(field)
|
|
55
|
+
collected[price.source] = value unless value.nil?
|
|
56
|
+
end
|
|
57
|
+
next if values.size < 2
|
|
58
|
+
next unless discrepant?(values.values)
|
|
59
|
+
|
|
60
|
+
Discrepancy.new(model: candidates.first.model, field: field, values: values)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def discrepant?(values)
|
|
65
|
+
min, max = values.minmax
|
|
66
|
+
return max != min if min.to_f.zero?
|
|
67
|
+
|
|
68
|
+
((max - min).abs / min.to_f) >= 0.05
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
class ModelCatalog
|
|
6
|
+
OPENROUTER_PROVIDER_PREFIXES = {
|
|
7
|
+
openai: %w[openai],
|
|
8
|
+
anthropic: %w[anthropic],
|
|
9
|
+
gemini: %w[google]
|
|
10
|
+
}.freeze
|
|
11
|
+
LITELLM_PROVIDER_PREFIXES = {
|
|
12
|
+
openai: [nil, "openai"],
|
|
13
|
+
anthropic: [nil, "anthropic"],
|
|
14
|
+
gemini: [nil, "gemini"]
|
|
15
|
+
}.freeze
|
|
16
|
+
ALIASES = {
|
|
17
|
+
"gpt-4o-2024-05-13" => "gpt-4o"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def resolve_from_litellm(our_model, payload)
|
|
22
|
+
litellm_candidates(our_model).find { |candidate| payload.key?(candidate) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def resolve_from_openrouter(our_model, index)
|
|
26
|
+
openrouter_candidates(our_model).find { |candidate| index.key?(candidate) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def guess_provider(our_model)
|
|
30
|
+
case our_model.to_s
|
|
31
|
+
when /\A(?:gpt-|o1|o3|o4|chatgpt|text-embedding)/
|
|
32
|
+
:openai
|
|
33
|
+
when /\Aclaude-/
|
|
34
|
+
:anthropic
|
|
35
|
+
when /\Agemini-/
|
|
36
|
+
:gemini
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def litellm_candidates(our_model)
|
|
43
|
+
provider = guess_provider(our_model)
|
|
44
|
+
prefixes = LITELLM_PROVIDER_PREFIXES.fetch(provider, [nil])
|
|
45
|
+
|
|
46
|
+
model_variants(our_model).flat_map do |variant|
|
|
47
|
+
prefixes.map { |prefix| prefix ? "#{prefix}/#{variant}" : variant }
|
|
48
|
+
end.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def openrouter_candidates(our_model)
|
|
52
|
+
provider = guess_provider(our_model)
|
|
53
|
+
prefixes = OPENROUTER_PROVIDER_PREFIXES.fetch(provider, [])
|
|
54
|
+
|
|
55
|
+
model_variants(our_model).flat_map do |variant|
|
|
56
|
+
prefixes.map { |prefix| "#{prefix}/#{variant}" }
|
|
57
|
+
end.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def model_variants(our_model)
|
|
61
|
+
model = our_model.to_s
|
|
62
|
+
canonical = ALIASES.fetch(model, model)
|
|
63
|
+
|
|
64
|
+
[model, canonical].flat_map do |variant|
|
|
65
|
+
[variant, anthropic_version_variant(variant)]
|
|
66
|
+
end.compact.uniq
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def anthropic_version_variant(model)
|
|
70
|
+
return nil unless guess_provider(model) == :anthropic
|
|
71
|
+
|
|
72
|
+
model.gsub(/(?<=\d)-(?=\d)/, ".")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
RawPrice = Data.define(
|
|
6
|
+
:model,
|
|
7
|
+
:provider,
|
|
8
|
+
:input,
|
|
9
|
+
:output,
|
|
10
|
+
:cached_input,
|
|
11
|
+
:cache_read_input,
|
|
12
|
+
:cache_creation_input,
|
|
13
|
+
:source,
|
|
14
|
+
:source_version,
|
|
15
|
+
:fetched_at
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class RawPrice
|
|
19
|
+
PRICE_FIELDS = %w[input output cached_input cache_read_input cache_creation_input].freeze
|
|
20
|
+
|
|
21
|
+
def to_registry_entry(today:)
|
|
22
|
+
{
|
|
23
|
+
"input" => input,
|
|
24
|
+
"output" => output,
|
|
25
|
+
"cached_input" => cached_input,
|
|
26
|
+
"cache_read_input" => cache_read_input,
|
|
27
|
+
"cache_creation_input" => cache_creation_input,
|
|
28
|
+
"_source" => source.to_s,
|
|
29
|
+
"_source_version" => source_version,
|
|
30
|
+
"_fetched_at" => fetched_at || today.iso8601
|
|
31
|
+
}.compact
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PriceSync
|
|
5
|
+
class Source
|
|
6
|
+
def fetch(current_models:, fetcher:)
|
|
7
|
+
raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def name
|
|
11
|
+
self.class.name.split("::").last.downcase.to_sym
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def priority
|
|
15
|
+
100
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def url
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def response_version(response)
|
|
25
|
+
response.source_version
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
cached_input: provider == "anthropic" ? nil : cache_read,
|
|
69
|
+
cache_read_input: provider == "anthropic" ? cache_read : nil,
|
|
70
|
+
cache_creation_input: provider == "anthropic" ? cache_write : nil,
|
|
71
|
+
source: name,
|
|
72
|
+
source_version: response_version(response),
|
|
73
|
+
fetched_at: response.fetched_at
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_provider(provider)
|
|
78
|
+
return "openai" if provider == "text-completion-openai"
|
|
79
|
+
|
|
80
|
+
provider
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def price_per_million(value)
|
|
84
|
+
return nil if value.nil?
|
|
85
|
+
|
|
86
|
+
value.to_f * PER_TOKEN_TO_PER_MILLION
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
cached_input: provider == "anthropic" ? nil : cache_read,
|
|
72
|
+
cache_read_input: provider == "anthropic" ? cache_read : nil,
|
|
73
|
+
cache_creation_input: provider == "anthropic" ? cache_write : nil,
|
|
74
|
+
source: name,
|
|
75
|
+
source_version: response_version(response),
|
|
76
|
+
fetched_at: response.fetched_at
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def normalize_provider(provider)
|
|
81
|
+
return "gemini" if provider == "google"
|
|
82
|
+
|
|
83
|
+
provider
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def price_per_million(value)
|
|
87
|
+
return nil if value.nil?
|
|
88
|
+
|
|
89
|
+
value.to_f * PER_TOKEN_TO_PER_MILLION
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|