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
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_families"
4
+
5
+ module LlmCostTracker
6
+ module Providers
7
+ module Openai
8
+ module ServiceCharges
9
+ RESPONSE_OUTPUT_RENAMES = {
10
+ "web_search_call" => "web_search_request",
11
+ "code_interpreter_call" => "container_session"
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def line_items_from_output(output_items, request: nil, model: nil)
17
+ deduped = {}
18
+ Array(output_items).each { |item| store_output_item(deduped, item) }
19
+ deduped.values
20
+ .select { |item| billable?(item) }
21
+ .filter_map { |item| build_line_item(item, request: request, model: model) }
22
+ end
23
+
24
+ def service_line_items_for(response, request: nil, model: nil)
25
+ output_items = Array(response["output"])
26
+ output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
27
+ line_items_from_output(output_items, request: request, model: model)
28
+ end
29
+
30
+ CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
31
+ CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
32
+
33
+ def chat_completions_web_search_items(response, model: nil)
34
+ return [] unless response["choices"]
35
+
36
+ provider_field = chat_completions_search_provider_field(response["choices"], model)
37
+ return [] unless provider_field
38
+
39
+ [{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
40
+ "provider_field" => provider_field }]
41
+ end
42
+
43
+ def chat_completions_search_provider_field(choices, model)
44
+ return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
45
+ return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
46
+
47
+ nil
48
+ end
49
+
50
+ def chat_completions_used_web_search?(choices)
51
+ Array(choices).any? do |choice|
52
+ Array(choice.dig("message", "annotations")).any? do |annotation|
53
+ annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
54
+ end
55
+ end
56
+ end
57
+
58
+ def billable?(item)
59
+ return false unless item.is_a?(Hash)
60
+
61
+ dimension = output_dimension(item["type"])
62
+ return false unless dimension
63
+ return true unless dimension == "web_search_request"
64
+
65
+ action_type = item.dig("action", "type")
66
+ action_type.nil? || action_type == "search"
67
+ end
68
+
69
+ def store_output_item(output_items, item)
70
+ return unless item.is_a?(Hash)
71
+
72
+ dimension = output_dimension(item["type"])
73
+ return unless dimension
74
+
75
+ key = if dimension == "container_session" && item["container_id"]
76
+ "#{dimension}:#{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
+ dimension_key = dimension_key_for(item, request: request, model: model)
87
+ return nil unless dimension_key
88
+
89
+ provider_item_id = if dimension_key == "container_session"
90
+ item["container_id"] || item["id"]
91
+ else
92
+ item["id"]
93
+ end
94
+ Charges::LineItem.build(
95
+ dimension_key: dimension_key,
96
+ quantity: 1,
97
+ cost_status: Charges::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 dimension_key_for(item, request:, model:)
106
+ dimension = output_dimension(item["type"])
107
+ return dimension unless dimension == "web_search_request"
108
+ return dimension 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 output_dimension(type)
114
+ key = RESPONSE_OUTPUT_RENAMES[type] || type
115
+ dimension = Usage::Catalog[key]
116
+ key if dimension && dimension.token_key.nil?
117
+ end
118
+
119
+ def web_search_preview_used?(request)
120
+ tools = request && (request[:tools] || request["tools"])
121
+ Array(tools).any? do |tool|
122
+ type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
123
+ type.to_s.include?("web_search_preview")
124
+ end
125
+ end
126
+
127
+ def chat_completions_search_model?(model)
128
+ name = local_model_name(model)
129
+ name && ModelFamilies.chat_completions_search?(name)
130
+ end
131
+
132
+ def reasoning_model?(model)
133
+ name = local_model_name(model)
134
+ name && ModelFamilies.reasoning?(name)
135
+ end
136
+
137
+ def local_model_name(model)
138
+ return nil unless model
139
+
140
+ model.to_s.split("/", 2).last
141
+ end
142
+
143
+ def line_item_details(item)
144
+ {
145
+ status: item["status"],
146
+ action_type: item.dig("action", "type"),
147
+ container_id: item["container_id"]
148
+ }.compact
149
+ end
150
+
151
+ def openai_stream_service_line_items(events, request: nil, model: nil)
152
+ output_items = []
153
+ each_event_data(events) do |data|
154
+ output_items.concat(Array(data.dig("response", "output")))
155
+ output_items << data["item"] if data["item"]
156
+ end
157
+ line_items_from_output(output_items, request: request, model: model)
158
+ end
159
+
160
+ def transcription_line_items(usage)
161
+ return [] unless usage
162
+
163
+ type = (usage[:type] || usage["type"]).to_s
164
+ return [] unless type == "duration"
165
+
166
+ seconds = (usage[:seconds] || usage["seconds"]).to_f
167
+ return [] unless seconds.positive?
168
+
169
+ [Charges::LineItem.build(
170
+ dimension_key: "transcription_minute",
171
+ quantity: (seconds / 60.0).ceil,
172
+ cost_status: Charges::CostStatus::UNKNOWN,
173
+ pricing_basis: "provider_usage",
174
+ provider_field: "usage.seconds",
175
+ details: { seconds: seconds }
176
+ )]
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_families"
4
+
5
+ module LlmCostTracker
6
+ module Providers
7
+ module Openai
8
+ module UsageExtractor
9
+ INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
10
+ OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
11
+ def self.token_usage(usage, model: nil)
12
+ input_tokens = (usage[:input_tokens] || usage[:prompt_tokens]).to_i
13
+ output_tokens = (usage[:output_tokens] || usage[:completion_tokens]).to_i
14
+ cache_read = cache_read_input_tokens(usage)
15
+ audio_input = audio_input_tokens(usage)
16
+ audio_output = audio_output_tokens(usage)
17
+ image_input = image_input_tokens(usage)
18
+ image_output, regular_output = split_output(
19
+ output_tokens: output_tokens,
20
+ image_output_details: image_output_tokens(usage),
21
+ text_output_details: text_output_tokens(usage),
22
+ audio_output: audio_output,
23
+ default_to_image: ModelFamilies.image_output?(model)
24
+ )
25
+
26
+ Usage::TokenUsage.build(
27
+ input_tokens: [input_tokens - cache_read - audio_input - image_input, 0].max,
28
+ output_tokens: regular_output,
29
+ total_tokens: usage[:total_tokens],
30
+ cache_read_input_tokens: cache_read,
31
+ audio_input_tokens: audio_input,
32
+ audio_output_tokens: audio_output,
33
+ image_input_tokens: image_input,
34
+ image_output_tokens: image_output,
35
+ hidden_output_tokens: hidden_output_tokens(usage)
36
+ )
37
+ end
38
+
39
+ def self.split_output(output_tokens:,
40
+ image_output_details:,
41
+ text_output_details:,
42
+ audio_output:,
43
+ default_to_image: false)
44
+ if image_output_details.zero? && text_output_details.zero?
45
+ remainder = [output_tokens - audio_output, 0].max
46
+ return default_to_image ? [remainder, 0] : [0, remainder]
47
+ end
48
+
49
+ text_output = text_output_details
50
+ text_output = [output_tokens - image_output_details - audio_output, 0].max if text_output.zero?
51
+ [image_output_details, text_output]
52
+ end
53
+
54
+ def self.cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
55
+ def self.hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
56
+ def self.audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
57
+ def self.audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
58
+ def self.image_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :image_tokens)
59
+ def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
60
+ def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
61
+
62
+ def self.detail(usage, containers, key)
63
+ containers.each do |container|
64
+ value = usage.dig(container, key)
65
+ return value.to_i if value
66
+ end
67
+ 0
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module OpenaiCompatible
6
+ class Parser < LlmCostTracker::Parsers::Base
7
+ include Openai::ResponseParser
8
+
9
+ TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
10
+
11
+ class << self
12
+ def match?(url)
13
+ match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
14
+ end
15
+
16
+ def provider_names
17
+ custom = LlmCostTracker.configuration.openai_compatible_providers.each_value.map do |provider|
18
+ provider.to_s.downcase
19
+ end
20
+ ["openai_compatible", *custom].uniq
21
+ end
22
+
23
+ def provider_for_uri(uri)
24
+ return nil unless uri
25
+
26
+ LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
27
+ end
28
+ end
29
+
30
+ def provider_for(request_url)
31
+ self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Providers
5
+ module Anthropic
6
+ autoload :Parser, "llm_cost_tracker/providers/anthropic/parser"
7
+ autoload :UsageExtractor, "llm_cost_tracker/providers/anthropic/usage_extractor"
8
+ autoload :ResponseParser, "llm_cost_tracker/providers/anthropic/response_parser"
9
+ end
10
+
11
+ module Azure
12
+ autoload :Hosts, "llm_cost_tracker/providers/azure/hosts"
13
+ autoload :Parser, "llm_cost_tracker/providers/azure/parser"
14
+ end
15
+
16
+ module Gemini
17
+ autoload :ModelFamilies, "llm_cost_tracker/providers/gemini/model_families"
18
+ autoload :Parser, "llm_cost_tracker/providers/gemini/parser"
19
+ autoload :UsageExtractor, "llm_cost_tracker/providers/gemini/usage_extractor"
20
+ end
21
+
22
+ module Openai
23
+ autoload :Hosts, "llm_cost_tracker/providers/openai/hosts"
24
+ autoload :ModelFamilies, "llm_cost_tracker/providers/openai/model_families"
25
+ autoload :Parser, "llm_cost_tracker/providers/openai/parser"
26
+ autoload :ServiceCharges, "llm_cost_tracker/providers/openai/service_charges"
27
+ autoload :UsageExtractor, "llm_cost_tracker/providers/openai/usage_extractor"
28
+ autoload :ResponseParser, "llm_cost_tracker/providers/openai/response_parser"
29
+ end
30
+
31
+ module OpenaiCompatible
32
+ autoload :Parser, "llm_cost_tracker/providers/openai_compatible/parser"
33
+ end
34
+ end
35
+ end
@@ -13,16 +13,9 @@ module LlmCostTracker
13
13
  require_relative "generators/llm_cost_tracker/prices_generator"
14
14
  require_relative "generators/llm_cost_tracker/call_rollups_generator"
15
15
  require_relative "generators/llm_cost_tracker/async_ingestion_generator"
16
- require_relative "generators/llm_cost_tracker/reconciliation_generator"
17
16
  require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
18
17
  require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
19
18
  require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
20
- require_relative "generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator"
21
- require_relative "generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator"
22
- end
23
-
24
- rake_tasks do
25
- load File.expand_path("../tasks/llm_cost_tracker.rake", __dir__)
26
19
  end
27
20
  end
28
21
  end
@@ -2,11 +2,11 @@
2
2
 
3
3
  require "active_support/core_ext/integer/time"
4
4
 
5
- require_relative "../billing/cost_status"
5
+ require_relative "../charges/cost_status"
6
6
  require_relative "../ledger"
7
7
 
8
8
  module LlmCostTracker
9
- class Report
9
+ module Report
10
10
  Data = ::Data.define(
11
11
  :days,
12
12
  :from_time,
@@ -58,8 +58,7 @@ module LlmCostTracker
58
58
  "COALESCE(SUM(total_cost), 0) AS total_cost, " \
59
59
  "COUNT(*) AS requests_count, " \
60
60
  "AVG(latency_ms) AS average_latency_ms, " \
61
- "COALESCE(SUM(CASE WHEN total_cost IS NULL " \
62
- "OR cost_status IN ('#{Billing::CostStatus::UNKNOWN}', '#{Billing::CostStatus::PARTIAL}') " \
61
+ "COALESCE(SUM(CASE WHEN #{Charges::CostStatus.unknown_pricing_sql} " \
63
62
  "THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
64
63
  )
65
64
  .take
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- class Report
4
+ module Report
5
5
  class Formatter
6
6
  TOP_LIMIT = 5
7
- NAME_COLUMN_WIDTH = 28
8
- TOP_CALL_COLUMN_WIDTH = 32
7
+ MIN_COLUMN_WIDTH = 28
9
8
 
10
- def initialize(data)
9
+ def initialize(data, color: $stdout.tty?)
11
10
  @data = data
11
+ @color = color
12
12
  end
13
13
 
14
14
  def to_s
15
- lines = ["LLM Cost Report (last #{@data.days} days)", ""]
15
+ lines = [bold("LLM Cost Report (last #{@data.days} days)"), ""]
16
16
  append_summary(lines)
17
- append_cost_section(lines, "By provider", @data.cost_by_provider)
18
- append_cost_section(lines, "By model", @data.cost_by_model)
17
+ append_cost_section(lines, "By provider", @data.cost_by_provider) { |row| row.name.to_s }
18
+ append_cost_section(lines, "By model", @data.cost_by_model) { |row| row.name.to_s }
19
19
  append_tag_sections(lines)
20
20
  append_top_calls(lines)
21
21
  lines.join("\n")
@@ -27,36 +27,37 @@ module LlmCostTracker
27
27
  lines << "Total cost: #{money(@data.total_cost)}"
28
28
  lines << "Requests: #{@data.requests_count}"
29
29
  lines << "Avg latency: #{average_latency}"
30
- lines << "Unknown pricing: #{@data.unknown_pricing_count}"
30
+ lines << "Unknown pricing: #{colored_unknown_pricing(@data.unknown_pricing_count)}"
31
31
  end
32
32
 
33
- def append_cost_section(lines, title, rows)
33
+ def append_cost_section(lines, title, rows, &name_for)
34
34
  lines << ""
35
- lines << "#{title}:"
35
+ lines << bold("#{title}:")
36
36
  return lines << " none" if rows.empty?
37
37
 
38
- rows.first(TOP_LIMIT).each do |row|
39
- lines << " #{row.name.to_s.ljust(NAME_COLUMN_WIDTH)} #{money(row.total_cost)}"
38
+ visible = rows.first(TOP_LIMIT)
39
+ width = column_width(visible, &name_for)
40
+ visible.each do |row|
41
+ lines << " #{name_for.call(row).ljust(width)} #{money(row.total_cost)}"
40
42
  end
41
43
  end
42
44
 
43
45
  def append_tag_sections(lines)
44
46
  @data.cost_by_tags.each do |tag_key, rows|
45
- append_cost_section(lines, "By tag (#{tag_key})", rows)
47
+ append_cost_section(lines, "By tag (#{tag_key})", rows) { |row| row.name.to_s }
46
48
  end
47
49
  end
48
50
 
49
51
  def append_top_calls(lines)
50
- lines << ""
51
- lines << "Top expensive calls:"
52
- return lines << " none" if @data.top_calls.empty?
53
-
54
- @data.top_calls.first(TOP_LIMIT).each do |call|
55
- label = "#{call.provider}/#{call.model}"
56
- lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
52
+ append_cost_section(lines, "Top expensive calls", @data.top_calls) do |call|
53
+ "#{call.provider}/#{call.model}"
57
54
  end
58
55
  end
59
56
 
57
+ def column_width(rows, &name_for)
58
+ [MIN_COLUMN_WIDTH, rows.map { |row| name_for.call(row).length }.max.to_i].max
59
+ end
60
+
60
61
  def average_latency
61
62
  @data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
62
63
  end
@@ -64,6 +65,18 @@ module LlmCostTracker
64
65
  def money(value)
65
66
  "$#{format('%.6f', value.to_f)}"
66
67
  end
68
+
69
+ def colored_unknown_pricing(count)
70
+ return count.to_s unless @color
71
+
72
+ count.to_i.positive? ? "\e[33m#{count}\e[0m" : "\e[32m#{count}\e[0m"
73
+ end
74
+
75
+ def bold(text)
76
+ return text unless @color
77
+
78
+ "\e[1m#{text}\e[0m"
79
+ end
67
80
  end
68
81
  end
69
82
  end
@@ -4,7 +4,7 @@ require_relative "report/data"
4
4
  require_relative "report/formatter"
5
5
 
6
6
  module LlmCostTracker
7
- class Report
7
+ module Report
8
8
  class << self
9
9
  def generate(days: Data::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
10
10
  report_data = Data.build(
@@ -21,17 +21,6 @@ module LlmCostTracker
21
21
  deleted
22
22
  end
23
23
 
24
- def prune_invoice_imports(older_than:, now: Time.now.utc)
25
- cutoff = resolve_cutoff(older_than, now)
26
- require_relative "ledger"
27
- return 0 unless LlmCostTracker::ProviderInvoiceImport.table_exists?
28
-
29
- LlmCostTracker::ProviderInvoiceImport
30
- .where(state: %w[completed failed])
31
- .where(finished_at: ...cutoff)
32
- .delete_all
33
- end
34
-
35
24
  def prune_inbox(older_than:, now: Time.now.utc)
36
25
  cutoff = resolve_cutoff(older_than, now)
37
26
  require_relative "ingestion"
@@ -70,23 +59,21 @@ module LlmCostTracker
70
59
  def prune_batch(cutoff, batch_size)
71
60
  LlmCostTracker::Call.transaction do
72
61
  cache_rollups = LlmCostTracker.configuration.cache_rollups
73
- rows = pluck_prunable(cutoff, batch_size, with_rollup_columns: cache_rollups)
62
+ rows = prunable_rows(cutoff, batch_size, with_rollup_columns: cache_rollups)
74
63
  next 0 if rows.empty?
75
64
 
76
- ids = cache_rollups ? rows.map(&:first) : rows
65
+ ids = cache_rollups ? rows.map(&:id) : rows
77
66
  deleted = LlmCostTracker::Call.where(id: ids).delete_all
78
67
  LlmCostTracker::Ledger::Rollups.decrement!(rows) if cache_rollups && deleted.positive?
79
68
  deleted
80
69
  end
81
70
  end
82
71
 
83
- def pluck_prunable(cutoff, batch_size, with_rollup_columns:)
72
+ def prunable_rows(cutoff, batch_size, with_rollup_columns:)
84
73
  relation = LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
85
- if with_rollup_columns
86
- relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
87
- else
88
- relation.pluck(:id)
89
- end
74
+ return relation.pluck(:id) unless with_rollup_columns
75
+
76
+ relation.select(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider).to_a
90
77
  end
91
78
  end
92
79
  end
@@ -17,14 +17,17 @@ module LlmCostTracker
17
17
  end
18
18
 
19
19
  def tags
20
- default_tags = LlmCostTracker.configuration.default_tags
21
- default_tags = default_tags.call if default_tags.respond_to?(:call)
22
-
23
- Sanitizer.call(default_tags.to_h).merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
20
+ config = LlmCostTracker.configuration
21
+ base = config.static_sanitized_default_tags ||
22
+ Sanitizer.call(call_default_tags(config.default_tags).to_h)
23
+ base.merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
24
24
  end
25
25
 
26
- def clear!
27
- ActiveSupport::IsolatedExecutionState[KEY] = []
26
+ def call_default_tags(proc_or_lambda)
27
+ proc_or_lambda.call
28
+ rescue StandardError => e
29
+ Logging.warn("LlmCostTracker default_tags proc raised: #{e.class}: #{e.message}; using empty default tags")
30
+ {}
28
31
  end
29
32
  end
30
33
  end
@@ -27,10 +27,20 @@ module LlmCostTracker
27
27
  limit = [config.max_tag_value_bytesize.to_i, 0].max
28
28
  max_count = [config.max_tag_count.to_i, 0].max
29
29
  tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
30
+ next unless valid_key?(key)
31
+
30
32
  sanitized[key] = sanitized_value(key, value, redacted, limit)
31
33
  end
32
34
  end
33
35
 
36
+ def valid_key?(key)
37
+ Tags::Key.validate!(key)
38
+ true
39
+ rescue ArgumentError => e
40
+ Logging.warn("LlmCostTracker tag key invalid: #{e.message}; skipping")
41
+ false
42
+ end
43
+
34
44
  def cap(tags, config: LlmCostTracker.configuration)
35
45
  tags = (tags || {}).to_h
36
46
  max_count = [config.max_tag_count.to_i, 0].max
@@ -2,13 +2,11 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  module Timing
5
- module_function
6
-
7
- def now_monotonic
5
+ def self.now_monotonic
8
6
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
9
7
  end
10
8
 
11
- def elapsed_ms(started_at)
9
+ def self.elapsed_ms(started_at)
12
10
  ((now_monotonic - started_at) * 1000).round
13
11
  end
14
12
  end