llm_cost_tracker 0.3.3 → 0.4.1

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +46 -25
  4. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +96 -23
  5. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +81 -0
  6. data/app/views/llm_cost_tracker/data_quality/index.html.erb +65 -0
  7. data/lib/llm_cost_tracker/budget.rb +73 -22
  8. data/lib/llm_cost_tracker/configuration.rb +4 -0
  9. data/lib/llm_cost_tracker/cost.rb +1 -2
  10. data/lib/llm_cost_tracker/errors.rb +22 -3
  11. data/lib/llm_cost_tracker/event.rb +4 -0
  12. data/lib/llm_cost_tracker/event_metadata.rb +21 -15
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_monthly_totals_generator.rb → add_period_totals_generator.rb} +4 -4
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +29 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +96 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +29 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +11 -5
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -1
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +11 -3
  20. data/lib/llm_cost_tracker/parsed_usage.rb +16 -7
  21. data/lib/llm_cost_tracker/parsers/anthropic.rb +24 -55
  22. data/lib/llm_cost_tracker/parsers/base.rb +80 -0
  23. data/lib/llm_cost_tracker/parsers/gemini.rb +17 -37
  24. data/lib/llm_cost_tracker/parsers/openai.rb +1 -6
  25. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +6 -15
  26. data/lib/llm_cost_tracker/parsers/openai_usage.rb +25 -34
  27. data/lib/llm_cost_tracker/parsers/registry.rb +17 -2
  28. data/lib/llm_cost_tracker/period_total.rb +9 -0
  29. data/lib/llm_cost_tracker/price_registry.rb +14 -4
  30. data/lib/llm_cost_tracker/price_sync/merger.rb +1 -1
  31. data/lib/llm_cost_tracker/price_sync/raw_price.rb +3 -5
  32. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +2 -3
  33. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +2 -3
  34. data/lib/llm_cost_tracker/prices.json +30 -30
  35. data/lib/llm_cost_tracker/pricing.rb +44 -32
  36. data/lib/llm_cost_tracker/railtie.rb +2 -1
  37. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +142 -0
  38. data/lib/llm_cost_tracker/storage/active_record_store.rb +35 -78
  39. data/lib/llm_cost_tracker/stream_collector.rb +4 -2
  40. data/lib/llm_cost_tracker/tags_column.rb +71 -14
  41. data/lib/llm_cost_tracker/tracker.rb +54 -32
  42. data/lib/llm_cost_tracker/usage_breakdown.rb +30 -0
  43. data/lib/llm_cost_tracker/version.rb +1 -1
  44. data/lib/llm_cost_tracker.rb +10 -3
  45. metadata +9 -4
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_monthly_totals_to_llm_cost_tracker.rb.erb +0 -48
  47. data/lib/llm_cost_tracker/monthly_total.rb +0 -9
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
 
7
5
  module LlmCostTracker
@@ -10,10 +8,7 @@ module LlmCostTracker
10
8
  HOSTS = %w[api.anthropic.com].freeze
11
9
 
12
10
  def match?(url)
13
- uri = URI.parse(url.to_s)
14
- HOSTS.include?(uri.host.to_s.downcase) && uri.path.include?("/v1/messages")
15
- rescue URI::InvalidURIError
16
- false
11
+ match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
17
12
  end
18
13
 
19
14
  def provider_names
@@ -28,6 +23,8 @@ module LlmCostTracker
28
23
  return nil unless usage
29
24
 
30
25
  request = safe_json_parse(request_body)
26
+ cache_read = usage["cache_read_input_tokens"].to_i
27
+ cache_write = usage["cache_creation_input_tokens"].to_i
31
28
 
32
29
  ParsedUsage.build(
33
30
  provider: "anthropic",
@@ -35,10 +32,9 @@ module LlmCostTracker
35
32
  model: response["model"] || request["model"],
36
33
  input_tokens: usage["input_tokens"].to_i,
37
34
  output_tokens: usage["output_tokens"].to_i,
38
- total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i +
39
- usage["cache_read_input_tokens"].to_i + usage["cache_creation_input_tokens"].to_i,
35
+ total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i + cache_read + cache_write,
40
36
  cache_read_input_tokens: usage["cache_read_input_tokens"],
41
- cache_creation_input_tokens: usage["cache_creation_input_tokens"],
37
+ cache_write_input_tokens: usage["cache_creation_input_tokens"],
42
38
  usage_source: :response
43
39
  )
44
40
  end
@@ -51,25 +47,25 @@ module LlmCostTracker
51
47
  usage = stream_usage(events)
52
48
  response_id = stream_response_id(events)
53
49
 
54
- usage ? build_stream_result(model, usage, response_id) : build_unknown_stream_result(model, response_id)
50
+ if usage
51
+ build_stream_result(model, usage, response_id)
52
+ else
53
+ build_unknown_stream_usage(
54
+ provider: "anthropic",
55
+ model: model,
56
+ provider_response_id: response_id
57
+ )
58
+ end
55
59
  end
56
60
 
57
61
  private
58
62
 
59
63
  def stream_usage(events)
60
- start_usage = nil
61
- latest_delta = nil
62
-
63
- events.each do |event|
64
- data = event[:data]
65
- next unless data.is_a?(Hash)
66
-
67
- case data["type"]
68
- when "message_start"
69
- start_usage = data.dig("message", "usage")
70
- when "message_delta"
71
- latest_delta = data["usage"] if data["usage"].is_a?(Hash)
72
- end
64
+ start_usage = find_event_value(events, reverse: true) do |data|
65
+ data.dig("message", "usage") if data["type"] == "message_start"
66
+ end
67
+ latest_delta = find_event_value(events, reverse: true) do |data|
68
+ data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
73
69
  end
74
70
 
75
71
  return nil unless start_usage || latest_delta
@@ -80,32 +76,18 @@ module LlmCostTracker
80
76
  end
81
77
 
82
78
  def stream_model(events)
83
- events.each do |event|
84
- data = event[:data]
85
- next unless data.is_a?(Hash)
86
-
87
- model = data.dig("message", "model")
88
- return model if model && !model.empty?
89
- end
90
- nil
79
+ find_event_value(events) { |data| data.dig("message", "model") }
91
80
  end
92
81
 
93
82
  def stream_response_id(events)
94
- events.each do |event|
95
- data = event[:data]
96
- next unless data.is_a?(Hash)
97
-
98
- id = data.dig("message", "id") || data["id"]
99
- return id if id && !id.to_s.empty?
100
- end
101
- nil
83
+ find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
102
84
  end
103
85
 
104
86
  def build_stream_result(model, usage, response_id)
105
87
  input = usage["input_tokens"].to_i
106
88
  output = usage["output_tokens"].to_i
107
89
  cache_read = usage["cache_read_input_tokens"].to_i
108
- cache_creation = usage["cache_creation_input_tokens"].to_i
90
+ cache_write = usage["cache_creation_input_tokens"].to_i
109
91
 
110
92
  ParsedUsage.build(
111
93
  provider: "anthropic",
@@ -113,26 +95,13 @@ module LlmCostTracker
113
95
  model: model,
114
96
  input_tokens: input,
115
97
  output_tokens: output,
116
- total_tokens: input + output + cache_read + cache_creation,
98
+ total_tokens: input + output + cache_read + cache_write,
117
99
  cache_read_input_tokens: usage["cache_read_input_tokens"],
118
- cache_creation_input_tokens: usage["cache_creation_input_tokens"],
100
+ cache_write_input_tokens: usage["cache_creation_input_tokens"],
119
101
  stream: true,
120
102
  usage_source: :stream_final
121
103
  )
122
104
  end
123
-
124
- def build_unknown_stream_result(model, response_id)
125
- ParsedUsage.build(
126
- provider: "anthropic",
127
- provider_response_id: response_id,
128
- model: model,
129
- input_tokens: 0,
130
- output_tokens: 0,
131
- total_tokens: 0,
132
- stream: true,
133
- usage_source: :unknown
134
- )
135
- end
136
105
  end
137
106
  end
138
107
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Parsers
@@ -40,6 +41,85 @@ module LlmCostTracker
40
41
  rescue JSON::ParserError
41
42
  {}
42
43
  end
44
+
45
+ def uri_matches?(url)
46
+ uri = parsed_uri(url)
47
+ uri ? yield(uri) : false
48
+ end
49
+
50
+ def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
51
+ uri_matches?(url) do |uri|
52
+ host_match = hosts.nil? || host_matches?(uri, hosts)
53
+ path_match = path_matches?(
54
+ uri,
55
+ exact_paths: exact_paths,
56
+ path_includes: path_includes,
57
+ path_suffixes: path_suffixes,
58
+ path_pattern: path_pattern
59
+ )
60
+ extra_match = block_given? ? yield(uri) : true
61
+
62
+ host_match && path_match && extra_match ? true : false
63
+ end
64
+ end
65
+
66
+ def parsed_uri(url)
67
+ URI.parse(url.to_s)
68
+ rescue URI::InvalidURIError
69
+ nil
70
+ end
71
+
72
+ def host_matches?(uri, hosts)
73
+ hosts.include?(uri.host.to_s.downcase)
74
+ end
75
+
76
+ def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
77
+ path = uri.path.to_s
78
+ matches = true
79
+
80
+ matches &&= exact_paths.include?(path) if exact_paths
81
+ matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
82
+ matches &&= path.match?(path_pattern) if path_pattern
83
+
84
+ matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
85
+
86
+ matches
87
+ end
88
+
89
+ def each_event_data(events, reverse: false)
90
+ enumerator = reverse ? events.reverse_each : events.each
91
+
92
+ enumerator.each do |event|
93
+ data = event[:data]
94
+ yield data if data.is_a?(Hash)
95
+ end
96
+ end
97
+
98
+ def find_event_value(events, reverse: false)
99
+ each_event_data(events, reverse:) do |data|
100
+ value = yield(data)
101
+ return value if event_value_present?(value)
102
+ end
103
+
104
+ nil
105
+ end
106
+
107
+ def build_unknown_stream_usage(provider:, model:, provider_response_id:)
108
+ ParsedUsage.build(
109
+ provider: provider,
110
+ provider_response_id: provider_response_id,
111
+ model: model,
112
+ input_tokens: 0,
113
+ output_tokens: 0,
114
+ total_tokens: 0,
115
+ stream: true,
116
+ usage_source: :unknown
117
+ )
118
+ end
119
+
120
+ def event_value_present?(value)
121
+ !value.nil? && (!value.respond_to?(:empty?) || !value.empty?)
122
+ end
43
123
  end
44
124
  end
45
125
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
 
7
5
  module LlmCostTracker
@@ -12,10 +10,7 @@ module LlmCostTracker
12
10
  STREAM_PATH_PATTERN = /:streamGenerateContent\z/
13
11
 
14
12
  def match?(url)
15
- uri = URI.parse(url.to_s)
16
- HOSTS.include?(uri.host.to_s.downcase) && uri.path.match?(TRACKED_PATH_PATTERN)
17
- rescue URI::InvalidURIError
18
- false
13
+ match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
19
14
  end
20
15
 
21
16
  def provider_names
@@ -48,6 +43,7 @@ module LlmCostTracker
48
43
 
49
44
  usage = merged_stream_usage(events)
50
45
  model = extract_model_from_url(request_url)
46
+ response_id = stream_response_id(events)
51
47
 
52
48
  if usage
53
49
  build_parsed_usage(
@@ -55,18 +51,13 @@ module LlmCostTracker
55
51
  usage,
56
52
  stream: true,
57
53
  usage_source: :stream_final,
58
- provider_response_id: stream_response_id(events)
54
+ provider_response_id: response_id
59
55
  )
60
56
  else
61
- ParsedUsage.build(
57
+ build_unknown_stream_usage(
62
58
  provider: "gemini",
63
- provider_response_id: stream_response_id(events),
64
59
  model: model,
65
- input_tokens: 0,
66
- output_tokens: 0,
67
- total_tokens: 0,
68
- stream: true,
69
- usage_source: :unknown
60
+ provider_response_id: response_id
70
61
  )
71
62
  end
72
63
  end
@@ -74,13 +65,16 @@ module LlmCostTracker
74
65
  private
75
66
 
76
67
  def build_parsed_usage(request_url, usage, usage_source:, stream: false, provider_response_id: nil)
68
+ cache_read = usage["cachedContentTokenCount"].to_i
69
+
77
70
  ParsedUsage.build(
78
71
  provider: "gemini",
79
72
  model: extract_model_from_url(request_url),
80
- input_tokens: usage["promptTokenCount"].to_i,
73
+ input_tokens: [usage["promptTokenCount"].to_i - cache_read, 0].max,
81
74
  output_tokens: output_tokens(usage),
82
75
  total_tokens: usage["totalTokenCount"].to_i,
83
- cached_input_tokens: usage["cachedContentTokenCount"],
76
+ cache_read_input_tokens: usage["cachedContentTokenCount"],
77
+ hidden_output_tokens: usage["thoughtsTokenCount"],
84
78
  stream: stream,
85
79
  usage_source: usage_source,
86
80
  provider_response_id: provider_response_id
@@ -88,15 +82,10 @@ module LlmCostTracker
88
82
  end
89
83
 
90
84
  def merged_stream_usage(events)
91
- latest = nil
92
- events.each do |event|
93
- data = event[:data]
94
- next unless data.is_a?(Hash)
95
-
85
+ find_event_value(events, reverse: true) do |data|
96
86
  meta = data["usageMetadata"]
97
- latest = meta if meta.is_a?(Hash)
87
+ meta if meta.is_a?(Hash)
98
88
  end
99
- latest
100
89
  end
101
90
 
102
91
  def output_tokens(usage)
@@ -104,28 +93,19 @@ module LlmCostTracker
104
93
  end
105
94
 
106
95
  def stream_response_id(events)
107
- events.each do |event|
108
- data = event[:data]
109
- next unless data.is_a?(Hash)
110
-
111
- id = data["responseId"]
112
- return id if id && !id.to_s.empty?
113
- end
114
- nil
96
+ find_event_value(events) { |data| data["responseId"] }
115
97
  end
116
98
 
117
99
  def streaming_url?(request_url)
118
- URI.parse(request_url.to_s).path.match?(STREAM_PATH_PATTERN)
119
- rescue URI::InvalidURIError
120
- false
100
+ match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
121
101
  end
122
102
 
123
103
  def extract_model_from_url(url)
124
- uri = URI.parse(url.to_s)
104
+ uri = parsed_uri(url)
105
+ return nil unless uri
106
+
125
107
  match = uri.path.match(%r{/models/([^/:]+)})
126
108
  match && match[1]
127
- rescue URI::InvalidURIError
128
- nil
129
109
  end
130
110
  end
131
111
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
  require_relative "openai_usage"
7
5
 
@@ -14,10 +12,7 @@ module LlmCostTracker
14
12
  TRACKED_PATHS = %w[/v1/chat/completions /v1/completions /v1/embeddings /v1/responses].freeze
15
13
 
16
14
  def match?(url)
17
- uri = URI.parse(url.to_s)
18
- HOSTS.include?(uri.host.to_s.downcase) && TRACKED_PATHS.include?(uri.path)
19
- rescue URI::InvalidURIError
20
- false
15
+ match_uri?(url, hosts: HOSTS, exact_paths: TRACKED_PATHS)
21
16
  end
22
17
 
23
18
  def provider_names
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  require_relative "base"
6
4
  require_relative "openai_usage"
7
5
 
@@ -13,10 +11,7 @@ module LlmCostTracker
13
11
  TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
14
12
 
15
13
  def match?(url)
16
- uri = URI.parse(url.to_s)
17
- !provider_for_host(uri.host).nil? && tracked_path?(uri.path)
18
- rescue URI::InvalidURIError
19
- false
14
+ match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
20
15
  end
21
16
 
22
17
  def provider_names
@@ -37,18 +32,14 @@ module LlmCostTracker
37
32
  private
38
33
 
39
34
  def provider_for(request_url)
40
- uri = URI.parse(request_url.to_s)
41
- provider_for_host(uri.host) || "openai_compatible"
42
- rescue URI::InvalidURIError
43
- "openai_compatible"
35
+ uri = parsed_uri(request_url)
36
+ provider_for_uri(uri) || "openai_compatible"
44
37
  end
45
38
 
46
- def provider_for_host(host)
47
- LlmCostTracker.configuration.openai_compatible_providers[host.to_s.downcase]&.to_s
48
- end
39
+ def provider_for_uri(uri)
40
+ return nil unless uri
49
41
 
50
- def tracked_path?(path)
51
- TRACKED_PATH_SUFFIXES.any? { |suffix| path == suffix || path.end_with?(suffix) }
42
+ LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
52
43
  end
53
44
  end
54
45
  end
@@ -13,15 +13,17 @@ module LlmCostTracker
13
13
  return nil unless usage
14
14
 
15
15
  request = safe_json_parse(request_body)
16
+ cache_read = cache_read_input_tokens(usage)
16
17
 
17
18
  ParsedUsage.build(
18
19
  provider: provider_for(request_url),
19
20
  provider_response_id: response["id"],
20
21
  model: response["model"] || request["model"],
21
- input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
22
+ input_tokens: regular_input_tokens(usage, cache_read),
22
23
  output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
23
24
  total_tokens: usage["total_tokens"].to_i,
24
- cached_input_tokens: cached_input_tokens(usage),
25
+ cache_read_input_tokens: cache_read,
26
+ hidden_output_tokens: hidden_output_tokens(usage),
25
27
  usage_source: :response
26
28
  )
27
29
  end
@@ -32,70 +34,59 @@ module LlmCostTracker
32
34
  request = safe_json_parse(request_body)
33
35
  model = detect_stream_model(events) || request["model"]
34
36
  usage = detect_stream_usage(events)
37
+ response_id = detect_stream_response_id(events)
35
38
 
36
39
  if usage
40
+ cache_read = cache_read_input_tokens(usage)
37
41
  ParsedUsage.build(
38
42
  provider: provider_for(request_url),
39
- provider_response_id: detect_stream_response_id(events),
43
+ provider_response_id: response_id,
40
44
  model: model,
41
- input_tokens: (usage["prompt_tokens"] || usage["input_tokens"]).to_i,
45
+ input_tokens: regular_input_tokens(usage, cache_read),
42
46
  output_tokens: (usage["completion_tokens"] || usage["output_tokens"]).to_i,
43
47
  total_tokens: usage["total_tokens"].to_i,
44
- cached_input_tokens: cached_input_tokens(usage),
48
+ cache_read_input_tokens: cache_read,
49
+ hidden_output_tokens: hidden_output_tokens(usage),
45
50
  stream: true,
46
51
  usage_source: :stream_final
47
52
  )
48
53
  else
49
- ParsedUsage.build(
54
+ build_unknown_stream_usage(
50
55
  provider: provider_for(request_url),
51
- provider_response_id: detect_stream_response_id(events),
52
56
  model: model,
53
- input_tokens: 0,
54
- output_tokens: 0,
55
- total_tokens: 0,
56
- stream: true,
57
- usage_source: :unknown
57
+ provider_response_id: response_id
58
58
  )
59
59
  end
60
60
  end
61
61
 
62
62
  def detect_stream_usage(events)
63
- events.reverse_each do |event|
64
- data = event[:data]
65
- next unless data.is_a?(Hash)
66
-
63
+ find_event_value(events, reverse: true) do |data|
67
64
  usage = data["usage"]
68
- return usage if usage.is_a?(Hash) && !usage.empty?
65
+ usage if usage.is_a?(Hash)
69
66
  end
70
- nil
71
67
  end
72
68
 
73
69
  def detect_stream_model(events)
74
- events.each do |event|
75
- data = event[:data]
76
- next unless data.is_a?(Hash)
77
-
78
- model = data["model"]
79
- return model if model && !model.to_s.empty?
80
- end
81
- nil
70
+ find_event_value(events) { |data| data["model"] }
82
71
  end
83
72
 
84
73
  def detect_stream_response_id(events)
85
- events.each do |event|
86
- data = event[:data]
87
- next unless data.is_a?(Hash)
74
+ find_event_value(events) { |data| data["id"] || data.dig("response", "id") }
75
+ end
88
76
 
89
- id = data["id"] || data.dig("response", "id")
90
- return id if id && !id.to_s.empty?
91
- end
92
- nil
77
+ def regular_input_tokens(usage, cache_read)
78
+ [(usage["prompt_tokens"] || usage["input_tokens"]).to_i - cache_read.to_i, 0].max
93
79
  end
94
80
 
95
- def cached_input_tokens(usage)
81
+ def cache_read_input_tokens(usage)
96
82
  details = usage["prompt_tokens_details"] || usage["input_tokens_details"] || {}
97
83
  details["cached_tokens"]
98
84
  end
85
+
86
+ def hidden_output_tokens(usage)
87
+ details = usage["completion_tokens_details"] || usage["output_tokens_details"] || {}
88
+ details["reasoning_tokens"]
89
+ end
99
90
  end
100
91
  end
101
92
  end
@@ -13,10 +13,14 @@ module LlmCostTracker
13
13
  end
14
14
 
15
15
  def register(parser)
16
+ parser = coerce_parser(parser)
17
+
16
18
  MUTEX.synchronize do
17
19
  current = @parsers || default_parsers.freeze
18
20
  @parsers = ([parser] + current).freeze
19
21
  end
22
+
23
+ parser
20
24
  end
21
25
 
22
26
  def find_for(url)
@@ -24,8 +28,8 @@ module LlmCostTracker
24
28
  end
25
29
 
26
30
  def find_for_provider(provider)
27
- provider_name = provider.to_s
28
- parsers.find { |parser| parser.provider_names.include?(provider_name) }
31
+ provider_name = provider.to_s.downcase
32
+ parsers.find { |parser| provider_names_for(parser).include?(provider_name) }
29
33
  end
30
34
 
31
35
  def reset!
@@ -34,6 +38,17 @@ module LlmCostTracker
34
38
 
35
39
  private
36
40
 
41
+ def coerce_parser(parser)
42
+ return parser.new if parser.is_a?(Class) && parser <= Base
43
+ return parser if parser.is_a?(Base)
44
+
45
+ raise ArgumentError, "parser must be a LlmCostTracker::Parsers::Base instance or class"
46
+ end
47
+
48
+ def provider_names_for(parser)
49
+ Array(parser.provider_names).map { |name| name.to_s.downcase }
50
+ end
51
+
37
52
  def default_parsers
38
53
  [Openai.new, OpenaiCompatible.new, Anthropic.new, Gemini.new]
39
54
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class PeriodTotal < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_period_totals"
8
+ end
9
+ end
@@ -10,7 +10,7 @@ module LlmCostTracker
10
10
  module PriceRegistry
11
11
  DEFAULT_PRICES_PATH = File.expand_path("prices.json", __dir__)
12
12
  EMPTY_PRICES = {}.freeze
13
- PRICE_KEYS = %w[input cached_input output cache_read_input cache_creation_input].freeze
13
+ PRICE_KEYS = %w[input output cache_read_input cache_write_input].freeze
14
14
  METADATA_KEYS = %w[_source _source_version _fetched_at _updated _notes _validator_override].freeze
15
15
  MUTEX = Monitor.new
16
16
 
@@ -60,7 +60,7 @@ module LlmCostTracker
60
60
  def normalize_price_entry(price)
61
61
  price.each_with_object({}) do |(key, value), normalized|
62
62
  key = key.to_s
63
- normalized[key.to_sym] = Float(value) if PRICE_KEYS.include?(key)
63
+ normalized[key.to_sym] = Float(value) if price_key?(key)
64
64
  end
65
65
  end
66
66
 
@@ -80,15 +80,25 @@ module LlmCostTracker
80
80
  end
81
81
 
82
82
  def warn_unknown_keys(model, price, path)
83
- unknown_keys = price.keys.map(&:to_s) - PRICE_KEYS - METADATA_KEYS
83
+ unknown_keys = price.keys.map(&:to_s).reject do |key|
84
+ price_key?(key) || METADATA_KEYS.include?(key)
85
+ end
84
86
  return if unknown_keys.empty?
85
87
 
86
88
  Logging.warn(
87
89
  "Unknown price keys #{unknown_keys.inspect} for #{model.inspect} in #{path}; " \
88
- "ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}"
90
+ "ignored. Known keys: #{(PRICE_KEYS + METADATA_KEYS).inspect}; mode-specific keys use mode_input"
89
91
  )
90
92
  end
91
93
 
94
+ def price_key?(key)
95
+ return true if PRICE_KEYS.include?(key)
96
+
97
+ PRICE_KEYS.any? do |base_key|
98
+ key.end_with?("_#{base_key}") && key.delete_suffix("_#{base_key}") != ""
99
+ end
100
+ end
101
+
92
102
  def load_price_file(path)
93
103
  contents = File.read(path)
94
104
  return YAML.safe_load(contents, aliases: false) || {} if yaml_file?(path)
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  Discrepancy = Data.define(:model, :field, :values)
7
7
 
8
8
  PRIORITY_ORDER = %i[litellm openrouter].freeze
9
- SUPPLEMENTAL_FIELDS = %i[cached_input cache_read_input cache_creation_input].freeze
9
+ SUPPLEMENTAL_FIELDS = %i[cache_read_input cache_write_input].freeze
10
10
 
11
11
  def merge(results_by_source)
12
12
  prices = collect_prices(results_by_source)
@@ -7,24 +7,22 @@ module LlmCostTracker
7
7
  :provider,
8
8
  :input,
9
9
  :output,
10
- :cached_input,
11
10
  :cache_read_input,
12
- :cache_creation_input,
11
+ :cache_write_input,
13
12
  :source,
14
13
  :source_version,
15
14
  :fetched_at
16
15
  )
17
16
 
18
17
  class RawPrice
19
- PRICE_FIELDS = %w[input output cached_input cache_read_input cache_creation_input].freeze
18
+ PRICE_FIELDS = %w[input output cache_read_input cache_write_input].freeze
20
19
 
21
20
  def to_registry_entry(today:)
22
21
  {
23
22
  "input" => input,
24
23
  "output" => output,
25
- "cached_input" => cached_input,
26
24
  "cache_read_input" => cache_read_input,
27
- "cache_creation_input" => cache_creation_input,
25
+ "cache_write_input" => cache_write_input,
28
26
  "_source" => source.to_s,
29
27
  "_source_version" => source_version,
30
28
  "_fetched_at" => fetched_at || today.iso8601