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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +116 -467
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +2 -1
  5. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +3 -15
  6. data/app/controllers/llm_cost_tracker/tags_controller.rb +7 -6
  7. data/app/helpers/llm_cost_tracker/application_helper.rb +21 -6
  8. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +3 -1
  9. data/app/services/llm_cost_tracker/dashboard/date_range.rb +42 -0
  10. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -8
  11. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +6 -5
  12. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +74 -18
  13. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +15 -4
  14. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  15. data/app/views/llm_cost_tracker/tags/show.html.erb +4 -0
  16. data/lib/llm_cost_tracker/configuration.rb +22 -16
  17. data/lib/llm_cost_tracker/doctor.rb +1 -1
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +1 -0
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +8 -2
  20. data/lib/llm_cost_tracker/integrations/anthropic.rb +12 -3
  21. data/lib/llm_cost_tracker/integrations/base.rb +77 -6
  22. data/lib/llm_cost_tracker/integrations/object_reader.rb +1 -1
  23. data/lib/llm_cost_tracker/integrations/openai.rb +14 -5
  24. data/lib/llm_cost_tracker/integrations/registry.rb +3 -1
  25. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +171 -0
  26. data/lib/llm_cost_tracker/llm_api_call.rb +10 -9
  27. data/lib/llm_cost_tracker/middleware/faraday.rb +10 -6
  28. data/lib/llm_cost_tracker/parsers/gemini.rb +8 -1
  29. data/lib/llm_cost_tracker/parsers/openai_usage.rb +11 -2
  30. data/lib/llm_cost_tracker/price_freshness.rb +3 -3
  31. data/lib/llm_cost_tracker/price_registry.rb +3 -0
  32. data/lib/llm_cost_tracker/price_sync/fetcher.rb +43 -12
  33. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +51 -0
  34. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +6 -0
  35. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +5 -1
  36. data/lib/llm_cost_tracker/price_sync.rb +103 -111
  37. data/lib/llm_cost_tracker/prices.json +225 -229
  38. data/lib/llm_cost_tracker/pricing.rb +27 -15
  39. data/lib/llm_cost_tracker/report.rb +8 -1
  40. data/lib/llm_cost_tracker/report_data.rb +25 -9
  41. data/lib/llm_cost_tracker/retention.rb +30 -7
  42. data/lib/llm_cost_tracker/storage/dispatcher.rb +68 -0
  43. data/lib/llm_cost_tracker/stream_capture.rb +7 -0
  44. data/lib/llm_cost_tracker/stream_collector.rb +25 -1
  45. data/lib/llm_cost_tracker/tag_sanitizer.rb +81 -0
  46. data/lib/llm_cost_tracker/tracker.rb +7 -59
  47. data/lib/llm_cost_tracker/version.rb +1 -1
  48. data/lib/llm_cost_tracker.rb +1 -0
  49. data/lib/tasks/llm_cost_tracker.rake +24 -78
  50. metadata +26 -15
  51. data/lib/llm_cost_tracker/price_sync/merger.rb +0 -72
  52. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +0 -77
  53. data/lib/llm_cost_tracker/price_sync/raw_price.rb +0 -33
  54. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +0 -164
  55. data/lib/llm_cost_tracker/price_sync/source.rb +0 -29
  56. data/lib/llm_cost_tracker/price_sync/source_result.rb +0 -7
  57. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +0 -90
  58. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +0 -93
  59. 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