llm_cost_tracker 0.10.0 → 0.12.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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +11 -5
  4. data/app/assets/llm_cost_tracker/application.css +784 -802
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
  9. data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
  12. data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  14. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  16. data/app/models/llm_cost_tracker/call.rb +28 -63
  17. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  18. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  19. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  20. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  21. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  22. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  23. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  24. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  25. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  26. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  27. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
  28. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
  29. data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
  30. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
  31. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  32. data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
  33. data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
  34. data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
  35. data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
  36. data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
  37. data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
  38. data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
  39. data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
  40. data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
  41. data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
  42. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
  43. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
  44. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
  45. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
  46. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
  47. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
  48. data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
  49. data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
  50. data/config/routes.rb +3 -3
  51. data/lib/llm_cost_tracker/budget.rb +25 -28
  52. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  53. data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
  54. data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
  55. data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
  56. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  57. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  58. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  59. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  60. data/lib/llm_cost_tracker/check.rb +5 -0
  61. data/lib/llm_cost_tracker/configuration.rb +13 -61
  62. data/lib/llm_cost_tracker/currency.rb +5 -0
  63. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  64. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  65. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  66. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  67. data/lib/llm_cost_tracker/doctor.rb +66 -64
  68. data/lib/llm_cost_tracker/engine.rb +4 -4
  69. data/lib/llm_cost_tracker/event.rb +12 -20
  70. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  71. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  72. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  73. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  74. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
  75. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  76. data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
  77. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  78. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  79. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  80. data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
  81. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  82. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  83. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  84. data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
  85. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
  86. data/lib/llm_cost_tracker/integrations.rb +32 -25
  87. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  88. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  89. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  90. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  91. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  92. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  93. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  94. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  95. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  96. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  97. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  98. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  99. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  100. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  101. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  102. data/lib/llm_cost_tracker/ledger.rb +14 -11
  103. data/lib/llm_cost_tracker/logging.rb +4 -21
  104. data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
  105. data/lib/llm_cost_tracker/parsers.rb +140 -29
  106. data/lib/llm_cost_tracker/prices.json +1707 -1
  107. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  108. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  109. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  110. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  111. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  112. data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
  113. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  114. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  115. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  116. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  117. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  118. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  119. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  120. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  121. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  122. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  123. data/lib/llm_cost_tracker/pricing.rb +10 -295
  124. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  125. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  126. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  127. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  128. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  129. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  130. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  131. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  132. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  133. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  134. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  135. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  136. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
  137. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  138. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  139. data/lib/llm_cost_tracker/providers.rb +35 -0
  140. data/lib/llm_cost_tracker/railtie.rb +0 -7
  141. data/lib/llm_cost_tracker/report/data.rb +3 -4
  142. data/lib/llm_cost_tracker/report/formatter.rb +33 -20
  143. data/lib/llm_cost_tracker/report.rb +1 -1
  144. data/lib/llm_cost_tracker/retention.rb +6 -19
  145. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  146. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  147. data/lib/llm_cost_tracker/timing.rb +2 -4
  148. data/lib/llm_cost_tracker/tracker.rb +24 -36
  149. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  150. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  151. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  152. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  153. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  154. data/lib/llm_cost_tracker/version.rb +1 -1
  155. data/lib/llm_cost_tracker.rb +43 -52
  156. data/lib/tasks/llm_cost_tracker.rake +14 -73
  157. metadata +92 -58
  158. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
  159. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  160. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  161. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  162. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  163. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
  164. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
  165. data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
  166. data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
  167. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  168. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  169. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  170. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  171. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  172. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  173. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  174. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  175. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  176. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  177. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  178. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
  179. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
  180. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  181. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  182. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  183. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  184. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  185. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  186. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  187. data/lib/llm_cost_tracker/masking.rb +0 -39
  188. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
  189. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  190. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  191. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
  192. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  193. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
  194. data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
  195. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  196. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  197. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  198. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  199. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  200. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
  201. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  202. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  203. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  204. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
  205. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
  206. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  207. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
  208. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  209. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,193 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require_relative "../providers/anthropic/tier_classification"
5
-
6
- module LlmCostTracker
7
- module Parsers
8
- class Anthropic < Base
9
- HOSTS = %w[api.anthropic.com].freeze
10
-
11
- class << self
12
- def match?(url)
13
- match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
14
- end
15
-
16
- def provider_names
17
- %w[anthropic]
18
- end
19
- end
20
-
21
- def parse(request_body:, response_status:, response_body:, **)
22
- return nil unless response_status == 200
23
-
24
- response = safe_json_parse(response_body)
25
- usage = response["usage"]
26
- return nil unless usage
27
-
28
- request = safe_json_parse(request_body)
29
- cache_read = usage["cache_read_input_tokens"].to_i
30
-
31
- Event.build(
32
- provider: "anthropic",
33
- provider_response_id: response["id"],
34
- pricing_mode: pricing_mode(request: request, usage: usage),
35
- model: response["model"] || request["model"],
36
- token_usage: token_usage(usage: usage, cache_read: cache_read),
37
- usage_source: :response,
38
- service_line_items: service_line_items(usage)
39
- )
40
- end
41
-
42
- def parse_stream(response_status:, request_body: nil, events: [], **)
43
- return nil unless response_status == 200
44
-
45
- request = safe_json_parse(request_body)
46
- model = find_event_value(events) { |data| data.dig("message", "model") } || request["model"]
47
- usage = stream_usage(events)
48
- response_id = find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
49
-
50
- if usage
51
- build_stream_result(
52
- model: model,
53
- usage: usage,
54
- response_id: response_id,
55
- pricing_mode: pricing_mode(request: request, usage: usage)
56
- )
57
- else
58
- build_unknown_stream_usage(
59
- provider: "anthropic",
60
- model: model,
61
- provider_response_id: response_id,
62
- pricing_mode: pricing_mode(request: request, usage: usage)
63
- )
64
- end
65
- end
66
-
67
- def provider_for(_request_url)
68
- "anthropic"
69
- end
70
-
71
- private
72
-
73
- def stream_usage(events)
74
- latest_delta = find_event_value(events, reverse: true) do |data|
75
- data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
76
- end
77
- return nil unless latest_delta
78
-
79
- start_usage = find_event_value(events, reverse: true) do |data|
80
- data.dig("message", "usage") if data["type"] == "message_start"
81
- end
82
-
83
- (start_usage || {}).merge(latest_delta) do |_key, start_val, delta_val|
84
- delta_val || start_val
85
- end
86
- end
87
-
88
- def build_stream_result(model:, usage:, response_id:, pricing_mode:)
89
- cache_read = usage["cache_read_input_tokens"].to_i
90
-
91
- Event.build(
92
- provider: "anthropic",
93
- provider_response_id: response_id,
94
- pricing_mode: pricing_mode,
95
- model: model,
96
- token_usage: token_usage(usage: usage, cache_read: cache_read),
97
- stream: true,
98
- usage_source: :stream_final,
99
- service_line_items: service_line_items(usage)
100
- )
101
- end
102
-
103
- def service_line_items(usage)
104
- server_tool_use = usage["server_tool_use"]
105
- return [] unless server_tool_use.is_a?(Hash)
106
-
107
- [
108
- service_line_item(
109
- component_key: :web_search_request,
110
- quantity: server_tool_use["web_search_requests"],
111
- provider_field: "usage.server_tool_use.web_search_requests"
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
- ),
118
- service_line_item(
119
- component_key: :code_execution_request,
120
- quantity: server_tool_use["code_execution_requests"],
121
- provider_field: "usage.server_tool_use.code_execution_requests"
122
- )
123
- ].compact
124
- end
125
-
126
- def service_line_item(component_key:, quantity:, provider_field:)
127
- quantity = quantity.to_i
128
- return if quantity.zero?
129
-
130
- Billing::LineItem.build(
131
- component_key: component_key,
132
- quantity: quantity,
133
- cost_status: Billing::CostStatus::UNKNOWN,
134
- pricing_basis: :provider_usage,
135
- provider_field: provider_field
136
- )
137
- end
138
-
139
- def token_usage(usage:, cache_read:)
140
- input = usage["input_tokens"].to_i
141
- output = usage["output_tokens"].to_i
142
- cache_creation = usage["cache_creation"]
143
- if cache_creation.is_a?(Hash)
144
- cache_write = cache_creation["ephemeral_5m_input_tokens"].to_i
145
- cache_write_extended = cache_creation["ephemeral_1h_input_tokens"].to_i
146
- else
147
- warn_unexpected_cache_creation(cache_creation, usage)
148
- cache_write = usage["cache_creation_input_tokens"].to_i
149
- cache_write_extended = 0
150
- end
151
- hidden_output = (
152
- usage["thinking_tokens"] || usage["thinking_output_tokens"] ||
153
- usage.dig("output_tokens_details", "reasoning_tokens")
154
- ).to_i
155
-
156
- TokenUsage.build(
157
- input_tokens: input,
158
- output_tokens: output,
159
- total_tokens: input + output + cache_read + cache_write + cache_write_extended,
160
- cache_read_input_tokens: cache_read,
161
- cache_write_input_tokens: cache_write,
162
- cache_write_extended_input_tokens: cache_write_extended,
163
- hidden_output_tokens: hidden_output
164
- )
165
- end
166
-
167
- def warn_unexpected_cache_creation(cache_creation, usage)
168
- return if cache_creation.nil? || usage.key?("cache_creation_input_tokens")
169
-
170
- Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
171
- end
172
-
173
- def pricing_mode(request:, usage:)
174
- modes = []
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)
178
-
179
- modes << Pricing.normalize_mode(speed)
180
- modes << Pricing.normalize_mode(service_tier)
181
- geo = inference_geo(request: request, usage: usage).downcase
182
- modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
183
-
184
- modes = modes.compact.uniq
185
- modes.empty? ? nil : modes.join("_")
186
- end
187
-
188
- def inference_geo(request:, usage:)
189
- (usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
190
- end
191
- end
192
- end
193
- end
@@ -1,46 +0,0 @@
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
@@ -1,131 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/core_ext/object/blank"
4
- require "json"
5
- require "uri"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- module UrlMatchers
10
- def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
11
- uri_matches?(url) do |uri|
12
- host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
13
- path_match = path_matches?(
14
- uri,
15
- exact_paths: exact_paths,
16
- path_includes: path_includes,
17
- path_suffixes: path_suffixes,
18
- path_pattern: path_pattern
19
- )
20
- extra_match = block_given? ? yield(uri) : true
21
-
22
- next false unless host_match && path_match
23
- next false unless extra_match
24
-
25
- true
26
- end
27
- end
28
-
29
- def uri_matches?(url)
30
- uri = parsed_uri(url)
31
- uri ? yield(uri) : false
32
- end
33
-
34
- def parsed_uri(url)
35
- URI.parse(url.to_s)
36
- rescue URI::InvalidURIError
37
- nil
38
- end
39
-
40
- def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
41
- path = uri.path.to_s
42
- matches = true
43
-
44
- matches &&= exact_paths.include?(path) if exact_paths
45
- matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
46
- matches &&= path.match?(path_pattern) if path_pattern
47
-
48
- matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
49
-
50
- matches
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
97
-
98
- def each_event_data(events, reverse: false)
99
- enumerator = reverse ? events.reverse_each : events.each
100
-
101
- enumerator.each do |event|
102
- data = event[:data]
103
- yield data if data.is_a?(Hash)
104
- end
105
- end
106
-
107
- def find_event_value(events, reverse: false)
108
- each_event_data(events, reverse:) do |data|
109
- value = yield(data)
110
- return value if value.present?
111
- end
112
-
113
- nil
114
- end
115
-
116
- def build_unknown_stream_usage(provider:, model:, provider_response_id:, pricing_mode: nil,
117
- service_line_items: nil)
118
- Event.build(
119
- provider: provider,
120
- provider_response_id: provider_response_id,
121
- pricing_mode: pricing_mode,
122
- model: model || Event::UNKNOWN_MODEL,
123
- token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
124
- stream: true,
125
- usage_source: :unknown,
126
- service_line_items: service_line_items
127
- )
128
- end
129
- end
130
- end
131
- end
@@ -1,232 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../billing/line_item"
4
- require_relative "base"
5
- require_relative "../providers/gemini/model_families"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- class Gemini < Base
10
- HOSTS = %w[generativelanguage.googleapis.com].freeze
11
- TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
12
- STREAM_PATH_PATTERN = /:streamGenerateContent\z/
13
-
14
- class << self
15
- def match?(url)
16
- match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
17
- end
18
-
19
- def provider_names
20
- %w[gemini]
21
- end
22
- end
23
-
24
- def streaming_request?(request_url, request_parsed)
25
- return true if match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
26
-
27
- super
28
- end
29
-
30
- def parse(request_url:, request_body:, response_status:, response_body:, response_headers: nil)
31
- return nil unless response_status == 200
32
-
33
- response = safe_json_parse(response_body)
34
- usage = response["usageMetadata"]
35
- return nil unless usage
36
-
37
- request = safe_json_parse(request_body)
38
- model = extract_model_from_url(request_url)
39
- build_event(
40
- request_url: request_url,
41
- usage: usage,
42
- usage_source: :response,
43
- provider_response_id: response["responseId"],
44
- pricing_mode: pricing_mode(request: request, response_headers: response_headers),
45
- service_line_items: grounding_line_items(grounding_request_count(response["candidates"]), model: model)
46
- )
47
- end
48
-
49
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], response_headers: nil)
50
- return nil unless response_status == 200
51
-
52
- request = safe_json_parse(request_body)
53
- usage = merged_stream_usage(events)
54
- model = extract_model_from_url(request_url)
55
- response_id = stream_response_id(events)
56
- mode = pricing_mode(request: request, response_headers: response_headers)
57
- service_line_items = grounding_line_items_for_stream(events, model: model)
58
-
59
- if usage
60
- build_event(
61
- request_url: request_url,
62
- usage: usage,
63
- stream: true,
64
- usage_source: :stream_final,
65
- provider_response_id: response_id,
66
- pricing_mode: mode,
67
- service_line_items: service_line_items
68
- )
69
- else
70
- build_unknown_stream_usage(
71
- provider: "gemini",
72
- model: model,
73
- provider_response_id: response_id,
74
- pricing_mode: mode,
75
- service_line_items: service_line_items
76
- )
77
- end
78
- end
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
-
88
- private
89
-
90
- def build_event(request_url:, usage:, usage_source:, stream: false, provider_response_id: nil,
91
- pricing_mode: nil, service_line_items: nil)
92
- cache_read = usage["cachedContentTokenCount"].to_i
93
- tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
94
- audio_input = audio_input_tokens(usage)
95
- audio_output = audio_output_tokens(usage)
96
-
97
- Event.build(
98
- provider: "gemini",
99
- model: extract_model_from_url(request_url),
100
- pricing_mode: pricing_mode,
101
- token_usage: TokenUsage.build(
102
- input_tokens: regular_input_tokens(usage: usage, cache_read: cache_read, audio_input: audio_input) +
103
- tool_use_prompt,
104
- output_tokens: regular_output_tokens(usage: usage, audio_output: audio_output),
105
- total_tokens: usage["totalTokenCount"],
106
- cache_read_input_tokens: cache_read,
107
- audio_input_tokens: audio_input,
108
- audio_output_tokens: audio_output,
109
- hidden_output_tokens: usage["thoughtsTokenCount"]
110
- ),
111
- stream: stream,
112
- usage_source: usage_source,
113
- provider_response_id: provider_response_id,
114
- service_line_items: service_line_items
115
- )
116
- end
117
-
118
- def merged_stream_usage(events)
119
- find_event_value(events, reverse: true) do |data|
120
- meta = data["usageMetadata"]
121
- meta if meta.is_a?(Hash)
122
- end
123
- end
124
-
125
- def output_tokens(usage)
126
- (usage["candidatesTokenCount"] || usage["responseTokenCount"]).to_i + usage["thoughtsTokenCount"].to_i
127
- end
128
-
129
- def regular_input_tokens(usage:, cache_read:, audio_input:)
130
- [usage["promptTokenCount"].to_i - cache_read - audio_input, 0].max
131
- end
132
-
133
- def regular_output_tokens(usage:, audio_output:)
134
- [output_tokens(usage) - audio_output, 0].max
135
- end
136
-
137
- def audio_input_tokens(usage)
138
- prompt_audio = modality_tokens(usage["promptTokensDetails"] || usage["prompt_tokens_details"], "AUDIO")
139
- cache_audio = modality_tokens(usage["cacheTokensDetails"] || usage["cache_tokens_details"], "AUDIO")
140
- [prompt_audio - cache_audio, 0].max
141
- end
142
-
143
- def audio_output_tokens(usage)
144
- modality_tokens(
145
- usage["candidatesTokensDetails"] ||
146
- usage["candidates_tokens_details"] ||
147
- usage["responseTokensDetails"] ||
148
- usage["response_tokens_details"],
149
- "AUDIO"
150
- )
151
- end
152
-
153
- def modality_tokens(details, modality)
154
- Array(details).sum do |detail|
155
- next 0 unless detail.is_a?(Hash)
156
-
157
- next 0 unless detail["modality"] == modality
158
-
159
- (detail["tokenCount"] || detail["token_count"]).to_i
160
- end
161
- end
162
-
163
- def stream_response_id(events)
164
- find_event_value(events) { |data| data["responseId"] }
165
- end
166
-
167
- def extract_model_from_url(url)
168
- uri = parsed_uri(url)
169
- return nil unless uri
170
-
171
- match = uri.path.match(%r{/models/([^/:]+)})
172
- match && match[1]
173
- end
174
-
175
- def pricing_mode(request:, response_headers:)
176
- response_tier = response_header(response_headers, "x-gemini-service-tier")
177
- response_mode = Pricing.normalize_mode(response_tier)
178
- return response_mode if response_mode
179
-
180
- request_mode = Pricing.normalize_mode(
181
- request["service_tier"] ||
182
- request["serviceTier"] ||
183
- request.dig("config", "service_tier") ||
184
- request.dig("config", "serviceTier")
185
- )
186
- request_mode == :flex ? request_mode : nil
187
- end
188
-
189
- def response_header(headers, name)
190
- headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
191
- end
192
-
193
- def grounding_line_items_for_stream(events, model:)
194
- quantity = find_event_value(events, reverse: true) do |data|
195
- count = grounding_request_count(data["candidates"])
196
- count if count.positive?
197
- end
198
- grounding_line_items(quantity || 0, model: model)
199
- end
200
-
201
- def grounding_request_count(candidates)
202
- Array(candidates).sum do |candidate|
203
- next 0 unless candidate.is_a?(Hash)
204
-
205
- metadata = candidate["groundingMetadata"] || candidate["grounding_metadata"] || {}
206
- queries = metadata["webSearchQueries"] || metadata["web_search_queries"] || []
207
- Array(queries).size
208
- end
209
- end
210
-
211
- def grounding_line_items(query_count, model:)
212
- return [] unless query_count.positive?
213
-
214
- billed_quantity = grounding_billed_quantity(query_count, model: model)
215
- [
216
- Billing::LineItem.build(
217
- component_key: :grounding_request,
218
- quantity: billed_quantity,
219
- cost_status: Billing::CostStatus::UNKNOWN,
220
- pricing_basis: :provider_usage,
221
- provider_field: "response.candidates.groundingMetadata.webSearchQueries",
222
- details: { web_search_queries: query_count }
223
- )
224
- ]
225
- end
226
-
227
- def grounding_billed_quantity(query_count, model:)
228
- LlmCostTracker::Providers::Gemini::ModelFamilies.per_query_grounding?(model) ? query_count : 1
229
- end
230
- end
231
- end
232
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require_relative "openai_usage"
5
- require_relative "../providers/openai/hosts"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- class Openai < Base
10
- include OpenaiUsage
11
-
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
24
- ].freeze
25
-
26
- class << self
27
- def match?(url)
28
- match_uri?(url, hosts: Providers::Openai::Hosts::API_HOSTS, exact_paths: TRACKED_PATHS)
29
- end
30
-
31
- def provider_names
32
- %w[openai]
33
- end
34
- end
35
-
36
- def provider_for(_request_url)
37
- "openai"
38
- end
39
- end
40
- end
41
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "base"
4
- require_relative "openai_usage"
5
-
6
- module LlmCostTracker
7
- module Parsers
8
- class OpenaiCompatible < Base
9
- include OpenaiUsage
10
-
11
- TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
12
-
13
- class << self
14
- def match?(url)
15
- match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
16
- end
17
-
18
- def provider_names
19
- providers = LlmCostTracker.configuration.openai_compatible_providers
20
- cached = @provider_names
21
- return cached if cached && @provider_names_providers.equal?(providers)
22
-
23
- names = [
24
- "openai_compatible",
25
- *providers.each_value.map { |provider| provider.to_s.downcase }
26
- ].uniq.freeze
27
- return names unless providers.frozen?
28
-
29
- @provider_names_providers = providers
30
- @provider_names = names
31
- end
32
-
33
- def provider_for(request_url)
34
- provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
35
- end
36
-
37
- private
38
-
39
- def provider_for_uri(uri)
40
- return nil unless uri
41
-
42
- LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
43
- end
44
- end
45
-
46
- def provider_for(request_url)
47
- self.class.provider_for(request_url)
48
- end
49
- end
50
- end
51
- end