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
@@ -9,67 +9,157 @@ module LlmCostTracker
9
9
  "web_fetch_request" => :web_fetch_requests
10
10
  }.freeze
11
11
  DATA_RESIDENCY_GEOS = %w[us].freeze
12
- private_constant :SERVER_TOOL_LINE_ITEMS, :DATA_RESIDENCY_GEOS
13
-
14
- def self.token_usage(usage)
15
- input = usage[:input_tokens].to_i
16
- output = usage[:output_tokens].to_i
17
- cache_read = usage[:cache_read_input_tokens].to_i
18
- cache_write, cache_write_extended = cache_writes(usage)
19
-
20
- Usage::TokenUsage.build(
21
- input_tokens: input,
22
- output_tokens: output,
23
- cache_read_input_tokens: cache_read,
24
- cache_write_input_tokens: cache_write,
25
- cache_write_extended_input_tokens: cache_write_extended,
26
- hidden_output_tokens: usage.dig(:output_tokens_details, :thinking_tokens).to_i
27
- )
28
- end
12
+ BILLED_REFUSAL_CATEGORIES = %w[bio frontier_llm reasoning_extraction].freeze
13
+ BEDROCK_REGIONAL_PROFILE = /\A(?!global\.)[a-z]+\.anthropic\./
14
+ private_constant :SERVER_TOOL_LINE_ITEMS,
15
+ :DATA_RESIDENCY_GEOS,
16
+ :BILLED_REFUSAL_CATEGORIES,
17
+ :BEDROCK_REGIONAL_PROFILE
29
18
 
30
- def self.pricing_mode(request:, usage:)
31
- speed = usage&.dig(:speed) || request&.dig(:speed)
32
- service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
33
- geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
19
+ class << self
20
+ def token_usage(usage)
21
+ entries = [usage, *iterations(usage, "compaction")]
22
+ writes = entries.map { |entry| cache_writes(entry) }
34
23
 
35
- modes = [Pricing::Mode.normalize(speed), Pricing::Mode.normalize(service_tier)]
36
- modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
37
- Pricing::Mode.compose(modes)
38
- end
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
33
+
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
39
39
 
40
- def self.service_line_items(usage)
41
- server_tool_use = usage[:server_tool_use]
42
- return [] unless server_tool_use.is_a?(Hash)
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
43
52
 
44
- SERVER_TOOL_LINE_ITEMS.filter_map do |dimension_key, count_key|
45
- quantity = server_tool_use[count_key].to_i
46
- next if quantity.zero?
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)
47
56
 
48
- Charges::LineItem.build(
49
- dimension_key: dimension_key,
50
- quantity: quantity,
51
- cost_status: Charges::CostStatus::UNKNOWN,
57
+ [Charges::LineItem.build(
58
+ dimension_key: "billed_request",
59
+ quantity: 1,
60
+ rate_amount: 0,
61
+ cost: 0,
52
62
  pricing_basis: "provider_usage",
53
- provider_field: "usage.server_tool_use.#{count_key}"
54
- )
63
+ price_source: "provider_response",
64
+ provider_field: "stop_details.category",
65
+ details: { category: refusal_category&.to_s }.compact
66
+ )]
55
67
  end
56
- end
57
68
 
58
- def self.cache_writes(usage)
59
- cache_creation = usage[:cache_creation]
60
- if cache_creation.is_a?(Hash)
61
- [cache_creation[:ephemeral_5m_input_tokens].to_i, cache_creation[:ephemeral_1h_input_tokens].to_i]
62
- else
63
- warn_unexpected_cache_creation(cache_creation, usage)
64
- [usage[:cache_creation_input_tokens].to_i, 0]
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)
65
73
  end
66
- end
67
74
 
68
- def self.warn_unexpected_cache_creation(cache_creation, usage)
69
- return if cache_creation.nil?
70
- return if usage.key?(:cache_creation_input_tokens)
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
79
+
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
96
+ end
71
97
 
72
- Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
98
+ private
99
+
100
+ def other_model_iterations(usage, content)
101
+ iterations(usage, "advisor_message") + billed_fallback_iterations(usage, content)
102
+ end
103
+
104
+ def billed_fallback_iterations(usage, content)
105
+ served = served_model(usage, content)
106
+ return [] unless served
107
+
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
113
+
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
118
+
119
+ def billed_hop?(hop, category)
120
+ hop.any? { |entry| entry[:output_tokens].to_i.positive? } || BILLED_REFUSAL_CATEGORIES.include?(category)
121
+ end
122
+
123
+ def fallback_blocks(content)
124
+ Array(content).select { |block| block.is_a?(Hash) && block[:type].to_s == "fallback" }
125
+ end
126
+
127
+ def iterations(usage, type)
128
+ Array(usage[:iterations]).select { |entry| entry.is_a?(Hash) && entry[:type].to_s == type }
129
+ end
130
+
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
155
+ end
156
+
157
+ def warn_unexpected_cache_creation(cache_creation, usage)
158
+ return if cache_creation.nil?
159
+ return if usage.key?(:cache_creation_input_tokens)
160
+
161
+ Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
162
+ end
73
163
  end
74
164
  end
75
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,14 +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
- TRACKED_ENDPOINTS = %w[
10
- chat/completions completions embeddings moderations responses
11
- audio/transcriptions audio/translations audio/speech
12
- images/generations images/edits images/variations
13
- ].freeze
14
-
15
- PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
10
+ PATH_PATTERN = %r{
11
+ \A/openai/(?:deployments/[^/]+|v1)/(?:#{Openai::Parser::TRACKED_ENDPOINTS.join('|')}|responses/resp_[^/]+)\z
12
+ }x
13
+ DEPLOYMENT_PATH_PATTERN = %r{/openai/deployments/([^/]+)/}
16
14
 
17
15
  class << self
18
16
  def match?(url)
@@ -31,12 +29,7 @@ module LlmCostTracker
31
29
  end
32
30
 
33
31
  def model_for(request_url, request_parsed)
34
- body_model = super
35
- return body_model if body_model
36
-
37
- uri = parsed_uri(request_url)
38
- match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
39
- match && match[1]
32
+ super || parsed_uri(request_url)&.path.to_s[DEPLOYMENT_PATH_PATTERN, 1]
40
33
  end
41
34
 
42
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