llm_cost_tracker 0.9.0 → 0.10.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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/README.md +2 -1
  4. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
  6. data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
  7. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  8. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  9. data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
  10. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  11. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  12. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  13. data/lib/llm_cost_tracker/budget.rb +28 -6
  14. data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
  15. data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
  16. data/lib/llm_cost_tracker/configuration.rb +31 -28
  17. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  18. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  19. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  20. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  21. data/lib/llm_cost_tracker/doctor.rb +6 -17
  22. data/lib/llm_cost_tracker/engine.rb +1 -2
  23. data/lib/llm_cost_tracker/errors.rb +3 -2
  24. data/lib/llm_cost_tracker/event.rb +47 -0
  25. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  26. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  27. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
  28. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  29. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  37. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  38. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
  39. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  40. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  41. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
  43. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  44. data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
  45. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
  46. data/lib/llm_cost_tracker/integrations.rb +14 -13
  47. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  48. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  49. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  50. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  51. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  52. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  53. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  54. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  55. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  56. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  57. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  58. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  59. data/lib/llm_cost_tracker/logging.rb +0 -4
  60. data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
  61. data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
  62. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  63. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  64. data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
  65. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  66. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
  67. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
  68. data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
  69. data/lib/llm_cost_tracker/parsers.rb +31 -4
  70. data/lib/llm_cost_tracker/prices.json +567 -579
  71. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  72. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  73. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  74. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  75. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  76. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  77. data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
  78. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  79. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  80. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  81. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  82. data/lib/llm_cost_tracker/pricing.rb +72 -27
  83. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  84. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  85. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  86. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  87. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  88. data/lib/llm_cost_tracker/railtie.rb +3 -1
  89. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  90. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  91. data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
  92. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
  93. data/lib/llm_cost_tracker/report.rb +0 -4
  94. data/lib/llm_cost_tracker/retention.rb +20 -8
  95. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  96. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  97. data/lib/llm_cost_tracker/tracker.rb +33 -74
  98. data/lib/llm_cost_tracker/version.rb +1 -1
  99. data/lib/llm_cost_tracker.rb +11 -15
  100. data/lib/tasks/llm_cost_tracker.rake +16 -2
  101. metadata +18 -7
  102. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  103. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  104. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -18,16 +18,25 @@ module LlmCostTracker
18
18
  end
19
19
 
20
20
  def call(request_env)
21
- return @app.call(request_env) unless LlmCostTracker.configuration.enabled
21
+ return @app.call(request_env) unless enabled?
22
22
 
23
23
  request_url = request_env.url.to_s
24
24
  request_body = read_body(request_env.body)
25
25
  parser = Parsers.find_for(request_url)
26
- streaming = parser&.streaming_request?(request_url, request_body)
27
- request_body = inject_stream_usage_flag(request_env, parser, request_url) if streaming
26
+ request_parsed = parser ? safe_json_parse(request_body) : nil
27
+ streaming = parser&.streaming_request?(request_url, request_parsed)
28
+ if streaming
29
+ request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
30
+ end
28
31
  stream_buffer = install_stream_tap(request_env) if streaming
29
32
 
30
- Tracker.enforce_budget! if parser
33
+ if parser
34
+ Tracker.enforce_budget!(
35
+ provider: parser.provider_for(request_url),
36
+ model: parser.model_for(request_url, request_parsed),
37
+ request: request_parsed
38
+ )
39
+ end
31
40
  context_tags, metadata = tag_snapshot(request_env) if parser
32
41
  started_at = LlmCostTracker::Timing.now_monotonic
33
42
 
@@ -40,6 +49,26 @@ module LlmCostTracker
40
49
 
41
50
  private
42
51
 
52
+ def enabled?
53
+ return @enabled if defined?(@enabled)
54
+
55
+ @enabled = LlmCostTracker.configuration.enabled
56
+ end
57
+
58
+ def safe_json_parse(body)
59
+ return {} if body.nil? || body.empty?
60
+
61
+ JSON.parse(body)
62
+ rescue JSON::ParserError
63
+ {}
64
+ end
65
+
66
+ def auto_enable_stream_usage?
67
+ return @auto_enable_stream_usage if defined?(@auto_enable_stream_usage)
68
+
69
+ @auto_enable_stream_usage = LlmCostTracker.configuration.auto_enable_stream_usage
70
+ end
71
+
43
72
  def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
44
73
  stream_buffer:, context_tags:, metadata:, started_at:)
45
74
  response_received = false
@@ -63,16 +92,15 @@ module LlmCostTracker
63
92
  raise
64
93
  end
65
94
 
66
- def inject_stream_usage_flag(request_env, parser, request_url)
67
- body_string = read_body(request_env.body)
68
- return body_string unless LlmCostTracker.configuration.auto_enable_stream_usage
69
- return body_string unless parser&.auto_enable_stream_usage?(request_url)
95
+ def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
96
+ return nil unless auto_enable_stream_usage?
97
+ return nil unless parser&.auto_enable_stream_usage?(request_url)
70
98
 
71
- body = JSON.parse(body_string)
72
- return body_string if body["stream_options"].is_a?(Hash) && body["stream_options"].key?("include_usage")
99
+ stream_options = request_parsed["stream_options"]
100
+ return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
73
101
 
74
- body["stream_options"] = (body["stream_options"] || {}).merge("include_usage" => true)
75
- new_body = body.to_json
102
+ request_parsed["stream_options"] = (stream_options || {}).merge("include_usage" => true)
103
+ new_body = request_parsed.to_json
76
104
  request_env.body = new_body
77
105
  new_body
78
106
  end
@@ -80,9 +108,9 @@ module LlmCostTracker
80
108
  def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
81
109
  context_tags:, metadata:, error:)
82
110
  request = parser.safe_json_parse(request_body)
83
- capture = UsageCapture.build(
111
+ event = Event.build(
84
112
  provider: parser.provider_for(request_url),
85
- model: request["model"] || UsageCapture::UNKNOWN_MODEL,
113
+ model: request["model"] || Event::UNKNOWN_MODEL,
86
114
  token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
87
115
  stream: true,
88
116
  usage_source: :unknown
@@ -92,7 +120,7 @@ module LlmCostTracker
92
120
  stream_interrupted_error: "#{error.class}: #{error.message}"
93
121
  )
94
122
  Tracker.record(
95
- capture: capture,
123
+ event: event,
96
124
  latency_ms: latency_ms,
97
125
  metadata: merged_metadata,
98
126
  context_tags: context_tags
@@ -125,7 +153,7 @@ module LlmCostTracker
125
153
  return unless parsed
126
154
 
127
155
  Tracker.record(
128
- capture: parsed,
156
+ event: parsed,
129
157
  latency_ms: latency_ms,
130
158
  metadata: metadata,
131
159
  context_tags: context_tags
@@ -1,18 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../providers/anthropic/tier_classification"
4
5
 
5
6
  module LlmCostTracker
6
7
  module Parsers
7
8
  class Anthropic < Base
8
9
  HOSTS = %w[api.anthropic.com].freeze
9
10
 
10
- def match?(url)
11
- match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
12
- end
11
+ class << self
12
+ def match?(url)
13
+ match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
14
+ end
13
15
 
14
- def provider_names
15
- %w[anthropic]
16
+ def provider_names
17
+ %w[anthropic]
18
+ end
16
19
  end
17
20
 
18
21
  def parse(request_body:, response_status:, response_body:, **)
@@ -25,10 +28,10 @@ module LlmCostTracker
25
28
  request = safe_json_parse(request_body)
26
29
  cache_read = usage["cache_read_input_tokens"].to_i
27
30
 
28
- UsageCapture.build(
31
+ Event.build(
29
32
  provider: "anthropic",
30
33
  provider_response_id: response["id"],
31
- pricing_mode: pricing_mode(request: request, response: response, usage: usage),
34
+ pricing_mode: pricing_mode(request: request, usage: usage),
32
35
  model: response["model"] || request["model"],
33
36
  token_usage: token_usage(usage: usage, cache_read: cache_read),
34
37
  usage_source: :response,
@@ -49,14 +52,14 @@ module LlmCostTracker
49
52
  model: model,
50
53
  usage: usage,
51
54
  response_id: response_id,
52
- pricing_mode: pricing_mode(request: request, response: nil, usage: usage)
55
+ pricing_mode: pricing_mode(request: request, usage: usage)
53
56
  )
54
57
  else
55
58
  build_unknown_stream_usage(
56
59
  provider: "anthropic",
57
60
  model: model,
58
61
  provider_response_id: response_id,
59
- pricing_mode: pricing_mode(request: request, response: nil, usage: usage)
62
+ pricing_mode: pricing_mode(request: request, usage: usage)
60
63
  )
61
64
  end
62
65
  end
@@ -65,10 +68,6 @@ module LlmCostTracker
65
68
  "anthropic"
66
69
  end
67
70
 
68
- DATA_RESIDENCY_GEOS = %w[us].freeze
69
- STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
70
- private_constant :DATA_RESIDENCY_GEOS, :STANDARD_EQUIVALENT_SERVICE_TIERS
71
-
72
71
  private
73
72
 
74
73
  def stream_usage(events)
@@ -89,7 +88,7 @@ module LlmCostTracker
89
88
  def build_stream_result(model:, usage:, response_id:, pricing_mode:)
90
89
  cache_read = usage["cache_read_input_tokens"].to_i
91
90
 
92
- UsageCapture.build(
91
+ Event.build(
93
92
  provider: "anthropic",
94
93
  provider_response_id: response_id,
95
94
  pricing_mode: pricing_mode,
@@ -171,29 +170,23 @@ module LlmCostTracker
171
170
  Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
172
171
  end
173
172
 
174
- def pricing_mode(request:, response:, usage:)
173
+ def pricing_mode(request:, usage:)
175
174
  modes = []
176
- speed = usage&.fetch("speed", nil) || response&.fetch("speed", nil) || request["speed"]
177
- service_tier = usage&.fetch("service_tier", nil) ||
178
- response&.fetch("service_tier", nil) ||
179
- request["service_tier"]
180
- service_tier = nil if STANDARD_EQUIVALENT_SERVICE_TIERS.include?(service_tier.to_s)
175
+ speed = usage&.fetch("speed", nil) || request["speed"]
176
+ service_tier = usage&.fetch("service_tier", nil) || request["service_tier"]
177
+ service_tier = nil if Providers::Anthropic::TierClassification.standard_equivalent_tier?(service_tier)
181
178
 
182
179
  modes << Pricing.normalize_mode(speed)
183
180
  modes << Pricing.normalize_mode(service_tier)
184
- geo = inference_geo(request: request, response: response, usage: usage).downcase
185
- modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
181
+ geo = inference_geo(request: request, usage: usage).downcase
182
+ modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
186
183
 
187
184
  modes = modes.compact.uniq
188
185
  modes.empty? ? nil : modes.join("_")
189
186
  end
190
187
 
191
- def inference_geo(request:, response:, usage:)
192
- (
193
- usage&.fetch("inference_geo", nil) ||
194
- response&.fetch("inference_geo", nil) ||
195
- request["inference_geo"]
196
- ).to_s
188
+ def inference_geo(request:, usage:)
189
+ (usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
197
190
  end
198
191
  end
199
192
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "openai_usage"
5
+ require_relative "../providers/azure/hosts"
6
+
7
+ module LlmCostTracker
8
+ module Parsers
9
+ class Azure < Base
10
+ include OpenaiUsage
11
+
12
+ TRACKED_ENDPOINTS = %w[
13
+ chat/completions completions embeddings moderations responses
14
+ audio/transcriptions audio/translations audio/speech
15
+ images/generations images/edits images/variations
16
+ ].freeze
17
+
18
+ PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
19
+
20
+ class << self
21
+ def match?(url)
22
+ uri_matches?(url) do |uri|
23
+ LlmCostTracker::Providers::Azure::Hosts.openai?(uri.host) && uri.path.to_s.match?(PATH_PATTERN)
24
+ end
25
+ end
26
+
27
+ def provider_names
28
+ %w[azure_openai]
29
+ end
30
+ end
31
+
32
+ def provider_for(_request_url)
33
+ "azure_openai"
34
+ end
35
+
36
+ def model_for(request_url, request_parsed)
37
+ body_model = super
38
+ return body_model if body_model
39
+
40
+ uri = parsed_uri(request_url)
41
+ match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
42
+ match && match[1]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -6,51 +6,7 @@ require "uri"
6
6
 
7
7
  module LlmCostTracker
8
8
  module Parsers
9
- class Base
10
- def parse(**)
11
- raise NotImplementedError
12
- end
13
-
14
- def provider_names
15
- []
16
- end
17
-
18
- def match?(url)
19
- raise NotImplementedError
20
- end
21
-
22
- def streaming_request?(_request_url, request_body)
23
- return false if request_body.blank?
24
-
25
- body = request_body.to_s
26
-
27
- request = safe_json_parse(body)
28
- request.is_a?(Hash) && request["stream"] == true
29
- end
30
-
31
- def parse_stream(**)
32
- nil
33
- end
34
-
35
- def auto_enable_stream_usage?(_request_url)
36
- false
37
- end
38
-
39
- def safe_json_parse(body)
40
- return {} if body.blank?
41
-
42
- JSON.parse(body)
43
- rescue JSON::ParserError
44
- {}
45
- end
46
-
47
- private
48
-
49
- def uri_matches?(url)
50
- uri = parsed_uri(url)
51
- uri ? yield(uri) : false
52
- end
53
-
9
+ module UrlMatchers
54
10
  def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
55
11
  uri_matches?(url) do |uri|
56
12
  host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
@@ -70,6 +26,11 @@ module LlmCostTracker
70
26
  end
71
27
  end
72
28
 
29
+ def uri_matches?(url)
30
+ uri = parsed_uri(url)
31
+ uri ? yield(uri) : false
32
+ end
33
+
73
34
  def parsed_uri(url)
74
35
  URI.parse(url.to_s)
75
36
  rescue URI::InvalidURIError
@@ -88,6 +49,51 @@ module LlmCostTracker
88
49
 
89
50
  matches
90
51
  end
52
+ end
53
+
54
+ class Base
55
+ extend UrlMatchers
56
+ include UrlMatchers
57
+
58
+ class << self
59
+ def match?(_url)
60
+ raise NotImplementedError
61
+ end
62
+
63
+ def provider_names
64
+ []
65
+ end
66
+ end
67
+
68
+ def parse(**)
69
+ raise NotImplementedError
70
+ end
71
+
72
+ def streaming_request?(_request_url, request_parsed)
73
+ request_parsed.is_a?(Hash) && request_parsed["stream"] == true
74
+ end
75
+
76
+ def model_for(_request_url, request_parsed)
77
+ request_parsed["model"] if request_parsed.is_a?(Hash)
78
+ end
79
+
80
+ def parse_stream(**)
81
+ nil
82
+ end
83
+
84
+ def auto_enable_stream_usage?(_request_url)
85
+ false
86
+ end
87
+
88
+ def safe_json_parse(body)
89
+ return {} if body.blank?
90
+
91
+ JSON.parse(body)
92
+ rescue JSON::ParserError
93
+ {}
94
+ end
95
+
96
+ private
91
97
 
92
98
  def each_event_data(events, reverse: false)
93
99
  enumerator = reverse ? events.reverse_each : events.each
@@ -109,11 +115,11 @@ module LlmCostTracker
109
115
 
110
116
  def build_unknown_stream_usage(provider:, model:, provider_response_id:, pricing_mode: nil,
111
117
  service_line_items: nil)
112
- UsageCapture.build(
118
+ Event.build(
113
119
  provider: provider,
114
120
  provider_response_id: provider_response_id,
115
121
  pricing_mode: pricing_mode,
116
- model: model || UsageCapture::UNKNOWN_MODEL,
122
+ model: model || Event::UNKNOWN_MODEL,
117
123
  token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
118
124
  stream: true,
119
125
  usage_source: :unknown,
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../billing/line_item"
4
4
  require_relative "base"
5
+ require_relative "../providers/gemini/model_families"
5
6
 
6
7
  module LlmCostTracker
7
8
  module Parsers
@@ -9,17 +10,18 @@ module LlmCostTracker
9
10
  HOSTS = %w[generativelanguage.googleapis.com].freeze
10
11
  TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
11
12
  STREAM_PATH_PATTERN = /:streamGenerateContent\z/
12
- PER_QUERY_GROUNDING_MODEL_PATTERN = /\bgemini-(?:[3-9]|[1-9]\d)\b/i
13
13
 
14
- def match?(url)
15
- match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
16
- end
14
+ class << self
15
+ def match?(url)
16
+ match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
17
+ end
17
18
 
18
- def provider_names
19
- %w[gemini]
19
+ def provider_names
20
+ %w[gemini]
21
+ end
20
22
  end
21
23
 
22
- def streaming_request?(request_url, request_body)
24
+ def streaming_request?(request_url, request_parsed)
23
25
  return true if match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
24
26
 
25
27
  super
@@ -34,13 +36,13 @@ module LlmCostTracker
34
36
 
35
37
  request = safe_json_parse(request_body)
36
38
  model = extract_model_from_url(request_url)
37
- build_usage_capture(
39
+ build_event(
38
40
  request_url: request_url,
39
41
  usage: usage,
40
42
  usage_source: :response,
41
43
  provider_response_id: response["responseId"],
42
44
  pricing_mode: pricing_mode(request: request, response_headers: response_headers),
43
- service_line_items: grounding_line_items_for_response(response, model: model)
45
+ service_line_items: grounding_line_items(grounding_request_count(response["candidates"]), model: model)
44
46
  )
45
47
  end
46
48
 
@@ -55,7 +57,7 @@ module LlmCostTracker
55
57
  service_line_items = grounding_line_items_for_stream(events, model: model)
56
58
 
57
59
  if usage
58
- build_usage_capture(
60
+ build_event(
59
61
  request_url: request_url,
60
62
  usage: usage,
61
63
  stream: true,
@@ -75,20 +77,24 @@ module LlmCostTracker
75
77
  end
76
78
  end
77
79
 
80
+ def model_for(request_url, _request_parsed)
81
+ extract_model_from_url(request_url)
82
+ end
83
+
78
84
  def provider_for(_request_url)
79
85
  "gemini"
80
86
  end
81
87
 
82
88
  private
83
89
 
84
- def build_usage_capture(request_url:, usage:, usage_source:, stream: false, provider_response_id: nil,
85
- pricing_mode: nil, service_line_items: nil)
90
+ def build_event(request_url:, usage:, usage_source:, stream: false, provider_response_id: nil,
91
+ pricing_mode: nil, service_line_items: nil)
86
92
  cache_read = usage["cachedContentTokenCount"].to_i
87
93
  tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
88
94
  audio_input = audio_input_tokens(usage)
89
95
  audio_output = audio_output_tokens(usage)
90
96
 
91
- UsageCapture.build(
97
+ Event.build(
92
98
  provider: "gemini",
93
99
  model: extract_model_from_url(request_url),
94
100
  pricing_mode: pricing_mode,
@@ -184,10 +190,6 @@ module LlmCostTracker
184
190
  headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
185
191
  end
186
192
 
187
- def grounding_line_items_for_response(response, model:)
188
- grounding_line_items(grounding_request_count(response["candidates"]), model: model)
189
- end
190
-
191
193
  def grounding_line_items_for_stream(events, model:)
192
194
  quantity = find_event_value(events, reverse: true) do |data|
193
195
  count = grounding_request_count(data["candidates"])
@@ -223,11 +225,7 @@ module LlmCostTracker
223
225
  end
224
226
 
225
227
  def grounding_billed_quantity(query_count, model:)
226
- per_query_billing?(model) ? query_count : 1
227
- end
228
-
229
- def per_query_billing?(model)
230
- model.to_s.match?(PER_QUERY_GROUNDING_MODEL_PATTERN)
228
+ LlmCostTracker::Providers::Gemini::ModelFamilies.per_query_grounding?(model) ? query_count : 1
231
229
  end
232
230
  end
233
231
  end
@@ -2,25 +2,13 @@
2
2
 
3
3
  require_relative "base"
4
4
  require_relative "openai_usage"
5
+ require_relative "../providers/openai/hosts"
5
6
 
6
7
  module LlmCostTracker
7
8
  module Parsers
8
9
  class Openai < Base
9
10
  include OpenaiUsage
10
11
 
11
- HOSTS = %w[
12
- api.openai.com
13
- us.api.openai.com
14
- eu.api.openai.com
15
- au.api.openai.com
16
- ca.api.openai.com
17
- jp.api.openai.com
18
- in.api.openai.com
19
- sg.api.openai.com
20
- kr.api.openai.com
21
- gb.api.openai.com
22
- ae.api.openai.com
23
- ].freeze
24
12
  TRACKED_PATHS = %w[
25
13
  /v1/chat/completions
26
14
  /v1/completions
@@ -35,34 +23,14 @@ module LlmCostTracker
35
23
  /v1/moderations
36
24
  ].freeze
37
25
 
38
- def match?(url)
39
- match_uri?(url, hosts: HOSTS, exact_paths: TRACKED_PATHS)
40
- end
41
-
42
- def provider_names
43
- %w[openai]
44
- end
45
-
46
- def parse(request_url:, request_body:, response_status:, response_body:, **)
47
- parse_openai_usage(
48
- request_url: request_url,
49
- request_body: request_body,
50
- response_status: response_status,
51
- response_body: response_body
52
- )
53
- end
54
-
55
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
56
- parse_openai_stream_usage(
57
- request_url: request_url,
58
- request_body: request_body,
59
- response_status: response_status,
60
- events: events
61
- )
62
- end
26
+ class << self
27
+ def match?(url)
28
+ match_uri?(url, hosts: Providers::Openai::Hosts::API_HOSTS, exact_paths: TRACKED_PATHS)
29
+ end
63
30
 
64
- def auto_enable_stream_usage?(request_url)
65
- openai_chat_completions_url?(request_url)
31
+ def provider_names
32
+ %w[openai]
33
+ end
66
34
  end
67
35
 
68
36
  def provider_for(_request_url)
@@ -10,58 +10,41 @@ module LlmCostTracker
10
10
 
11
11
  TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
12
12
 
13
- def match?(url)
14
- match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
15
- end
13
+ class << self
14
+ def match?(url)
15
+ match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
16
+ end
16
17
 
17
- def provider_names
18
- providers = LlmCostTracker.configuration.openai_compatible_providers
19
- cached = @provider_names
20
- return cached if cached && @provider_names_providers.equal?(providers)
18
+ def provider_names
19
+ providers = LlmCostTracker.configuration.openai_compatible_providers
20
+ cached = @provider_names
21
+ return cached if cached && @provider_names_providers.equal?(providers)
21
22
 
22
- names = [
23
- "openai_compatible",
24
- *providers.each_value.map { |provider| provider.to_s.downcase }
25
- ].uniq.freeze
26
- return names unless providers.frozen?
23
+ names = [
24
+ "openai_compatible",
25
+ *providers.each_value.map { |provider| provider.to_s.downcase }
26
+ ].uniq.freeze
27
+ return names unless providers.frozen?
27
28
 
28
- @provider_names_providers = providers
29
- @provider_names = names
30
- end
29
+ @provider_names_providers = providers
30
+ @provider_names = names
31
+ end
31
32
 
32
- def parse(request_url:, request_body:, response_status:, response_body:, **)
33
- parse_openai_usage(
34
- request_url: request_url,
35
- request_body: request_body,
36
- response_status: response_status,
37
- response_body: response_body
38
- )
39
- end
33
+ def provider_for(request_url)
34
+ provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
35
+ end
40
36
 
41
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
42
- parse_openai_stream_usage(
43
- request_url: request_url,
44
- request_body: request_body,
45
- response_status: response_status,
46
- events: events
47
- )
48
- end
37
+ private
49
38
 
50
- def auto_enable_stream_usage?(request_url)
51
- openai_chat_completions_url?(request_url)
52
- end
39
+ def provider_for_uri(uri)
40
+ return nil unless uri
53
41
 
54
- def provider_for(request_url)
55
- uri = parsed_uri(request_url)
56
- provider_for_uri(uri) || "openai_compatible"
42
+ LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
43
+ end
57
44
  end
58
45
 
59
- private
60
-
61
- def provider_for_uri(uri)
62
- return nil unless uri
63
-
64
- LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
46
+ def provider_for(request_url)
47
+ self.class.provider_for(request_url)
65
48
  end
66
49
  end
67
50
  end