llm_cost_tracker 0.14.2 → 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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +33 -41
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
  12. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
  13. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
  14. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
  15. data/app/models/llm_cost_tracker/call.rb +6 -4
  16. data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
  17. data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
  18. data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
  19. data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
  20. data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
  23. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
  24. data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
  25. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  26. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
  27. data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
  28. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
  29. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
  30. data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
  31. data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
  32. data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
  33. data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
  34. data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  37. data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
  38. data/lib/llm_cost_tracker/budget/global.rb +69 -0
  39. data/lib/llm_cost_tracker/budget/limit.rb +52 -0
  40. data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
  41. data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
  42. data/lib/llm_cost_tracker/budget.rb +19 -157
  43. data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
  44. data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
  45. data/lib/llm_cost_tracker/capture/sse.rb +7 -5
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
  47. data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
  48. data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
  49. data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
  50. data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
  51. data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
  52. data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
  53. data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
  54. data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
  55. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
  56. data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
  57. data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
  58. data/lib/llm_cost_tracker/doctor.rb +23 -20
  59. data/lib/llm_cost_tracker/engine.rb +8 -0
  60. data/lib/llm_cost_tracker/errors.rb +2 -4
  61. data/lib/llm_cost_tracker/event.rb +22 -29
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
  67. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
  68. data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
  69. data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
  70. data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
  71. data/lib/llm_cost_tracker/ingestion.rb +20 -35
  72. data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
  73. data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
  74. data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
  75. data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
  76. data/lib/llm_cost_tracker/integrations/base.rb +65 -81
  77. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
  78. data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
  79. data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
  80. data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
  81. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
  82. data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
  83. data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
  84. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
  85. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
  86. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
  87. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
  88. data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
  89. data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
  90. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
  91. data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
  92. data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
  93. data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
  94. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
  95. data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
  96. data/lib/llm_cost_tracker/ledger/store.rb +17 -35
  97. data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
  98. data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
  99. data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
  100. data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
  101. data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
  102. data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
  103. data/lib/llm_cost_tracker/parsers.rb +12 -0
  104. data/lib/llm_cost_tracker/prices.json +1179 -208
  105. data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
  106. data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
  107. data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
  108. data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
  109. data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
  110. data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
  112. data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
  113. data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
  115. data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
  116. data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
  117. data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
  119. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
  120. data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
  121. data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
  122. data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
  123. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
  129. data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
  134. data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
  135. data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
  136. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
  137. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
  138. data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
  139. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
  140. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
  141. data/lib/llm_cost_tracker/providers.rb +3 -0
  142. data/lib/llm_cost_tracker/redaction.rb +13 -0
  143. data/lib/llm_cost_tracker/report/data.rb +25 -28
  144. data/lib/llm_cost_tracker/tags/context.rb +22 -7
  145. data/lib/llm_cost_tracker/tracker.rb +32 -26
  146. data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
  147. data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
  148. data/lib/llm_cost_tracker/version.rb +1 -1
  149. data/lib/llm_cost_tracker.rb +8 -4
  150. data/lib/tasks/llm_cost_tracker.rake +35 -5
  151. data/llm_cost_tracker.gemspec +6 -5
  152. metadata +58 -5
@@ -11,7 +11,7 @@ module LlmCostTracker
11
11
  class << self
12
12
  def match?(url)
13
13
  uri_matches?(url) do |uri|
14
- HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.include?("/v1/messages")
14
+ HOSTS.include?(uri.host.to_s.downcase) && uri.path.to_s.match?(%r{\A/v1/messages(?:/batches)?\z})
15
15
  end
16
16
  end
17
17
 
@@ -20,7 +20,7 @@ module LlmCostTracker
20
20
  end
21
21
  end
22
22
 
23
- def parse(request_body:, response_status:, response_body:, **)
23
+ def parse(request_body:, response_status:, response_body:, request_url: nil, **)
24
24
  return nil unless response_status == 200
25
25
 
26
26
  response = safe_json_parse(response_body)
@@ -36,37 +36,33 @@ module LlmCostTracker
36
36
  usage_source: Usage::Source::RESPONSE,
37
37
  request: request,
38
38
  content: Array(response["content"]).grep(Hash).map(&:deep_symbolize_keys),
39
+ host: parsed_uri(request_url)&.host,
39
40
  **stop_fields(response)
40
41
  )
41
42
  end
42
43
 
43
- def parse_stream(response_status:, request_body: nil, events: [], **)
44
+ def parse_stream(response_status:, request_body: nil, request_url: nil, events: [], **)
44
45
  return nil unless response_status == 200
45
46
 
46
47
  request = symbolize_request(request_body)
47
- model = find_event_value(events) { |data| data.dig("message", "model") } || request[:model]
48
48
  usage = stream_usage(events)&.deep_symbolize_keys
49
- response_id = find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
50
-
51
- if usage
52
- ResponseParser.event_from_usage(
53
- usage: usage,
54
- model: model,
55
- provider_response_id: response_id,
56
- usage_source: Usage::Source::STREAM_FINAL,
57
- request: request,
58
- stream: true,
59
- content: content_blocks(events),
60
- **stop_fields(final_delta(events))
61
- )
62
- else
63
- build_unknown_stream_usage(
64
- provider: "anthropic",
65
- model: model,
66
- provider_response_id: response_id,
67
- pricing_mode: UsageExtractor.pricing_mode(request: request, usage: usage)
68
- )
69
- end
49
+ message = {
50
+ model: find_event_value(events) { |data| data.dig("message", "model") } || request[:model],
51
+ provider_response_id: find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
52
+ }
53
+ host = parsed_uri(request_url)&.host
54
+ return unknown_stream_event(request: request, host: host, **message) unless usage
55
+
56
+ ResponseParser.event_from_usage(
57
+ usage: usage,
58
+ usage_source: Usage::Source::STREAM_FINAL,
59
+ request: request,
60
+ stream: true,
61
+ content: content_blocks(events),
62
+ host: host,
63
+ **message,
64
+ **stop_fields(final_delta(events))
65
+ )
70
66
  end
71
67
 
72
68
  def provider_for(_request_url)
@@ -83,6 +79,15 @@ module LlmCostTracker
83
79
  safe_json_parse(request_body).deep_symbolize_keys
84
80
  end
85
81
 
82
+ def unknown_stream_event(model:, provider_response_id:, request:, host:)
83
+ build_unknown_stream_usage(
84
+ provider: "anthropic",
85
+ model: model,
86
+ provider_response_id: provider_response_id,
87
+ pricing_mode: UsageExtractor.pricing_mode(request: request, usage: nil, host: host, model: model)
88
+ )
89
+ end
90
+
86
91
  def final_delta(events)
87
92
  find_event_value(events, reverse: true) { |data| data["delta"] if data["type"] == "message_delta" }
88
93
  end
@@ -14,8 +14,10 @@ module LlmCostTracker
14
14
  stream: false,
15
15
  stop_reason: nil,
16
16
  refusal_category: nil,
17
- content: nil)
18
- pricing_mode = UsageExtractor.pricing_mode(request: request, usage: usage)
17
+ content: nil,
18
+ host: nil)
19
+ model = UsageExtractor.served_model(usage, content) || model
20
+ pricing_mode = UsageExtractor.pricing_mode(request: request, usage: usage, host: host, model: model)
19
21
  line_items = UsageExtractor.service_line_items(usage) +
20
22
  UsageExtractor.iteration_line_items(usage, content: content) +
21
23
  UsageExtractor.refusal_line_items(usage, stop_reason:, refusal_category:)
@@ -23,7 +25,7 @@ module LlmCostTracker
23
25
  provider: "anthropic",
24
26
  provider_response_id: provider_response_id,
25
27
  pricing_mode: pricing_mode,
26
- model: UsageExtractor.served_model(usage, content) || model,
28
+ model: model,
27
29
  token_usage: UsageExtractor.token_usage(usage),
28
30
  stream: stream,
29
31
  usage_source: usage_source,
@@ -16,129 +16,150 @@ module LlmCostTracker
16
16
  :BILLED_REFUSAL_CATEGORIES,
17
17
  :BEDROCK_REGIONAL_PROFILE
18
18
 
19
- def self.token_usage(usage)
20
- entries = [usage, *iterations(usage, "compaction")]
21
- writes = entries.map { |entry| cache_writes(entry) }
22
-
23
- Usage::TokenUsage.build(
24
- input_tokens: entries.sum { |entry| entry[:input_tokens].to_i },
25
- output_tokens: entries.sum { |entry| entry[:output_tokens].to_i },
26
- cache_read_input_tokens: entries.sum { |entry| entry[:cache_read_input_tokens].to_i },
27
- cache_write_input_tokens: writes.sum(&:first),
28
- cache_write_extended_input_tokens: writes.sum(&:last),
29
- hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
30
- )
31
- end
19
+ class << self
20
+ def token_usage(usage)
21
+ entries = [usage, *iterations(usage, "compaction")]
22
+ writes = entries.map { |entry| cache_writes(entry) }
23
+
24
+ Usage::TokenUsage.build(
25
+ input_tokens: entries.sum { |entry| entry[:input_tokens].to_i },
26
+ output_tokens: entries.sum { |entry| entry[:output_tokens].to_i },
27
+ cache_read_input_tokens: entries.sum { |entry| entry[:cache_read_input_tokens].to_i },
28
+ cache_write_input_tokens: writes.sum(&:first),
29
+ cache_write_extended_input_tokens: writes.sum(&:last),
30
+ hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
31
+ )
32
+ end
32
33
 
33
- def self.served_model(usage, content = nil)
34
- served = iterations(usage, "fallback_message").last&.dig(:model)
35
- served ||= fallback_blocks(content).last&.dig(:to, :model)
36
- served&.to_s.presence
37
- end
34
+ def served_model(usage, content = nil)
35
+ served = iterations(usage, "fallback_message").last&.dig(:model)
36
+ served ||= fallback_blocks(content).last&.dig(:to, :model)
37
+ served&.to_s.presence
38
+ end
38
39
 
39
- def self.iteration_line_items(usage, content: nil)
40
- other_model_iterations(usage, content).map do |iteration|
41
- tokens = token_usage(iteration)
42
- Charges::LineItem.build(
43
- dimension_key: "model_iteration",
40
+ def iteration_line_items(usage, content: nil)
41
+ other_model_iterations(usage, content).map do |iteration|
42
+ tokens = token_usage(iteration)
43
+ Charges::LineItem.build(
44
+ dimension_key: "model_iteration",
45
+ quantity: 1,
46
+ pricing_basis: "provider_usage",
47
+ provider_field: "usage.iterations.#{iteration[:type]}",
48
+ details: tokens.to_h.select { |_key, count| count.positive? }.merge(model: iteration[:model].to_s)
49
+ )
50
+ end
51
+ end
52
+
53
+ def refusal_line_items(usage, stop_reason:, refusal_category:)
54
+ return [] unless stop_reason.to_s == "refusal" && token_usage(usage).output_tokens.zero?
55
+ return [] if BILLED_REFUSAL_CATEGORIES.include?(refusal_category.to_s)
56
+
57
+ [Charges::LineItem.build(
58
+ dimension_key: "billed_request",
44
59
  quantity: 1,
60
+ rate_amount: 0,
61
+ cost: 0,
45
62
  pricing_basis: "provider_usage",
46
- provider_field: "usage.iterations.#{iteration[:type]}",
47
- details: tokens.to_h.select { |_key, count| count.positive? }.merge(model: iteration[:model].to_s)
48
- )
63
+ price_source: "provider_response",
64
+ provider_field: "stop_details.category",
65
+ details: { category: refusal_category&.to_s }.compact
66
+ )]
49
67
  end
50
- end
51
68
 
52
- def self.refusal_line_items(usage, stop_reason:, refusal_category:)
53
- return [] unless stop_reason.to_s == "refusal" && token_usage(usage).output_tokens.zero?
54
- return [] if BILLED_REFUSAL_CATEGORIES.include?(refusal_category.to_s)
55
-
56
- [Charges::LineItem.build(
57
- dimension_key: "billed_request",
58
- quantity: 1,
59
- rate_amount: 0,
60
- cost: 0,
61
- pricing_basis: "provider_usage",
62
- price_source: "provider_response",
63
- provider_field: "stop_details.category",
64
- details: { category: refusal_category&.to_s }.compact
65
- )]
66
- end
69
+ def pricing_mode(request:, usage:, host: nil, model: request&.dig(:model))
70
+ modes = %i[speed service_tier].map { |key| Pricing::Mode.normalize(reported(key, usage, request)) }
71
+ modes << "data_residency" if data_residency?(usage, request) || regional_host?(host, model)
72
+ Pricing::Mode.compose(modes)
73
+ end
67
74
 
68
- def self.other_model_iterations(usage, content)
69
- advisors = iterations(usage, "advisor_message")
70
- served = served_model(usage, content)
71
- return advisors unless served
75
+ def regional_host?(host, model)
76
+ Openai::Hosts.vertex_non_global?(host) &&
77
+ Pricing::Matcher.modifier_priced?(provider: "anthropic", model: model, modifier: "data_residency")
78
+ end
72
79
 
73
- categories = fallback_blocks(content).map { |block| block.dig(:trigger, :category).to_s }
74
- declined = iterations(usage, "message").reject { |entry| ["", served].include?(entry[:model].to_s) }
75
- hops = declined.group_by { |entry| entry[:model].to_s }.values
76
- billed = hops.select.with_index do |hop, index|
77
- hop.any? { |entry| entry[:output_tokens].to_i.positive? } ||
78
- BILLED_REFUSAL_CATEGORIES.include?(categories[index])
80
+ def service_line_items(usage)
81
+ server_tool_use = usage[:server_tool_use]
82
+ return [] unless server_tool_use.is_a?(Hash)
83
+
84
+ SERVER_TOOL_LINE_ITEMS.filter_map do |dimension_key, count_key|
85
+ quantity = server_tool_use[count_key].to_i
86
+ next if quantity.zero?
87
+
88
+ Charges::LineItem.build(
89
+ dimension_key: dimension_key,
90
+ quantity: quantity,
91
+ cost_status: Charges::CostStatus::UNKNOWN,
92
+ pricing_basis: "provider_usage",
93
+ provider_field: "usage.server_tool_use.#{count_key}"
94
+ )
95
+ end
79
96
  end
80
- advisors + billed.flatten
81
- end
82
97
 
83
- def self.fallback_blocks(content)
84
- Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
85
- end
98
+ private
86
99
 
87
- def self.iterations(usage, type)
88
- Array(usage[:iterations]).select { |entry| entry.is_a?(Hash) && entry[:type].to_s == type }
89
- end
100
+ def other_model_iterations(usage, content)
101
+ iterations(usage, "advisor_message") + billed_fallback_iterations(usage, content)
102
+ end
90
103
 
91
- def self.pricing_mode(request:, usage:)
92
- speed = usage&.dig(:speed) || request&.dig(:speed)
93
- service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
94
- geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
104
+ def billed_fallback_iterations(usage, content)
105
+ served = served_model(usage, content)
106
+ return [] unless served
95
107
 
96
- modes = [Pricing::Mode.normalize(speed), Pricing::Mode.normalize(service_tier)]
97
- modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo) || bedrock_regional?(request&.dig(:model))
98
- Pricing::Mode.compose(modes)
99
- end
108
+ categories = fallback_blocks(content).map { |block| block.dig(:trigger, :category).to_s }
109
+ declined_hops(usage, served).zip(categories).flat_map do |hop, category|
110
+ billed_hop?(hop, category) ? hop : []
111
+ end
112
+ end
100
113
 
101
- def self.bedrock_regional?(model)
102
- model.to_s.match?(BEDROCK_REGIONAL_PROFILE) &&
103
- Pricing::Matcher.modifier_priced?(provider: "bedrock", model: model.to_s, modifier: "data_residency")
104
- end
114
+ def declined_hops(usage, served)
115
+ declined = iterations(usage, "message").reject { |entry| ["", served].include?(entry[:model].to_s) }
116
+ declined.group_by { |entry| entry[:model].to_s }.values
117
+ end
105
118
 
106
- def self.service_line_items(usage)
107
- server_tool_use = usage[:server_tool_use]
108
- return [] unless server_tool_use.is_a?(Hash)
119
+ def billed_hop?(hop, category)
120
+ hop.any? { |entry| entry[:output_tokens].to_i.positive? } || BILLED_REFUSAL_CATEGORIES.include?(category)
121
+ end
109
122
 
110
- SERVER_TOOL_LINE_ITEMS.filter_map do |dimension_key, count_key|
111
- quantity = server_tool_use[count_key].to_i
112
- next if quantity.zero?
123
+ def fallback_blocks(content)
124
+ Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
125
+ end
113
126
 
114
- Charges::LineItem.build(
115
- dimension_key: dimension_key,
116
- quantity: quantity,
117
- cost_status: Charges::CostStatus::UNKNOWN,
118
- pricing_basis: "provider_usage",
119
- provider_field: "usage.server_tool_use.#{count_key}"
120
- )
127
+ def iterations(usage, type)
128
+ Array(usage[:iterations]).select { |entry| entry.is_a?(Hash) && entry[:type].to_s == type }
121
129
  end
122
- end
123
130
 
124
- def self.cache_writes(usage)
125
- cache_creation = usage[:cache_creation]
126
- if cache_creation.is_a?(Hash)
127
- five = cache_creation[:ephemeral_5m_input_tokens].to_i
128
- one = cache_creation[:ephemeral_1h_input_tokens].to_i
129
- # A stream's message_delta total also counts later server-tool breakpoints, which are always 5m.
130
- [five + [usage[:cache_creation_input_tokens].to_i - five - one, 0].max, one]
131
- else
132
- warn_unexpected_cache_creation(cache_creation, usage)
133
- [usage[:cache_creation_input_tokens].to_i, 0]
131
+ def reported(key, usage, request)
132
+ usage&.dig(key) || request&.dig(key)
133
+ end
134
+
135
+ def data_residency?(usage, request)
136
+ DATA_RESIDENCY_GEOS.include?(reported(:inference_geo, usage, request).to_s.downcase) ||
137
+ bedrock_regional?(request&.dig(:model))
138
+ end
139
+
140
+ def bedrock_regional?(model)
141
+ model.to_s.match?(BEDROCK_REGIONAL_PROFILE) &&
142
+ Pricing::Matcher.modifier_priced?(provider: "bedrock", model: model.to_s, modifier: "data_residency")
143
+ end
144
+
145
+ def cache_writes(usage)
146
+ cache_creation = usage[:cache_creation]
147
+ if cache_creation.is_a?(Hash)
148
+ five_minute = cache_creation[:ephemeral_5m_input_tokens].to_i
149
+ one_hour = cache_creation[:ephemeral_1h_input_tokens].to_i
150
+ [five_minute + [usage[:cache_creation_input_tokens].to_i - five_minute - one_hour, 0].max, one_hour]
151
+ else
152
+ warn_unexpected_cache_creation(cache_creation, usage)
153
+ [usage[:cache_creation_input_tokens].to_i, 0]
154
+ end
134
155
  end
135
- end
136
156
 
137
- def self.warn_unexpected_cache_creation(cache_creation, usage)
138
- return if cache_creation.nil?
139
- return if usage.key?(:cache_creation_input_tokens)
157
+ def warn_unexpected_cache_creation(cache_creation, usage)
158
+ return if cache_creation.nil?
159
+ return if usage.key?(:cache_creation_input_tokens)
140
160
 
141
- Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
161
+ Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
162
+ end
142
163
  end
143
164
  end
144
165
  end
@@ -4,7 +4,7 @@ module LlmCostTracker
4
4
  module Providers
5
5
  module Azure
6
6
  module Hosts
7
- OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai\.azure\.com|services\.ai\.azure\.com)\z/i
7
+ OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai|services\.ai|cognitiveservices)\.azure\.com\z/i
8
8
  def self.openai?(host)
9
9
  host.to_s.match?(OPENAI_HOST_PATTERN)
10
10
  end
@@ -5,10 +5,12 @@ module LlmCostTracker
5
5
  module Azure
6
6
  class Parser < LlmCostTracker::Parsers::Base
7
7
  include Openai::ResponseParser
8
+ include Openai::StreamParser
8
9
 
9
10
  PATH_PATTERN = %r{
10
11
  \A/openai/(?:deployments/[^/]+|v1)/(?:#{Openai::Parser::TRACKED_ENDPOINTS.join('|')}|responses/resp_[^/]+)\z
11
12
  }x
13
+ DEPLOYMENT_PATH_PATTERN = %r{/openai/deployments/([^/]+)/}
12
14
 
13
15
  class << self
14
16
  def match?(url)
@@ -27,12 +29,7 @@ module LlmCostTracker
27
29
  end
28
30
 
29
31
  def model_for(request_url, request_parsed)
30
- body_model = super
31
- return body_model if body_model
32
-
33
- uri = parsed_uri(request_url)
34
- match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
35
- match && match[1]
32
+ super || parsed_uri(request_url)&.path.to_s[DEPLOYMENT_PATH_PATTERN, 1]
36
33
  end
37
34
 
38
35
  def auto_enable_stream_usage?(request_url, request_parsed)
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Gemini
6
+ class Grounding
7
+ CANDIDATE_FIELDS = {
8
+ "grounding_request" => "response.candidates.groundingMetadata.webSearchQueries",
9
+ "maps_grounding_request" => "response.candidates.groundingMetadata.groundingChunks.maps"
10
+ }.freeze
11
+ TOOL_KINDS = {
12
+ "google_search" => "grounding_request",
13
+ "google_maps" => "maps_grounding_request"
14
+ }.freeze
15
+ TOOL_COUNT_FIELD = "response.usage.grounding_tool_count"
16
+
17
+ class << self
18
+ def from_response(response)
19
+ usage = response["usage"]
20
+ return from_candidates(response["candidates"]) unless usage.is_a?(Hash)
21
+
22
+ from_tool_counts(usage["grounding_tool_count"])
23
+ end
24
+
25
+ def from_candidates(candidates)
26
+ counts = Array(candidates).each_with_object(Hash.new(0)) do |candidate, totals|
27
+ metadata = candidate["groundingMetadata"]
28
+ next unless metadata.is_a?(Hash)
29
+
30
+ kind, count = metadata_count(metadata)
31
+ totals[kind] += count
32
+ end
33
+ new(counts)
34
+ end
35
+
36
+ private
37
+
38
+ def from_tool_counts(entries)
39
+ counts = Array(entries).each_with_object(Hash.new(0)) do |entry, totals|
40
+ kind = TOOL_KINDS[entry["type"]]
41
+ totals[kind] += entry["count"].to_i if kind
42
+ end
43
+ new(counts, provider_field: TOOL_COUNT_FIELD)
44
+ end
45
+
46
+ def metadata_count(metadata)
47
+ queries = unique_count(metadata["webSearchQueries"])
48
+ if Array(metadata["groundingChunks"]).any? { |chunk| chunk.is_a?(Hash) && chunk.key?("maps") }
49
+ ["maps_grounding_request", [queries, 1].max]
50
+ else
51
+ ["grounding_request", queries + unique_count(metadata["imageSearchQueries"])]
52
+ end
53
+ end
54
+
55
+ def unique_count(queries)
56
+ Array(queries).map { |query| query.to_s.strip }.reject(&:empty?).uniq.size
57
+ end
58
+ end
59
+
60
+ def initialize(counts, provider_field: nil)
61
+ @counts = counts
62
+ @provider_field = provider_field
63
+ end
64
+
65
+ def any?
66
+ @counts.values.any?(&:positive?)
67
+ end
68
+
69
+ def line_items(model:)
70
+ per_query = ModelFamilies.per_query_grounding?(model)
71
+ @counts.filter_map do |kind, count|
72
+ next unless count.positive?
73
+
74
+ Charges::LineItem.build(
75
+ dimension_key: kind,
76
+ quantity: per_query ? count : 1,
77
+ cost_status: Charges::CostStatus::UNKNOWN,
78
+ pricing_basis: "provider_usage",
79
+ provider_field: @provider_field || CANDIDATE_FIELDS.fetch(kind),
80
+ details: { web_search_queries: count }
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end