llm_cost_tracker 0.8.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +14 -6
  4. data/app/assets/llm_cost_tracker/application.css +65 -5
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
  6. data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
  9. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
  12. data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
  13. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
  15. data/app/models/llm_cost_tracker/call.rb +0 -3
  16. data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
  17. data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
  18. data/app/models/llm_cost_tracker/call_tag.rb +0 -4
  19. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
  20. data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
  21. data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
  22. data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
  26. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
  27. data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
  28. data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
  29. data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
  30. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
  31. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
  32. data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
  34. data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
  35. data/config/routes.rb +3 -2
  36. data/lib/llm_cost_tracker/billing/components.rb +45 -3
  37. data/lib/llm_cost_tracker/billing/components.yml +71 -0
  38. data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
  39. data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
  40. data/lib/llm_cost_tracker/budget.rb +31 -7
  41. data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
  42. data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
  43. data/lib/llm_cost_tracker/configuration.rb +72 -17
  44. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
  45. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
  46. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
  47. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
  48. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
  49. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
  50. data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
  51. data/lib/llm_cost_tracker/doctor.rb +72 -14
  52. data/lib/llm_cost_tracker/engine.rb +8 -0
  53. data/lib/llm_cost_tracker/errors.rb +3 -2
  54. data/lib/llm_cost_tracker/event.rb +48 -1
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
  68. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
  69. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
  74. data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
  75. data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
  76. data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
  77. data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
  78. data/lib/llm_cost_tracker/ingestion.rb +48 -11
  79. data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
  80. data/lib/llm_cost_tracker/integrations/base.rb +35 -15
  81. data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
  82. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
  83. data/lib/llm_cost_tracker/integrations.rb +33 -14
  84. data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
  85. data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
  86. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
  87. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
  88. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
  89. data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
  90. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
  91. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
  92. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
  93. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
  94. data/lib/llm_cost_tracker/ledger/store.rb +34 -31
  95. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
  96. data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
  97. data/lib/llm_cost_tracker/ledger.rb +2 -1
  98. data/lib/llm_cost_tracker/logging.rb +0 -4
  99. data/lib/llm_cost_tracker/masking.rb +39 -0
  100. data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
  101. data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
  102. data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
  103. data/lib/llm_cost_tracker/parsers/base.rb +53 -43
  104. data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
  105. data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
  106. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
  107. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
  108. data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
  109. data/lib/llm_cost_tracker/parsers.rb +31 -4
  110. data/lib/llm_cost_tracker/prices.json +572 -493
  111. data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
  112. data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
  113. data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
  114. data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
  115. data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
  116. data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
  117. data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
  118. data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
  119. data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
  121. data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
  123. data/lib/llm_cost_tracker/pricing.rb +117 -44
  124. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
  125. data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
  126. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
  127. data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
  128. data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
  129. data/lib/llm_cost_tracker/railtie.rb +8 -0
  130. data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
  131. data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
  132. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
  133. data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
  134. data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
  135. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
  136. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
  137. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
  138. data/lib/llm_cost_tracker/reconciliation.rb +118 -0
  139. data/lib/llm_cost_tracker/report/data.rb +4 -1
  140. data/lib/llm_cost_tracker/report.rb +0 -4
  141. data/lib/llm_cost_tracker/retention.rb +31 -6
  142. data/lib/llm_cost_tracker/tags/context.rb +3 -4
  143. data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
  144. data/lib/llm_cost_tracker/token_usage.rb +14 -2
  145. data/lib/llm_cost_tracker/tracker.rb +41 -55
  146. data/lib/llm_cost_tracker/version.rb +1 -1
  147. data/lib/llm_cost_tracker.rb +19 -14
  148. data/lib/tasks/llm_cost_tracker.rake +41 -4
  149. metadata +49 -3
  150. data/lib/llm_cost_tracker/usage_capture.rb +0 -58
@@ -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,39 +52,43 @@ 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
63
66
 
67
+ def provider_for(_request_url)
68
+ "anthropic"
69
+ end
70
+
64
71
  private
65
72
 
66
73
  def stream_usage(events)
67
- start_usage = find_event_value(events, reverse: true) do |data|
68
- data.dig("message", "usage") if data["type"] == "message_start"
69
- end
70
74
  latest_delta = find_event_value(events, reverse: true) do |data|
71
75
  data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
72
76
  end
77
+ return nil unless latest_delta
73
78
 
74
- return nil unless start_usage || latest_delta
79
+ start_usage = find_event_value(events, reverse: true) do |data|
80
+ data.dig("message", "usage") if data["type"] == "message_start"
81
+ end
75
82
 
76
- (start_usage || {}).merge(latest_delta || {}) do |_key, start_val, delta_val|
77
- delta_val.nil? ? start_val : delta_val
83
+ (start_usage || {}).merge(latest_delta) do |_key, start_val, delta_val|
84
+ delta_val || start_val
78
85
  end
79
86
  end
80
87
 
81
88
  def build_stream_result(model:, usage:, response_id:, pricing_mode:)
82
89
  cache_read = usage["cache_read_input_tokens"].to_i
83
90
 
84
- UsageCapture.build(
91
+ Event.build(
85
92
  provider: "anthropic",
86
93
  provider_response_id: response_id,
87
94
  pricing_mode: pricing_mode,
@@ -103,6 +110,11 @@ module LlmCostTracker
103
110
  quantity: server_tool_use["web_search_requests"],
104
111
  provider_field: "usage.server_tool_use.web_search_requests"
105
112
  ),
113
+ service_line_item(
114
+ component_key: :web_fetch_request,
115
+ quantity: server_tool_use["web_fetch_requests"],
116
+ provider_field: "usage.server_tool_use.web_fetch_requests"
117
+ ),
106
118
  service_line_item(
107
119
  component_key: :code_execution_request,
108
120
  quantity: server_tool_use["code_execution_requests"],
@@ -158,27 +170,23 @@ module LlmCostTracker
158
170
  Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
159
171
  end
160
172
 
161
- def pricing_mode(request:, response:, usage:)
173
+ def pricing_mode(request:, usage:)
162
174
  modes = []
163
- speed = usage&.fetch("speed", nil) || response&.fetch("speed", nil) || request["speed"]
164
- service_tier = usage&.fetch("service_tier", nil) ||
165
- response&.fetch("service_tier", nil) ||
166
- request["service_tier"]
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)
167
178
 
168
179
  modes << Pricing.normalize_mode(speed)
169
180
  modes << Pricing.normalize_mode(service_tier)
170
- modes << "data_residency" if inference_geo(request: request, response: response, usage: usage) == "us"
181
+ geo = inference_geo(request: request, usage: usage).downcase
182
+ modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
171
183
 
172
184
  modes = modes.compact.uniq
173
185
  modes.empty? ? nil : modes.join("_")
174
186
  end
175
187
 
176
- def inference_geo(request:, response:, usage:)
177
- (
178
- usage&.fetch("inference_geo", nil) ||
179
- response&.fetch("inference_geo", nil) ||
180
- request["inference_geo"]
181
- ).to_s
188
+ def inference_geo(request:, usage:)
189
+ (usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
182
190
  end
183
191
  end
184
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,47 +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
- private
36
-
37
- def safe_json_parse(body)
38
- return {} if body.blank?
39
-
40
- JSON.parse(body)
41
- rescue JSON::ParserError
42
- {}
43
- end
44
-
45
- def uri_matches?(url)
46
- uri = parsed_uri(url)
47
- uri ? yield(uri) : false
48
- end
49
-
9
+ module UrlMatchers
50
10
  def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
51
11
  uri_matches?(url) do |uri|
52
12
  host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
@@ -66,6 +26,11 @@ module LlmCostTracker
66
26
  end
67
27
  end
68
28
 
29
+ def uri_matches?(url)
30
+ uri = parsed_uri(url)
31
+ uri ? yield(uri) : false
32
+ end
33
+
69
34
  def parsed_uri(url)
70
35
  URI.parse(url.to_s)
71
36
  rescue URI::InvalidURIError
@@ -84,6 +49,51 @@ module LlmCostTracker
84
49
 
85
50
  matches
86
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
87
97
 
88
98
  def each_event_data(events, reverse: false)
89
99
  enumerator = reverse ? events.reverse_each : events.each
@@ -105,11 +115,11 @@ module LlmCostTracker
105
115
 
106
116
  def build_unknown_stream_usage(provider:, model:, provider_response_id:, pricing_mode: nil,
107
117
  service_line_items: nil)
108
- UsageCapture.build(
118
+ Event.build(
109
119
  provider: provider,
110
120
  provider_response_id: provider_response_id,
111
121
  pricing_mode: pricing_mode,
112
- model: model || UsageCapture::UNKNOWN_MODEL,
122
+ model: model || Event::UNKNOWN_MODEL,
113
123
  token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
114
124
  stream: true,
115
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,16 +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
+
84
+ def provider_for(_request_url)
85
+ "gemini"
86
+ end
87
+
78
88
  private
79
89
 
80
- def build_usage_capture(request_url:, usage:, usage_source:, stream: false, provider_response_id: nil,
81
- 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)
82
92
  cache_read = usage["cachedContentTokenCount"].to_i
83
93
  tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
84
94
  audio_input = audio_input_tokens(usage)
85
95
  audio_output = audio_output_tokens(usage)
86
96
 
87
- UsageCapture.build(
97
+ Event.build(
88
98
  provider: "gemini",
89
99
  model: extract_model_from_url(request_url),
90
100
  pricing_mode: pricing_mode,
@@ -180,10 +190,6 @@ module LlmCostTracker
180
190
  headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
181
191
  end
182
192
 
183
- def grounding_line_items_for_response(response, model:)
184
- grounding_line_items(grounding_request_count(response["candidates"]), model: model)
185
- end
186
-
187
193
  def grounding_line_items_for_stream(events, model:)
188
194
  quantity = find_event_value(events, reverse: true) do |data|
189
195
  count = grounding_request_count(data["candidates"])
@@ -219,11 +225,7 @@ module LlmCostTracker
219
225
  end
220
226
 
221
227
  def grounding_billed_quantity(query_count, model:)
222
- per_query_billing?(model) ? query_count : 1
223
- end
224
-
225
- def per_query_billing?(model)
226
- model.to_s.match?(PER_QUERY_GROUNDING_MODEL_PATTERN)
228
+ LlmCostTracker::Providers::Gemini::ModelFamilies.per_query_grounding?(model) ? query_count : 1
227
229
  end
228
230
  end
229
231
  end
@@ -2,55 +2,37 @@
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
12
+ TRACKED_PATHS = %w[
13
+ /v1/chat/completions
14
+ /v1/completions
15
+ /v1/embeddings
16
+ /v1/responses
17
+ /v1/images/generations
18
+ /v1/images/edits
19
+ /v1/images/variations
20
+ /v1/audio/transcriptions
21
+ /v1/audio/translations
22
+ /v1/audio/speech
23
+ /v1/moderations
23
24
  ].freeze
24
- TRACKED_PATHS = %w[/v1/chat/completions /v1/completions /v1/embeddings /v1/responses].freeze
25
25
 
26
- def match?(url)
27
- match_uri?(url, hosts: HOSTS, exact_paths: TRACKED_PATHS)
28
- end
29
-
30
- def provider_names
31
- %w[openai]
32
- end
26
+ class << self
27
+ def match?(url)
28
+ match_uri?(url, hosts: Providers::Openai::Hosts::API_HOSTS, exact_paths: TRACKED_PATHS)
29
+ end
33
30
 
34
- def parse(request_url:, request_body:, response_status:, response_body:, **)
35
- parse_openai_usage(
36
- request_url: request_url,
37
- request_body: request_body,
38
- response_status: response_status,
39
- response_body: response_body
40
- )
31
+ def provider_names
32
+ %w[openai]
33
+ end
41
34
  end
42
35
 
43
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
44
- parse_openai_stream_usage(
45
- request_url: request_url,
46
- request_body: request_body,
47
- response_status: response_status,
48
- events: events
49
- )
50
- end
51
-
52
- private
53
-
54
36
  def provider_for(_request_url)
55
37
  "openai"
56
38
  end
@@ -10,54 +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
- private
39
+ def provider_for_uri(uri)
40
+ return nil unless uri
51
41
 
52
- def provider_for(request_url)
53
- uri = parsed_uri(request_url)
54
- provider_for_uri(uri) || "openai_compatible"
42
+ LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
43
+ end
55
44
  end
56
45
 
57
- def provider_for_uri(uri)
58
- return nil unless uri
59
-
60
- 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)
61
48
  end
62
49
  end
63
50
  end