llm_cost_tracker 0.14.1 → 0.15.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +38 -35
  5. data/app/assets/llm_cost_tracker/application.css +16 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  7. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
  8. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
  10. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  11. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
  12. data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
  13. data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  15. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  16. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +36 -26
  17. data/app/models/llm_cost_tracker/call.rb +15 -17
  18. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  19. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  20. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  22. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +67 -222
  24. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
  25. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  28. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
  31. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  32. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
  33. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
  34. data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
  35. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
  36. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  37. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  38. data/app/views/llm_cost_tracker/calls/show.html.erb +7 -32
  39. data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
  40. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  41. data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
  45. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
  46. data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
  47. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  48. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  49. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  50. data/lib/llm_cost_tracker/budget/per_tag.rb +63 -25
  51. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  52. data/lib/llm_cost_tracker/budget.rb +22 -171
  53. data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
  54. data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
  55. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  56. data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
  57. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  58. data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
  59. data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
  60. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  61. data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
  62. data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
  63. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  64. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  65. data/lib/llm_cost_tracker/configuration/pricing.rb +1 -1
  66. data/lib/llm_cost_tracker/configuration.rb +5 -9
  67. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  68. data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
  69. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  70. data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
  71. data/lib/llm_cost_tracker/doctor.rb +42 -22
  72. data/lib/llm_cost_tracker/engine.rb +8 -1
  73. data/lib/llm_cost_tracker/errors.rb +2 -4
  74. data/lib/llm_cost_tracker/event.rb +28 -27
  75. data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
  76. data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
  77. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
  78. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
  79. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
  80. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
  81. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
  82. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
  83. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
  84. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
  85. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
  86. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
  87. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
  88. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
  89. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
  90. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
  91. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
  92. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  93. data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
  94. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  95. data/lib/llm_cost_tracker/ingestion.rb +23 -36
  96. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  97. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  98. data/lib/llm_cost_tracker/integrations/anthropic.rb +82 -90
  99. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  100. data/lib/llm_cost_tracker/integrations/base.rb +98 -93
  101. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
  102. data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
  103. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  104. data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
  105. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  106. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  107. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  108. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  109. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  110. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  111. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  112. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  113. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  114. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -243
  115. data/lib/llm_cost_tracker/integrations.rb +1 -20
  116. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
  117. data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
  118. data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
  119. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
  120. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
  121. data/lib/llm_cost_tracker/ledger/store.rb +19 -37
  122. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
  123. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
  124. data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -2
  125. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  126. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  127. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  128. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  129. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -284
  130. data/lib/llm_cost_tracker/parsers.rb +19 -40
  131. data/lib/llm_cost_tracker/prices.json +3241 -187
  132. data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
  133. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  134. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  135. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  136. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  137. data/lib/llm_cost_tracker/pricing/calculation.rb +113 -161
  138. data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
  139. data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
  140. data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
  141. data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
  142. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  143. data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
  144. data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
  145. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  146. data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
  147. data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
  148. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  149. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  150. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +13 -9
  151. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  152. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  153. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  154. data/lib/llm_cost_tracker/pricing/sync.rb +24 -119
  155. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  156. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
  157. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
  158. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
  159. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  160. data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
  161. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  162. data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
  163. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
  164. data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
  165. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  166. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  167. data/lib/llm_cost_tracker/providers/openai/parser.rb +12 -13
  168. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
  169. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
  170. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  171. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
  172. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -4
  173. data/lib/llm_cost_tracker/providers.rb +3 -0
  174. data/lib/llm_cost_tracker/redaction.rb +13 -0
  175. data/lib/llm_cost_tracker/report/data.rb +25 -28
  176. data/lib/llm_cost_tracker/retention.rb +1 -3
  177. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  178. data/lib/llm_cost_tracker/tracker.rb +45 -31
  179. data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
  180. data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
  181. data/lib/llm_cost_tracker/usage/dimensions.yml +85 -0
  182. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  183. data/lib/llm_cost_tracker/version.rb +1 -1
  184. data/lib/llm_cost_tracker.rb +8 -4
  185. data/lib/tasks/llm_cost_tracker.rake +58 -25
  186. data/llm_cost_tracker.gemspec +64 -0
  187. metadata +65 -11
@@ -1,16 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+ require "time"
5
+
3
6
  module LlmCostTracker
4
7
  module Providers
5
8
  module Gemini
6
9
  class Parser < LlmCostTracker::Parsers::Base
7
10
  HOSTS = %w[generativelanguage.googleapis.com].freeze
8
- TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
9
- STREAM_PATH_PATTERN = /:streamGenerateContent\z/
11
+ INTERACTIONS_PATH_PATTERN = %r{/interactions(?:/[^/]+)?\z}
12
+ INTERACTION_RETRIEVE_PATH_PATTERN = %r{/interactions/[^/]+\z}
13
+ CACHE_PATH_PATTERN = %r{/cachedContents\z}
14
+ TRACKED_PATH_PATTERN = Regexp.union(
15
+ %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}, INTERACTIONS_PATH_PATTERN, CACHE_PATH_PATTERN
16
+ )
17
+ STREAM_PATH_PATTERN = /:streamGenerateContent\z/
18
+ MODEL_PATH_PATTERN = %r{/models/([^/:]+)}
19
+ PENDING_STATUSES = %w[queued in_progress].freeze
20
+ TRAFFIC_TYPE_MODES = {
21
+ "ON_DEMAND" => nil,
22
+ "TRAFFIC_TYPE_UNSPECIFIED" => nil,
23
+ "ON_DEMAND_PRIORITY" => "priority",
24
+ "ON_DEMAND_FLEX" => "flex"
25
+ }.freeze
10
26
 
11
27
  class << self
12
28
  def match?(url)
13
- match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
29
+ uri_matches?(url) do |uri|
30
+ HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.match?(TRACKED_PATH_PATTERN)
31
+ end
14
32
  end
15
33
 
16
34
  def provider_names
@@ -19,161 +37,204 @@ module LlmCostTracker
19
37
  end
20
38
 
21
39
  def streaming_request?(request_url, request_parsed)
22
- return true if match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
23
-
24
- super
40
+ path_matches?(request_url, STREAM_PATH_PATTERN) || super
25
41
  end
26
42
 
27
43
  def parse(request_url:, request_body:, response_status:, response_body:, response_headers: nil)
28
44
  return nil unless response_status == 200
29
45
 
30
46
  response = safe_json_parse(response_body)
31
- usage = response["usageMetadata"]
32
- return nil unless usage
33
-
34
47
  request = safe_json_parse(request_body)
35
- model = extract_model_from_url(request_url)
36
- build_event(
37
- request_url: request_url,
38
- usage: usage,
39
- usage_source: Usage::Source::RESPONSE,
40
- provider_response_id: response["responseId"],
41
- pricing_mode: pricing_mode(request: request, usage: usage, response_headers: response_headers),
42
- service_line_items: grounding_line_items(grounding_request_count(response["candidates"]), model: model)
43
- )
48
+ if path_matches?(request_url, INTERACTIONS_PATH_PATTERN)
49
+ return interaction_response_event(response, request_url:, request:, response_headers:)
50
+ end
51
+ return cache_storage_event(response) if path_matches?(request_url, CACHE_PATH_PATTERN)
52
+
53
+ content_event(response, request_url:, request:, response_headers:)
44
54
  end
45
55
 
46
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], response_headers: nil)
56
+ def parse_stream(response_status:,
57
+ request_url: nil,
58
+ request_body: nil,
59
+ events: [],
60
+ response_headers: nil,
61
+ model: nil)
47
62
  return nil unless response_status == 200
48
63
 
49
64
  request = safe_json_parse(request_body)
50
- usage = merged_stream_usage(events)
51
- model = extract_model_from_url(request_url)
52
- response_id = stream_response_id(events)
53
- mode = pricing_mode(request: request, usage: usage, response_headers: response_headers)
54
- service_line_items = grounding_line_items_for_stream(events, model: model)
55
-
56
- if usage
57
- build_event(
58
- request_url: request_url,
59
- usage: usage,
60
- stream: true,
61
- usage_source: Usage::Source::STREAM_FINAL,
62
- provider_response_id: response_id,
63
- pricing_mode: mode,
64
- service_line_items: service_line_items
65
- )
66
- else
67
- build_unknown_stream_usage(
68
- provider: "gemini",
69
- model: model,
70
- provider_response_id: response_id,
71
- pricing_mode: mode,
72
- service_line_items: service_line_items
73
- )
74
- end
65
+ interaction = find_event_value(events, reverse: true) { |data| completed_interaction(data) }
66
+ return interaction_event(interaction, request:, response_headers:, stream: true) if interaction
67
+
68
+ content_stream_event(events, request:, request_url:, response_headers:, fallback_model: model)
75
69
  end
76
70
 
77
- def model_for(request_url, _request_parsed)
78
- extract_model_from_url(request_url)
71
+ def model_for(request_url, request_parsed)
72
+ model_from_url(request_url) ||
73
+ (request_parsed["model"] if path_matches?(request_url, INTERACTIONS_PATH_PATTERN))
79
74
  end
80
75
 
81
76
  def retain_stream_event?(data)
82
- data.is_a?(Hash) && grounding_request_count(data["candidates"]).positive?
77
+ data.is_a?(Hash) && Grounding.from_candidates(data["candidates"]).any?
83
78
  end
84
79
 
85
80
  def provider_for(_request_url)
86
81
  "gemini"
87
82
  end
88
83
 
89
- private
84
+ def service_line_items_for(response, model:)
85
+ Grounding.from_response(response).line_items(model: model)
86
+ end
87
+
88
+ def cache_storage_event(response)
89
+ tokens = response.dig("usageMetadata", "totalTokenCount")
90
+ from = response["createTime"]
91
+ to = response["expireTime"]
92
+ return nil unless tokens && from && to
90
93
 
91
- def build_event(request_url:,
92
- usage:,
93
- usage_source:,
94
- stream: false,
95
- provider_response_id: nil,
96
- pricing_mode: nil,
97
- service_line_items: nil)
98
94
  Event.build(
99
95
  provider: "gemini",
100
- model: extract_model_from_url(request_url),
101
- pricing_mode: pricing_mode,
102
- token_usage: UsageExtractor.token_usage(usage),
96
+ model: response["model"].to_s.split("/").last,
97
+ token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
98
+ usage_source: Usage::Source::RESPONSE,
99
+ provider_response_id: response["name"],
100
+ service_line_items: [cache_storage_line_item(tokens, from, to)]
101
+ )
102
+ end
103
+
104
+ def pricing_mode(request:, usage:, response_headers:, host: nil, model: nil)
105
+ regional = Openai::Hosts.vertex_non_global?(host) &&
106
+ Pricing::Matcher.modifier_priced?(provider: "gemini", model: model, modifier: "data_residency")
107
+ Pricing::Mode.compose([service_tier(request, usage, response_headers), ("data_residency" if regional)])
108
+ end
109
+
110
+ private
111
+
112
+ def interaction_response_event(response, request_url:, request:, response_headers:)
113
+ event = interaction_event(response, request:, response_headers:)
114
+ return event unless event && path_matches?(request_url, INTERACTION_RETRIEVE_PATH_PATTERN)
115
+
116
+ event unless Call.already_recorded?(provider: "gemini", provider_response_id: event.provider_response_id)
117
+ end
118
+
119
+ def interaction_event(interaction, request:, response_headers:, stream: false)
120
+ usage = interaction["usage"]
121
+ return nil unless usage.is_a?(Hash) && !PENDING_STATUSES.include?(interaction["status"])
122
+
123
+ model = interaction["model"] || request["model"]
124
+ metadata = UsageExtractor.from_interaction(usage, interaction["service_tier"])
125
+ build_event(
126
+ model: model,
127
+ usage: metadata,
103
128
  stream: stream,
104
- usage_source: usage_source,
105
- provider_response_id: provider_response_id,
106
- service_line_items: service_line_items
129
+ usage_source: stream ? Usage::Source::STREAM_FINAL : Usage::Source::RESPONSE,
130
+ provider_response_id: interaction["id"],
131
+ pricing_mode: pricing_mode(request: request, usage: metadata, response_headers: response_headers),
132
+ service_line_items: service_line_items_for(interaction, model: model)
133
+ ).keyed_by_response_id
134
+ end
135
+
136
+ def content_event(response, request_url:, request:, response_headers:)
137
+ usage = response["usageMetadata"]
138
+ return nil unless usage
139
+
140
+ model = response["modelVersion"].presence || model_from_url(request_url)
141
+ host = parsed_uri(request_url)&.host
142
+ build_event(
143
+ model: model,
144
+ usage: usage,
145
+ usage_source: Usage::Source::RESPONSE,
146
+ provider_response_id: response["responseId"],
147
+ pricing_mode: pricing_mode(request:, usage:, response_headers:, host:, model:),
148
+ service_line_items: service_line_items_for(response, model: model)
107
149
  )
108
150
  end
109
151
 
110
- def merged_stream_usage(events)
111
- find_event_value(events, reverse: true) do |data|
112
- meta = data["usageMetadata"]
113
- meta if meta.is_a?(Hash)
114
- end
152
+ def content_stream_event(events, request:, request_url:, response_headers:, fallback_model:)
153
+ usage = last_usage_metadata(events)
154
+ model = find_event_value(events, reverse: true) { |data| data["modelVersion"] } ||
155
+ model_from_url(request_url) || fallback_model || request["model"]
156
+ host = parsed_uri(request_url)&.host
157
+ attributes = {
158
+ model: model,
159
+ provider_response_id: find_event_value(events) { |data| data["responseId"] },
160
+ pricing_mode: pricing_mode(request:, usage:, response_headers:, host:, model:),
161
+ service_line_items: stream_grounding_line_items(events, model: model)
162
+ }
163
+ return build_unknown_stream_usage(provider: "gemini", **attributes) unless usage
164
+
165
+ build_event(usage: usage, stream: true, usage_source: Usage::Source::STREAM_FINAL, **attributes)
115
166
  end
116
167
 
117
- def stream_response_id(events)
118
- find_event_value(events) { |data| data["responseId"] }
168
+ def build_event(usage:, service_line_items:, **attributes)
169
+ Event.build(
170
+ provider: "gemini",
171
+ token_usage: UsageExtractor.token_usage(usage),
172
+ service_line_items: service_line_items + UsageExtractor.line_items(usage),
173
+ **attributes
174
+ )
119
175
  end
120
176
 
121
- def extract_model_from_url(url)
122
- uri = parsed_uri(url)
123
- return nil unless uri
177
+ def cache_storage_line_item(tokens, from, to)
178
+ seconds = Time.iso8601(to) - Time.iso8601(from)
179
+ Charges::LineItem.build(
180
+ dimension_key: "cache_storage_token_hour",
181
+ quantity: BigDecimal(tokens.to_s) * BigDecimal(seconds.to_s) / 3600,
182
+ cost_status: Charges::CostStatus::UNKNOWN,
183
+ pricing_basis: "provider_usage",
184
+ provider_field: "response.usageMetadata.totalTokenCount",
185
+ details: { cached_tokens: tokens, expire_time: to }
186
+ )
187
+ end
124
188
 
125
- match = uri.path.match(%r{/models/([^/:]+)})
126
- match && match[1]
189
+ def service_tier(request, usage, response_headers)
190
+ reported_service_tier(usage) ||
191
+ Pricing::Mode.normalize(response_header(response_headers, "x-gemini-service-tier")) ||
192
+ requested_flex_tier(request)
127
193
  end
128
194
 
129
- def pricing_mode(request:, usage:, response_headers:)
130
- body_mode = Pricing::Mode.normalize(usage && usage["serviceTier"])
131
- return body_mode if body_mode
195
+ def reported_service_tier(usage)
196
+ return nil unless usage
132
197
 
133
- header_mode = Pricing::Mode.normalize(response_header(response_headers, "x-gemini-service-tier"))
134
- return header_mode if header_mode
198
+ traffic = usage["trafficType"]
199
+ Pricing::Mode.normalize(usage["serviceTier"]) ||
200
+ TRAFFIC_TYPE_MODES.fetch(traffic.to_s) { Pricing::Mode.normalize(traffic) }
201
+ end
135
202
 
136
- request_mode = Pricing::Mode.normalize(request["service_tier"] || request["serviceTier"])
137
- request_mode == "flex" ? request_mode : nil
203
+ def requested_flex_tier(request)
204
+ "flex" if Pricing::Mode.normalize(request["service_tier"] || request["serviceTier"]) == "flex"
138
205
  end
139
206
 
140
207
  def response_header(headers, name)
141
208
  headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
142
209
  end
143
210
 
144
- def grounding_line_items_for_stream(events, model:)
145
- quantity = find_event_value(events, reverse: true) do |data|
146
- count = grounding_request_count(data["candidates"])
147
- count if count.positive?
148
- end
149
- grounding_line_items(quantity || 0, model: model)
211
+ def path_matches?(url, pattern)
212
+ uri_matches?(url) { |uri| uri.path.to_s.match?(pattern) }
150
213
  end
151
214
 
152
- def grounding_request_count(candidates)
153
- Array(candidates).sum do |candidate|
154
- queries = candidate.dig("groundingMetadata", "webSearchQueries") || []
155
- Array(queries).size
156
- end
215
+ def model_from_url(url)
216
+ uri = parsed_uri(url)
217
+ uri.path[MODEL_PATH_PATTERN, 1] if uri
157
218
  end
158
219
 
159
- def grounding_line_items(query_count, model:)
160
- return [] unless query_count.positive?
220
+ def last_usage_metadata(events)
221
+ find_event_value(events, reverse: true) do |data|
222
+ metadata = data["usageMetadata"]
223
+ metadata if metadata.is_a?(Hash)
224
+ end
225
+ end
161
226
 
162
- billed_quantity = grounding_billed_quantity(query_count, model: model)
163
- [
164
- Charges::LineItem.build(
165
- dimension_key: "grounding_request",
166
- quantity: billed_quantity,
167
- cost_status: Charges::CostStatus::UNKNOWN,
168
- pricing_basis: "provider_usage",
169
- provider_field: "response.candidates.groundingMetadata.webSearchQueries",
170
- details: { web_search_queries: query_count }
171
- )
172
- ]
227
+ def completed_interaction(data)
228
+ interaction = data["interaction"]
229
+ interaction if interaction.is_a?(Hash) && interaction["usage"].is_a?(Hash)
173
230
  end
174
231
 
175
- def grounding_billed_quantity(query_count, model:)
176
- ModelFamilies.per_query_grounding?(model) ? query_count : 1
232
+ def stream_grounding_line_items(events, model:)
233
+ grounding = find_event_value(events, reverse: true) do |data|
234
+ candidates = Grounding.from_candidates(data["candidates"])
235
+ candidates if candidates.any?
236
+ end
237
+ grounding ? grounding.line_items(model: model) : []
177
238
  end
178
239
  end
179
240
  end
@@ -4,70 +4,80 @@ module LlmCostTracker
4
4
  module Providers
5
5
  module Gemini
6
6
  module UsageExtractor
7
- def self.token_usage(usage)
8
- cache_read = usage["cachedContentTokenCount"].to_i
9
- tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
10
- audio_input = audio_input_tokens(usage)
11
- audio_output = audio_output_tokens(usage)
12
- image_input = image_input_tokens(usage)
13
- image_output = image_output_tokens(usage)
14
-
15
- Usage::TokenUsage.build(
16
- input_tokens: regular_input_tokens(usage: usage,
17
- cache_read: cache_read,
18
- audio_input: audio_input,
19
- image_input: image_input) +
20
- tool_use_prompt,
21
- output_tokens: regular_output_tokens(usage: usage,
22
- audio_output: audio_output,
23
- image_output: image_output),
24
- total_tokens: usage["totalTokenCount"],
25
- cache_read_input_tokens: cache_read,
26
- audio_input_tokens: audio_input,
27
- audio_output_tokens: audio_output,
28
- image_input_tokens: image_input,
29
- image_output_tokens: image_output,
30
- hidden_output_tokens: usage["thoughtsTokenCount"]
31
- )
32
- end
7
+ class << self
8
+ def token_usage(usage)
9
+ Usage::TokenUsage.build(
10
+ **input_counts(usage),
11
+ **output_counts(usage),
12
+ total_tokens: usage["totalTokenCount"],
13
+ hidden_output_tokens: usage["thoughtsTokenCount"]
14
+ )
15
+ end
33
16
 
34
- def self.gross_output_tokens(usage)
35
- usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
36
- end
17
+ def line_items(usage)
18
+ {
19
+ "audio_cache_read_input" => modality_tokens(usage["cacheTokensDetails"], "AUDIO"),
20
+ "video_output" => modality_tokens(usage["candidatesTokensDetails"], "VIDEO")
21
+ }.filter_map do |dimension_key, quantity|
22
+ Charges::LineItem.build(dimension_key: dimension_key, quantity: quantity) if quantity.positive?
23
+ end
24
+ end
37
25
 
38
- def self.regular_input_tokens(usage:, cache_read:, audio_input:, image_input:)
39
- [usage["promptTokenCount"].to_i - cache_read - audio_input - image_input, 0].max
40
- end
26
+ def from_interaction(usage, service_tier)
27
+ {
28
+ "promptTokenCount" => usage["total_input_tokens"],
29
+ "cachedContentTokenCount" => usage["total_cached_tokens"],
30
+ "toolUsePromptTokenCount" => usage["total_tool_use_tokens"],
31
+ "candidatesTokenCount" => usage["total_output_tokens"],
32
+ "thoughtsTokenCount" => usage["total_thought_tokens"],
33
+ "totalTokenCount" => usage["total_tokens"],
34
+ "promptTokensDetails" => interaction_modalities(usage["input_tokens_by_modality"]),
35
+ "cacheTokensDetails" => interaction_modalities(usage["cached_tokens_by_modality"]),
36
+ "candidatesTokensDetails" => interaction_modalities(usage["output_tokens_by_modality"]),
37
+ "serviceTier" => service_tier
38
+ }
39
+ end
41
40
 
42
- def self.regular_output_tokens(usage:, audio_output:, image_output:)
43
- [gross_output_tokens(usage) - audio_output - image_output, 0].max
44
- end
41
+ def modality_tokens(details, modality)
42
+ Array(details).sum do |detail|
43
+ next 0 unless detail["modality"] == modality
45
44
 
46
- def self.audio_input_tokens(usage)
47
- prompt_audio = modality_tokens(usage["promptTokensDetails"], "AUDIO")
48
- cache_audio = modality_tokens(usage["cacheTokensDetails"], "AUDIO")
49
- [prompt_audio - cache_audio, 0].max
50
- end
45
+ detail["tokenCount"].to_i
46
+ end
47
+ end
51
48
 
52
- def self.audio_output_tokens(usage)
53
- modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
54
- end
49
+ private
55
50
 
56
- def self.image_input_tokens(usage)
57
- prompt_image = modality_tokens(usage["promptTokensDetails"], "IMAGE")
58
- cache_image = modality_tokens(usage["cacheTokensDetails"], "IMAGE")
59
- [prompt_image - cache_image, 0].max
60
- end
51
+ def input_counts(usage)
52
+ cache_read = usage["cachedContentTokenCount"].to_i
53
+ audio = uncached_prompt_tokens(usage, "AUDIO")
54
+ image = uncached_prompt_tokens(usage, "IMAGE") + uncached_prompt_tokens(usage, "DOCUMENT")
55
+ text = [usage["promptTokenCount"].to_i - cache_read - audio - image, 0].max
56
+ {
57
+ input_tokens: text + usage["toolUsePromptTokenCount"].to_i,
58
+ cache_read_input_tokens: cache_read,
59
+ audio_input_tokens: audio,
60
+ image_input_tokens: image
61
+ }
62
+ end
61
63
 
62
- def self.image_output_tokens(usage)
63
- modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
64
- end
64
+ def output_counts(usage)
65
+ audio = modality_tokens(usage["candidatesTokensDetails"], "AUDIO")
66
+ image = modality_tokens(usage["candidatesTokensDetails"], "IMAGE")
67
+ gross = usage["candidatesTokenCount"].to_i + usage["thoughtsTokenCount"].to_i
68
+ { output_tokens: [gross - audio - image, 0].max, audio_output_tokens: audio, image_output_tokens: image }
69
+ end
65
70
 
66
- def self.modality_tokens(details, modality)
67
- Array(details).sum do |detail|
68
- next 0 unless detail["modality"] == modality
71
+ def uncached_prompt_tokens(usage, modality)
72
+ prompt = modality_tokens(usage["promptTokensDetails"] || usage["promptTokenDetails"], modality)
73
+ cached = modality_tokens(usage["cacheTokensDetails"], modality)
74
+ [prompt - cached, 0].max
75
+ end
69
76
 
70
- detail["tokenCount"].to_i
77
+ def interaction_modalities(entries)
78
+ Array(entries).map do |entry|
79
+ { "modality" => entry["modality"].to_s.upcase, "tokenCount" => entry["tokens"] }
80
+ end
71
81
  end
72
82
  end
73
83
  end
@@ -18,11 +18,22 @@ module LlmCostTracker
18
18
  ae.api.openai.com
19
19
  ].freeze
20
20
 
21
- DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
21
+ DATA_RESIDENCY_HOST_PATTERN = /\A(?:[a-z]{2,3}\.api\.openai\.com|us\.api\.x\.ai|api\.(?:eu|us)\.mistral\.ai)\z/
22
+ VERTEX_NON_GLOBAL_HOST_PATTERN = /\A(?:[a-z0-9-]+-aiplatform|aiplatform\.[a-z0-9-]+\.rep)\.googleapis\.com\z/
23
+
24
+ BEDROCK_HOST_PATTERN = /\Abedrock-(?:mantle|runtime(?:-fips)?)\./
22
25
 
23
26
  def self.data_residency?(host)
24
27
  host.to_s.downcase.match?(DATA_RESIDENCY_HOST_PATTERN)
25
28
  end
29
+
30
+ def self.vertex_non_global?(host)
31
+ host.to_s.downcase.match?(VERTEX_NON_GLOBAL_HOST_PATTERN)
32
+ end
33
+
34
+ def self.bedrock?(host)
35
+ host.to_s.downcase.match?(BEDROCK_HOST_PATTERN)
36
+ end
26
37
  end
27
38
  end
28
39
  end
@@ -6,7 +6,7 @@ module LlmCostTracker
6
6
  module ModelFamilies
7
7
  IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
8
8
 
9
- CHARACTER_BILLED_TTS_MODEL_PATTERN = /\Atts-1(-hd)?\z/
9
+ CHARACTER_BILLED_TTS_MODEL_PATTERN = %r{\A(?:tts-1(?:-hd)?|voxtral-mini-tts-[\w-]+|canopylabs/orpheus-[\w-]+)\z}
10
10
 
11
11
  REASONING_MODEL_PATTERNS = [
12
12
  /\Agpt-5(\b|[\d.-])/i,
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Openai
6
+ class OutputItem
7
+ RENAMED_TYPES = {
8
+ "web_search_call" => "web_search_request",
9
+ "code_interpreter_call" => "container_session"
10
+ }.freeze
11
+
12
+ attr_reader :dimension
13
+
14
+ def self.wrap(data)
15
+ item = new(data) if data.is_a?(Hash)
16
+ item if item&.dimension
17
+ end
18
+
19
+ def initialize(data)
20
+ @data = data
21
+ @dimension = shell_session? ? "container_session" : catalog_dimension
22
+ end
23
+
24
+ def billable?
25
+ case dimension
26
+ when "image_generation_call" then @data["status"] == "completed"
27
+ when "web_search_request" then [nil, "search"].include?(@data.dig("action", "type"))
28
+ else true
29
+ end
30
+ end
31
+
32
+ def dedup_key(position)
33
+ return "#{dimension}:#{container_id}" if dimension == "container_session" && container_id
34
+
35
+ @data["id"] || "#{@data['type']}:#{position}"
36
+ end
37
+
38
+ def line_item(dimension_key)
39
+ Charges::LineItem.build(
40
+ dimension_key: dimension_key,
41
+ quantity: 1,
42
+ cost_status: Charges::CostStatus::UNKNOWN,
43
+ pricing_basis: "provider_usage",
44
+ provider_field: @data["provider_field"] || "response.output.#{@data['type']}",
45
+ provider_item_id: dimension_key == "container_session" ? container_id || @data["id"] : @data["id"],
46
+ details: details
47
+ )
48
+ end
49
+
50
+ def billing_fields
51
+ @data.slice("type", "id", "status", "container_id").merge(
52
+ "action" => @data["action"]&.slice("type"),
53
+ "environment" => @data["environment"]&.slice("type", "container_id")
54
+ ).compact
55
+ end
56
+
57
+ private
58
+
59
+ def shell_session?
60
+ @data["type"] == "shell_call" && shell_container_id
61
+ end
62
+
63
+ def catalog_dimension
64
+ key = RENAMED_TYPES[@data["type"]] || @data["type"]
65
+ dimension = Usage::Catalog[key]
66
+ key if dimension && dimension.token_key.nil?
67
+ end
68
+
69
+ def container_id
70
+ @data["container_id"] || shell_container_id
71
+ end
72
+
73
+ def shell_container_id
74
+ environment = @data["environment"]
75
+ environment["container_id"] if environment.is_a?(Hash) && environment["type"] == "container_reference"
76
+ end
77
+
78
+ def details
79
+ { status: @data["status"], action_type: @data.dig("action", "type"), container_id: container_id }.compact
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -5,24 +5,23 @@ module LlmCostTracker
5
5
  module Openai
6
6
  class Parser < LlmCostTracker::Parsers::Base
7
7
  include ResponseParser
8
+ include StreamParser
8
9
 
9
- TRACKED_PATHS = %w[
10
- /v1/chat/completions
11
- /v1/completions
12
- /v1/embeddings
13
- /v1/responses
14
- /v1/images/generations
15
- /v1/images/edits
16
- /v1/images/variations
17
- /v1/audio/transcriptions
18
- /v1/audio/translations
19
- /v1/audio/speech
20
- /v1/moderations
10
+ TRACKED_ENDPOINTS = %w[
11
+ chat/completions completions embeddings moderations responses responses/compact
12
+ audio/transcriptions audio/translations audio/speech
13
+ images/generations images/edits images/variations
21
14
  ].freeze
15
+ TRACKED_PATHS = TRACKED_ENDPOINTS.map { |endpoint| "/v1/#{endpoint}" }.freeze
16
+ RETRIEVE_PATH = %r{\A/v1/responses/resp_[^/]+\z}
22
17
 
23
18
  class << self
24
19
  def match?(url)
25
- match_uri?(url, hosts: Hosts::API_HOSTS, exact_paths: TRACKED_PATHS)
20
+ uri_matches?(url) do |uri|
21
+ path = uri.path.to_s
22
+ Hosts::API_HOSTS.include?(uri.host.to_s.downcase) &&
23
+ (TRACKED_PATHS.include?(path) || path.match?(RETRIEVE_PATH))
24
+ end
26
25
  end
27
26
 
28
27
  def provider_names