llm_cost_tracker 0.9.0 → 0.11.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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +6 -2
  4. data/app/assets/llm_cost_tracker/application.css +782 -801
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
  16. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
  17. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
  18. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  19. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
  20. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  27. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  28. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  29. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
  30. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  31. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  32. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  33. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
  39. data/config/routes.rb +1 -0
  40. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  41. data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
  42. data/lib/llm_cost_tracker/budget.rb +29 -8
  43. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
  46. data/lib/llm_cost_tracker/configuration.rb +30 -44
  47. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  48. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
  49. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  51. data/lib/llm_cost_tracker/doctor.rb +80 -25
  52. data/lib/llm_cost_tracker/engine.rb +1 -2
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +47 -0
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
  57. 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
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  67. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
  69. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
  71. data/lib/llm_cost_tracker/ingestion.rb +8 -9
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
  73. data/lib/llm_cost_tracker/integrations/base.rb +14 -11
  74. data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
  76. data/lib/llm_cost_tracker/integrations.rb +14 -13
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
  79. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
  81. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
  82. data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
  83. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
  85. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
  86. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
  87. data/lib/llm_cost_tracker/ledger/store.rb +21 -18
  88. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
  89. data/lib/llm_cost_tracker/ledger.rb +13 -0
  90. data/lib/llm_cost_tracker/logging.rb +0 -4
  91. data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
  92. data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
  93. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  94. data/lib/llm_cost_tracker/parsers/base.rb +53 -47
  95. data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
  96. data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
  97. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
  98. data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
  99. data/lib/llm_cost_tracker/parsers.rb +29 -4
  100. data/lib/llm_cost_tracker/prices.json +567 -579
  101. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  102. data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
  103. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  104. data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
  105. data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
  106. data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
  107. data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
  108. data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
  109. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  110. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
  111. data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
  112. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  113. data/lib/llm_cost_tracker/pricing.rb +71 -43
  114. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  118. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  119. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  120. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
  121. data/lib/llm_cost_tracker/railtie.rb +3 -5
  122. data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
  123. data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
  124. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
  125. data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
  126. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
  127. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
  128. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
  129. data/lib/llm_cost_tracker/report/formatter.rb +32 -19
  130. data/lib/llm_cost_tracker/report.rb +0 -4
  131. data/lib/llm_cost_tracker/retention.rb +20 -8
  132. data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
  133. data/lib/llm_cost_tracker/token_usage.rb +4 -0
  134. data/lib/llm_cost_tracker/tracker.rb +33 -74
  135. data/lib/llm_cost_tracker/version.rb +1 -1
  136. data/lib/llm_cost_tracker.rb +11 -15
  137. data/lib/tasks/llm_cost_tracker.rake +16 -2
  138. metadata +31 -12
  139. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  140. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  141. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  142. data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
  143. data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
  144. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
  145. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -1,18 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../providers/anthropic/server_tools"
5
+ require_relative "../providers/anthropic/tier_classification"
4
6
 
5
7
  module LlmCostTracker
6
8
  module Parsers
7
9
  class Anthropic < Base
8
10
  HOSTS = %w[api.anthropic.com].freeze
9
11
 
10
- def match?(url)
11
- match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
12
- end
12
+ class << self
13
+ def match?(url)
14
+ match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
15
+ end
13
16
 
14
- def provider_names
15
- %w[anthropic]
17
+ def provider_names
18
+ %w[anthropic]
19
+ end
16
20
  end
17
21
 
18
22
  def parse(request_body:, response_status:, response_body:, **)
@@ -25,10 +29,10 @@ module LlmCostTracker
25
29
  request = safe_json_parse(request_body)
26
30
  cache_read = usage["cache_read_input_tokens"].to_i
27
31
 
28
- UsageCapture.build(
32
+ Event.build(
29
33
  provider: "anthropic",
30
34
  provider_response_id: response["id"],
31
- pricing_mode: pricing_mode(request: request, response: response, usage: usage),
35
+ pricing_mode: pricing_mode(request: request, usage: usage),
32
36
  model: response["model"] || request["model"],
33
37
  token_usage: token_usage(usage: usage, cache_read: cache_read),
34
38
  usage_source: :response,
@@ -49,14 +53,14 @@ module LlmCostTracker
49
53
  model: model,
50
54
  usage: usage,
51
55
  response_id: response_id,
52
- pricing_mode: pricing_mode(request: request, response: nil, usage: usage)
56
+ pricing_mode: pricing_mode(request: request, usage: usage)
53
57
  )
54
58
  else
55
59
  build_unknown_stream_usage(
56
60
  provider: "anthropic",
57
61
  model: model,
58
62
  provider_response_id: response_id,
59
- pricing_mode: pricing_mode(request: request, response: nil, usage: usage)
63
+ pricing_mode: pricing_mode(request: request, usage: usage)
60
64
  )
61
65
  end
62
66
  end
@@ -65,10 +69,6 @@ module LlmCostTracker
65
69
  "anthropic"
66
70
  end
67
71
 
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
72
  private
73
73
 
74
74
  def stream_usage(events)
@@ -89,7 +89,7 @@ module LlmCostTracker
89
89
  def build_stream_result(model:, usage:, response_id:, pricing_mode:)
90
90
  cache_read = usage["cache_read_input_tokens"].to_i
91
91
 
92
- UsageCapture.build(
92
+ Event.build(
93
93
  provider: "anthropic",
94
94
  provider_response_id: response_id,
95
95
  pricing_mode: pricing_mode,
@@ -105,36 +105,18 @@ module LlmCostTracker
105
105
  server_tool_use = usage["server_tool_use"]
106
106
  return [] unless server_tool_use.is_a?(Hash)
107
107
 
108
- [
109
- service_line_item(
110
- component_key: :web_search_request,
111
- quantity: server_tool_use["web_search_requests"],
112
- provider_field: "usage.server_tool_use.web_search_requests"
113
- ),
114
- service_line_item(
115
- component_key: :web_fetch_request,
116
- quantity: server_tool_use["web_fetch_requests"],
117
- provider_field: "usage.server_tool_use.web_fetch_requests"
118
- ),
119
- service_line_item(
120
- component_key: :code_execution_request,
121
- quantity: server_tool_use["code_execution_requests"],
122
- provider_field: "usage.server_tool_use.code_execution_requests"
123
- )
124
- ].compact
125
- end
126
-
127
- def service_line_item(component_key:, quantity:, provider_field:)
128
- quantity = quantity.to_i
129
- return if quantity.zero?
108
+ Providers::Anthropic::ServerTools::LINE_ITEMS.filter_map do |component_key, count_key|
109
+ quantity = server_tool_use[count_key.to_s].to_i
110
+ next if quantity.zero?
130
111
 
131
- Billing::LineItem.build(
132
- component_key: component_key,
133
- quantity: quantity,
134
- cost_status: Billing::CostStatus::UNKNOWN,
135
- pricing_basis: :provider_usage,
136
- provider_field: provider_field
137
- )
112
+ Billing::LineItem.build(
113
+ component_key: component_key,
114
+ quantity: quantity,
115
+ cost_status: Billing::CostStatus::UNKNOWN,
116
+ pricing_basis: :provider_usage,
117
+ provider_field: "usage.server_tool_use.#{count_key}"
118
+ )
119
+ end
138
120
  end
139
121
 
140
122
  def token_usage(usage:, cache_read:)
@@ -171,29 +153,23 @@ module LlmCostTracker
171
153
  Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
172
154
  end
173
155
 
174
- def pricing_mode(request:, response:, usage:)
156
+ def pricing_mode(request:, usage:)
175
157
  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)
158
+ speed = usage&.fetch("speed", nil) || request["speed"]
159
+ service_tier = usage&.fetch("service_tier", nil) || request["service_tier"]
160
+ service_tier = nil if Providers::Anthropic::TierClassification.standard_equivalent_tier?(service_tier)
181
161
 
182
- modes << Pricing.normalize_mode(speed)
183
- 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)
162
+ modes << Pricing::Mode.normalize(speed)
163
+ modes << Pricing::Mode.normalize(service_tier)
164
+ geo = inference_geo(request: request, usage: usage).downcase
165
+ modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
186
166
 
187
167
  modes = modes.compact.uniq
188
168
  modes.empty? ? nil : modes.join("_")
189
169
  end
190
170
 
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
171
+ def inference_geo(request:, usage:)
172
+ (usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
197
173
  end
198
174
  end
199
175
  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,
@@ -146,9 +152,7 @@ module LlmCostTracker
146
152
 
147
153
  def modality_tokens(details, modality)
148
154
  Array(details).sum do |detail|
149
- next 0 unless detail.is_a?(Hash)
150
-
151
- next 0 unless detail["modality"] == modality
155
+ next 0 unless detail.is_a?(Hash) && detail["modality"] == modality
152
156
 
153
157
  (detail["tokenCount"] || detail["token_count"]).to_i
154
158
  end
@@ -168,10 +172,10 @@ module LlmCostTracker
168
172
 
169
173
  def pricing_mode(request:, response_headers:)
170
174
  response_tier = response_header(response_headers, "x-gemini-service-tier")
171
- response_mode = Pricing.normalize_mode(response_tier)
175
+ response_mode = Pricing::Mode.normalize(response_tier)
172
176
  return response_mode if response_mode
173
177
 
174
- request_mode = Pricing.normalize_mode(
178
+ request_mode = Pricing::Mode.normalize(
175
179
  request["service_tier"] ||
176
180
  request["serviceTier"] ||
177
181
  request.dig("config", "service_tier") ||
@@ -184,10 +188,6 @@ module LlmCostTracker
184
188
  headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
185
189
  end
186
190
 
187
- def grounding_line_items_for_response(response, model:)
188
- grounding_line_items(grounding_request_count(response["candidates"]), model: model)
189
- end
190
-
191
191
  def grounding_line_items_for_stream(events, model:)
192
192
  quantity = find_event_value(events, reverse: true) do |data|
193
193
  count = grounding_request_count(data["candidates"])
@@ -223,11 +223,7 @@ module LlmCostTracker
223
223
  end
224
224
 
225
225
  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)
226
+ LlmCostTracker::Providers::Gemini::ModelFamilies.per_query_grounding?(model) ? query_count : 1
231
227
  end
232
228
  end
233
229
  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,35 @@ 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
16
-
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)
21
-
22
- names = [
23
- "openai_compatible",
24
- *providers.each_value.map { |provider| provider.to_s.downcase }
25
- ].uniq.freeze
26
- return names unless providers.frozen?
27
-
28
- @provider_names_providers = providers
29
- @provider_names = names
30
- end
31
-
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
40
-
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
49
-
50
- def auto_enable_stream_usage?(request_url)
51
- openai_chat_completions_url?(request_url)
13
+ class << self
14
+ def match?(url)
15
+ match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
16
+ end
17
+
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)
22
+
23
+ names = [
24
+ "openai_compatible",
25
+ *providers.each_value.map { |provider| provider.to_s.downcase }
26
+ ].uniq.freeze
27
+ return names unless providers.frozen?
28
+
29
+ @provider_names_providers = providers
30
+ @provider_names = names
31
+ end
32
+
33
+ def provider_for_uri(uri)
34
+ return nil unless uri
35
+
36
+ LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
37
+ end
52
38
  end
53
39
 
54
40
  def provider_for(request_url)
55
- uri = parsed_uri(request_url)
56
- provider_for_uri(uri) || "openai_compatible"
57
- end
58
-
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
41
+ self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
65
42
  end
66
43
  end
67
44
  end