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,155 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../billing/line_item"
4
- require_relative "../providers/openai/model_families"
5
-
6
- module LlmCostTracker
7
- module Parsers
8
- module OpenaiServiceCharges
9
- RESPONSE_OUTPUT_COMPONENTS = {
10
- "web_search_call" => :web_search_request,
11
- "file_search_call" => :file_search_call,
12
- "code_interpreter_call" => :container_session,
13
- "mcp_call" => :mcp_call
14
- }.freeze
15
-
16
- module_function
17
-
18
- def line_items_from_output(output_items, request: nil, model: nil)
19
- deduped = {}
20
- Array(output_items).each { |item| store_output_item(deduped, item) }
21
- deduped.values
22
- .select { |item| billable?(item) }
23
- .filter_map { |item| build_line_item(item, request: request, model: model) }
24
- end
25
-
26
- def service_line_items_for(response, request: nil, model: nil)
27
- output_items = Array(response["output"])
28
- output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
29
- line_items_from_output(output_items, request: request, model: model)
30
- end
31
-
32
- CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
33
- CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
34
-
35
- def chat_completions_web_search_items(response, model: nil)
36
- return [] unless response["choices"]
37
-
38
- provider_field = chat_completions_search_provider_field(response["choices"], model)
39
- return [] unless provider_field
40
-
41
- [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
42
- "provider_field" => provider_field }]
43
- end
44
-
45
- def chat_completions_search_provider_field(choices, model)
46
- return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
47
- return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
48
-
49
- nil
50
- end
51
-
52
- def chat_completions_used_web_search?(choices)
53
- Array(choices).any? do |choice|
54
- Array(choice.dig("message", "annotations")).any? do |annotation|
55
- annotation.is_a?(Hash) && annotation["type"] == "url_citation"
56
- end
57
- end
58
- end
59
-
60
- def billable?(item)
61
- return false unless item.is_a?(Hash)
62
-
63
- component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
64
- return false unless component
65
- return true unless component == :web_search_request
66
-
67
- action_type = item.dig("action", "type")
68
- action_type.nil? || action_type == "search"
69
- end
70
-
71
- def store_output_item(output_items, item)
72
- return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
73
-
74
- component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
75
- key = if component == :container_session && item["container_id"]
76
- "#{component}:#{item['container_id']}"
77
- else
78
- item["id"] || "#{item['type']}:#{output_items.length}"
79
- end
80
- output_items[key] = item
81
- end
82
-
83
- def build_line_item(item, request: nil, model: nil)
84
- return nil unless item.is_a?(Hash)
85
-
86
- component_key = component_key_for(item, request: request, model: model)
87
- return nil unless component_key
88
-
89
- provider_item_id = if component_key == :container_session
90
- item["container_id"] || item["id"]
91
- else
92
- item["id"]
93
- end
94
- Billing::LineItem.build(
95
- component_key: component_key,
96
- quantity: 1,
97
- cost_status: Billing::CostStatus::UNKNOWN,
98
- pricing_basis: :provider_usage,
99
- provider_field: item["provider_field"] || "response.output.#{item['type']}",
100
- provider_item_id: provider_item_id,
101
- details: line_item_details(item)
102
- )
103
- end
104
-
105
- def component_key_for(item, request:, model:)
106
- component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
107
- return component unless component == :web_search_request
108
- return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
109
-
110
- reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
111
- end
112
-
113
- def web_search_preview_used?(request)
114
- tools = request && (request[:tools] || request["tools"])
115
- return false unless tools.respond_to?(:each)
116
-
117
- tools.any? do |tool|
118
- type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
119
- type.to_s.include?("web_search_preview")
120
- end
121
- end
122
-
123
- def chat_completions_search_model?(model)
124
- return false unless model
125
-
126
- name = model.to_s.split("/", 2).last
127
- LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(name)
128
- end
129
-
130
- def reasoning_model?(model)
131
- return false unless model
132
-
133
- name = model.to_s.split("/", 2).last
134
- LlmCostTracker::Providers::Openai::ModelFamilies.reasoning?(name)
135
- end
136
-
137
- def line_item_details(item)
138
- {
139
- "status" => item["status"],
140
- "action_type" => item.dig("action", "type"),
141
- "container_id" => item["container_id"]
142
- }.compact
143
- end
144
-
145
- def openai_stream_service_line_items(events, request: nil, model: nil)
146
- output_items = []
147
- each_event_data(events) do |data|
148
- output_items.concat(Array(data.dig("response", "output")))
149
- output_items << data["item"] if data["item"]
150
- end
151
- line_items_from_output(output_items, request: request, model: model)
152
- end
153
- end
154
- end
155
- end
@@ -1,228 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "openai_service_charges"
4
- require_relative "../providers/openai/hosts"
5
- require_relative "../providers/openai/model_families"
6
-
7
- module LlmCostTracker
8
- module Parsers
9
- module OpenaiUsage
10
- include OpenaiServiceCharges
11
-
12
- class << self
13
- def combined_pricing_mode(host:, model:, service_tier:)
14
- modes = [Pricing.normalize_mode(service_tier)]
15
- modes << "data_residency" if regional_processing?(host: host, model: model)
16
- modes = modes.compact.uniq
17
- modes.empty? ? nil : modes.join("_")
18
- end
19
-
20
- def regional_processing?(host:, model:)
21
- LlmCostTracker::Providers::Openai::Hosts.data_residency?(host) &&
22
- LlmCostTracker::Providers::Openai::ModelFamilies.data_residency?(model)
23
- end
24
- end
25
-
26
- def parse(request_url:, request_body:, response_status:, response_body:, **)
27
- return nil unless response_status == 200
28
-
29
- response = safe_json_parse(response_body)
30
- usage = response["usage"]
31
- return nil unless usage
32
-
33
- request = safe_json_parse(request_body)
34
- cache_read = cache_read_input_tokens(usage)
35
-
36
- model = response["model"] || request["model"]
37
-
38
- Event.build(
39
- provider: provider_for(request_url),
40
- provider_response_id: response["id"],
41
- pricing_mode: pricing_mode(
42
- request_url: request_url,
43
- model: model,
44
- service_tier: response["service_tier"] || request["service_tier"]
45
- ),
46
- model: model,
47
- token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
48
- usage_source: :response,
49
- service_line_items: service_line_items_for(response, request: request, model: response["model"])
50
- )
51
- end
52
-
53
- def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
54
- return nil unless response_status == 200
55
-
56
- request = safe_json_parse(request_body)
57
- usage = detect_stream_usage(events)
58
- context = stream_capture_context(events: events, request: request, request_url: request_url)
59
-
60
- return build_known_stream_usage(usage: usage, **context) if usage
61
-
62
- warn_missing_stream_usage(request_url: request_url, request: request)
63
- build_unknown_stream_usage(**context)
64
- end
65
-
66
- def auto_enable_stream_usage?(request_url)
67
- openai_chat_completions_url?(request_url)
68
- end
69
-
70
- private
71
-
72
- def stream_capture_context(events:, request:, request_url:)
73
- model = find_event_value(events) do |data|
74
- data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
75
- end || request["model"]
76
- {
77
- provider: provider_for(request_url),
78
- model: model,
79
- provider_response_id: find_event_value(events) do |data|
80
- data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
81
- end,
82
- pricing_mode: pricing_mode(
83
- request_url: request_url,
84
- model: model,
85
- service_tier: stream_pricing_mode(events) || request["service_tier"]
86
- ),
87
- service_line_items: openai_stream_service_line_items(events, request: request, model: model)
88
- }
89
- end
90
-
91
- def build_known_stream_usage(usage:, provider:, model:, provider_response_id:, pricing_mode:, service_line_items:)
92
- cache_read = cache_read_input_tokens(usage)
93
- Event.build(
94
- provider: provider,
95
- provider_response_id: provider_response_id,
96
- pricing_mode: pricing_mode,
97
- model: model,
98
- token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
99
- stream: true,
100
- usage_source: :stream_final,
101
- service_line_items: service_line_items
102
- )
103
- end
104
-
105
- def warn_missing_stream_usage(request_url:, request:)
106
- return unless request.is_a?(Hash) && request["stream"]
107
- return unless openai_chat_completions_url?(request_url)
108
- return if request.dig("stream_options", "include_usage")
109
-
110
- Logging.warn(
111
- "OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
112
- "Set `stream_options: { include_usage: true }` in your request body so the gem can " \
113
- "record token counts. This call was stored with usage_source=unknown."
114
- )
115
- end
116
-
117
- def openai_chat_completions_url?(request_url)
118
- uri = parsed_uri(request_url)
119
- uri && uri.path.to_s.end_with?("/chat/completions")
120
- end
121
-
122
- def detect_stream_usage(events)
123
- find_event_value(events, reverse: true) do |data|
124
- usage = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage")
125
- usage if usage.is_a?(Hash)
126
- end
127
- end
128
-
129
- def stream_pricing_mode(events)
130
- find_event_value(events, reverse: true) do |data|
131
- data["service_tier"] || data.dig("response", "service_tier") || data.dig("chunk", "service_tier")
132
- end
133
- end
134
-
135
- def pricing_mode(request_url:, model:, service_tier:)
136
- OpenaiUsage.combined_pricing_mode(host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier)
137
- end
138
-
139
- def token_usage(usage:, cache_read:, model: nil)
140
- audio_input = audio_input_tokens(usage)
141
- audio_output = audio_output_tokens(usage)
142
- image_input = image_input_tokens(usage)
143
- image_output_details = image_output_tokens(usage)
144
- text_output_details = text_output_tokens(usage)
145
- raw_output = (usage["completion_tokens"] || usage["output_tokens"]).to_i
146
- image_output, regular_output_remainder = split_stream_image_output(
147
- raw_output: raw_output, image_output_details: image_output_details,
148
- text_output_details: text_output_details, audio_output: audio_output,
149
- default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
150
- )
151
-
152
- TokenUsage.build(
153
- input_tokens: regular_input_tokens(
154
- usage: usage, cache_read: cache_read, audio_input: audio_input, image_input: image_input
155
- ),
156
- output_tokens: regular_output_remainder,
157
- total_tokens: usage["total_tokens"],
158
- cache_read_input_tokens: cache_read,
159
- audio_input_tokens: audio_input,
160
- audio_output_tokens: audio_output,
161
- image_input_tokens: image_input,
162
- image_output_tokens: image_output,
163
- hidden_output_tokens: hidden_output_tokens(usage)
164
- )
165
- end
166
-
167
- def split_stream_image_output(raw_output:, image_output_details:, text_output_details:, audio_output:,
168
- default_to_image: false)
169
- if image_output_details.zero? && text_output_details.zero?
170
- remainder = [raw_output - audio_output, 0].max
171
- return default_to_image ? [remainder, 0] : [0, remainder]
172
- end
173
-
174
- text_output = text_output_details
175
- text_output = [raw_output - image_output_details - audio_output, 0].max if text_output.zero?
176
- [image_output_details, text_output]
177
- end
178
-
179
- def regular_input_tokens(usage:, cache_read:, audio_input:, image_input:)
180
- raw = (usage["prompt_tokens"] || usage["input_tokens"]).to_i
181
- [raw - cache_read - audio_input - image_input, 0].max
182
- end
183
-
184
- def cache_read_input_tokens(usage)
185
- details = input_token_details(usage)
186
- details["cached_tokens"].to_i
187
- end
188
-
189
- def audio_input_tokens(usage)
190
- details = input_token_details(usage)
191
- details["audio_tokens"].to_i
192
- end
193
-
194
- def hidden_output_tokens(usage)
195
- details = output_token_details(usage)
196
- details["reasoning_tokens"].to_i
197
- end
198
-
199
- def audio_output_tokens(usage)
200
- details = output_token_details(usage)
201
- details["audio_tokens"].to_i
202
- end
203
-
204
- def image_input_tokens(usage)
205
- details = input_token_details(usage)
206
- details["image_tokens"].to_i
207
- end
208
-
209
- def image_output_tokens(usage)
210
- details = output_token_details(usage)
211
- details["image_tokens"].to_i
212
- end
213
-
214
- def text_output_tokens(usage)
215
- details = output_token_details(usage)
216
- details["text_tokens"].to_i
217
- end
218
-
219
- def input_token_details(usage)
220
- usage["prompt_tokens_details"] || usage["input_tokens_details"] || usage["input_token_details"] || {}
221
- end
222
-
223
- def output_token_details(usage)
224
- usage["completion_tokens_details"] || usage["output_tokens_details"] || usage["output_token_details"] || {}
225
- end
226
- end
227
- end
228
- end
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../token_usage"
4
- require_relative "effective_prices"
5
-
6
- module LlmCostTracker
7
- module Pricing
8
- Explanation = Data.define(
9
- :provider,
10
- :model,
11
- :pricing_mode,
12
- :source,
13
- :matched_key,
14
- :matched_by,
15
- :prices,
16
- :effective_prices,
17
- :missing_price_keys
18
- ) do
19
- def matched?
20
- !prices.nil?
21
- end
22
-
23
- def complete?
24
- matched? && missing_price_keys.empty?
25
- end
26
-
27
- def message
28
- return "No price entry matched #{provider}/#{model}" unless matched?
29
- return "Matched #{matched_key} from #{source} via #{matched_by}" if complete?
30
-
31
- "Matched #{matched_key} from #{source} via #{matched_by}, but missing #{missing_price_keys.join(', ')}"
32
- end
33
- end
34
-
35
- module Explainer
36
- class << self
37
- def call(provider:, model:, tokens:, pricing_mode: nil)
38
- match = Lookup.call(provider: provider, model: model)
39
-
40
- explanation(
41
- provider: provider,
42
- model: model,
43
- pricing_mode: pricing_mode,
44
- match: match,
45
- usage: TokenUsage.build_from_tokens(tokens)
46
- )
47
- end
48
-
49
- private
50
-
51
- def explanation(provider:, model:, pricing_mode:, match:, usage:)
52
- prices = match&.prices
53
- pricing_mode = Pricing.normalize_mode(pricing_mode)
54
- effective = if prices
55
- EffectivePrices.call(usage: usage, quantities: usage.priced_quantities,
56
- prices: prices, pricing_mode: pricing_mode)
57
- end
58
-
59
- Explanation.new(
60
- provider: provider.to_s,
61
- model: model.to_s,
62
- pricing_mode: pricing_mode,
63
- source: match&.source,
64
- matched_key: match&.key,
65
- matched_by: match&.matched_by,
66
- prices: prices,
67
- effective_prices: effective || {},
68
- missing_price_keys: effective ? effective.filter_map { |key, value| key if value.nil? } : []
69
- )
70
- end
71
- end
72
- end
73
- end
74
- end
@@ -1,236 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LlmCostTracker
4
- module Pricing
5
- module Lookup
6
- Match = Data.define(:source, :key, :prices, :matched_by, :currency)
7
- DEFAULT_CURRENCY = "USD"
8
- MUTEX = Mutex.new
9
- CACHE_MISS = Object.new.freeze
10
- NO_MATCH = Object.new.freeze
11
- LOOKUP_CACHE_LIMIT = 2_048
12
- PRICE_FILE_RECHECK_INTERVAL = 1.0
13
- private_constant :PRICE_FILE_RECHECK_INTERVAL
14
-
15
- class << self
16
- def call(provider:, model:)
17
- provider_name = provider.to_s.presence
18
- model_name = model.to_s
19
- return nil if model_name.empty?
20
-
21
- invalidate_cache_if_prices_file_changed!
22
-
23
- cache_key = [provider_name, model_name]
24
- cached = cached_lookup(cache_key)
25
- return cached unless cached.equal?(CACHE_MISS)
26
-
27
- match = lookup_match(provider_name: provider_name, model_name: model_name)
28
- cache_lookup(cache_key, match)
29
- match
30
- end
31
-
32
- def reset!
33
- MUTEX.synchronize do
34
- reset_prices_caches!(signature: nil)
35
- @prices_file_last_check_at = nil
36
- end
37
- end
38
-
39
- def prices_file_mtime_iso
40
- invalidate_cache_if_prices_file_changed!
41
- signature = @prices_file_signature
42
- return nil unless signature
43
-
44
- cached = @prices_file_iso_cache
45
- return cached[:value] if cached && cached[:mtime] == signature
46
-
47
- MUTEX.synchronize do
48
- cached = @prices_file_iso_cache
49
- return cached[:value] if cached && cached[:mtime] == signature
50
-
51
- iso = signature.utc.iso8601
52
- @prices_file_iso_cache = { mtime: signature, value: iso }.freeze
53
- iso
54
- end
55
- end
56
-
57
- private
58
-
59
- def invalidate_cache_if_prices_file_changed!
60
- path = LlmCostTracker.configuration.prices_file
61
-
62
- unless path
63
- return if @prices_file_signature.nil?
64
-
65
- MUTEX.synchronize { reset_prices_caches!(signature: nil) unless @prices_file_signature.nil? }
66
- return
67
- end
68
-
69
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
- last_check = @prices_file_last_check_at
71
- return if last_check && (now - last_check) < PRICE_FILE_RECHECK_INTERVAL
72
-
73
- signature = File.exist?(path) ? File.mtime(path) : nil
74
- MUTEX.synchronize do
75
- @prices_file_last_check_at = now
76
- reset_prices_caches!(signature: signature) if @prices_file_signature != signature
77
- end
78
- end
79
-
80
- def reset_prices_caches!(signature:)
81
- @prices_cache = nil
82
- @lookup_cache = nil
83
- @sorted_price_keys_cache = nil
84
- @prices_file_iso_cache = nil
85
- @prices_file_signature = signature
86
- end
87
-
88
- def lookup_match(provider_name:, model_name:)
89
- provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
90
- normalized_model = normalize_model_name(model_name)
91
- current = current_price_tables
92
-
93
- ordered_table_lookups(current).each do |source, table|
94
- match = explain_table(
95
- table: table,
96
- source: source,
97
- provider_model: provider_model,
98
- model_name: model_name,
99
- normalized_model: normalized_model
100
- )
101
- return match if match
102
- end
103
- nil
104
- end
105
-
106
- def ordered_table_lookups(current)
107
- [
108
- [:pricing_overrides, current.fetch(:pricing_overrides)],
109
- [:prices_file, current.fetch(:file_prices)],
110
- [:bundled, Registry.builtin_prices]
111
- ]
112
- end
113
-
114
- def current_price_tables
115
- cached = @prices_cache
116
- return cached if cached
117
-
118
- MUTEX.synchronize do
119
- cached = @prices_cache
120
- return cached if cached
121
-
122
- config = LlmCostTracker.configuration
123
- file_prices = Registry.file_prices(config.prices_file)
124
- value = { pricing_overrides: config.pricing_overrides, file_prices: file_prices }.freeze
125
- @prices_cache = value
126
- value
127
- end
128
- end
129
-
130
- def cached_lookup(cache_key)
131
- cached = @lookup_cache
132
- return CACHE_MISS unless cached&.key?(cache_key)
133
-
134
- match = cached.fetch(cache_key)
135
- match.equal?(NO_MATCH) ? nil : match
136
- end
137
-
138
- def cache_lookup(cache_key, match)
139
- MUTEX.synchronize do
140
- values = (@lookup_cache || {}).dup
141
- values.shift while values.size >= LOOKUP_CACHE_LIMIT
142
- values[cache_key] = match || NO_MATCH
143
- @lookup_cache = values.freeze
144
- end
145
- end
146
-
147
- def explain_table(table:, source:, provider_model:, model_name:, normalized_model:)
148
- return nil if table.empty?
149
-
150
- direct_match(table: table, source: source, key: provider_model, matched_by: :provider_model) ||
151
- direct_match(table: table, source: source, key: model_name, matched_by: :model) ||
152
- direct_match(table: table, source: source, key: normalized_model, matched_by: :normalized_model) ||
153
- unique_providerless_lookup(model: normalized_model, table: table, source: source) ||
154
- fuzzy_match(model: provider_model, normalized_model: normalized_model, table: table, source: source) ||
155
- unique_providerless_fuzzy_match(model: normalized_model, table: table, source: source)
156
- end
157
-
158
- def normalize_model_name(model)
159
- model.to_s.split("/").last
160
- end
161
-
162
- def unique_providerless_lookup(model:, table:, source:)
163
- matches = sorted_price_keys(table).select { |key| normalize_model_name(key) == model }
164
- return unless matches.one?
165
-
166
- match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_model)
167
- end
168
-
169
- def fuzzy_match(model:, normalized_model:, table:, source:)
170
- sorted_price_keys(table).each do |key|
171
- if snapshot_variant?(model, key) || snapshot_variant?(normalized_model, key)
172
- return match(table: table, source: source, key: key, matched_by: :dated_snapshot)
173
- end
174
- end
175
-
176
- nil
177
- end
178
-
179
- def unique_providerless_fuzzy_match(model:, table:, source:)
180
- matches = sorted_price_keys(table).select { |key| snapshot_variant?(model, normalize_model_name(key)) }
181
- return unless matches.one?
182
-
183
- match(table: table, source: source, key: matches.first, matched_by: :unique_providerless_dated_snapshot)
184
- end
185
-
186
- def direct_match(table:, source:, key:, matched_by:)
187
- match(table: table, source: source, key: key, matched_by: matched_by) if table.key?(key)
188
- end
189
-
190
- def match(table:, source:, key:, matched_by:)
191
- Match.new(
192
- source: source,
193
- key: key,
194
- prices: table[key],
195
- matched_by: matched_by,
196
- currency: source_currency(source)
197
- )
198
- end
199
-
200
- def source_currency(source)
201
- case source
202
- when :bundled then Registry.metadata["currency"] || DEFAULT_CURRENCY
203
- when :prices_file
204
- Registry.file_metadata(LlmCostTracker.configuration.prices_file)["currency"] || DEFAULT_CURRENCY
205
- else DEFAULT_CURRENCY
206
- end
207
- end
208
-
209
- def snapshot_variant?(model, key)
210
- suffix = model.delete_prefix("#{key}-")
211
- return false if suffix == model
212
-
213
- suffix.match?(/\A(?:\d{4}-\d{2}-\d{2}|\d{8})\z/)
214
- end
215
-
216
- def sorted_price_keys(table)
217
- cached = @sorted_price_keys_cache
218
- existing = cached && cached[table]
219
- return existing if existing
220
-
221
- MUTEX.synchronize do
222
- cached = @sorted_price_keys_cache
223
- existing = cached && cached[table]
224
- return existing if existing
225
-
226
- keys = table.keys.sort_by { |key| -key.length }
227
- next_cache = cached ? cached.dup : {}.compare_by_identity
228
- next_cache[table] = keys
229
- @sorted_price_keys_cache = next_cache.freeze
230
- keys
231
- end
232
- end
233
- end
234
- end
235
- end
236
- end