llm_cost_tracker 0.11.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 (195) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +7 -4
  4. data/app/assets/llm_cost_tracker/application.css +8 -7
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
  7. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  8. data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
  9. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  10. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  11. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  12. data/app/models/llm_cost_tracker/call.rb +28 -63
  13. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  14. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  15. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  16. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
  17. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
  18. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  19. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  20. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  21. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  22. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  23. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  24. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  25. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  26. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  27. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  28. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  29. data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
  30. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  31. data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
  32. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  33. data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
  34. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  39. data/config/routes.rb +2 -3
  40. data/lib/llm_cost_tracker/budget.rb +24 -26
  41. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  42. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  43. data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
  44. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  45. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  46. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  47. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  48. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
  49. data/lib/llm_cost_tracker/check.rb +5 -0
  50. data/lib/llm_cost_tracker/configuration.rb +13 -44
  51. data/lib/llm_cost_tracker/currency.rb +5 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  54. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  55. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  56. data/lib/llm_cost_tracker/doctor.rb +5 -69
  57. data/lib/llm_cost_tracker/engine.rb +4 -4
  58. data/lib/llm_cost_tracker/event.rb +12 -20
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
  63. data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
  64. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  65. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  66. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  67. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  68. data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
  69. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  70. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  71. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  72. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  73. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  74. data/lib/llm_cost_tracker/integrations.rb +32 -25
  75. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  76. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  77. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  78. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  79. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  81. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  82. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  84. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  86. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  87. data/lib/llm_cost_tracker/ledger/store.rb +18 -42
  88. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  89. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  90. data/lib/llm_cost_tracker/ledger.rb +8 -18
  91. data/lib/llm_cost_tracker/logging.rb +4 -21
  92. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  93. data/lib/llm_cost_tracker/parsers.rb +139 -26
  94. data/lib/llm_cost_tracker/prices.json +1707 -1
  95. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  96. data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
  97. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  98. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  99. data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
  100. data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
  101. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  102. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  103. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  104. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  105. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  106. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  107. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  108. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  109. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  110. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  111. data/lib/llm_cost_tracker/pricing.rb +10 -278
  112. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  113. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  115. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  116. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  117. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  118. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  119. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  120. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  121. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  122. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  123. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  124. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  125. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  126. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  127. data/lib/llm_cost_tracker/providers.rb +35 -0
  128. data/lib/llm_cost_tracker/railtie.rb +0 -3
  129. data/lib/llm_cost_tracker/report/data.rb +3 -4
  130. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  131. data/lib/llm_cost_tracker/report.rb +1 -1
  132. data/lib/llm_cost_tracker/retention.rb +6 -19
  133. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  134. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  135. data/lib/llm_cost_tracker/timing.rb +2 -4
  136. data/lib/llm_cost_tracker/tracker.rb +24 -36
  137. data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
  138. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  139. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
  140. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  141. data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
  142. data/lib/llm_cost_tracker/version.rb +1 -1
  143. data/lib/llm_cost_tracker.rb +43 -52
  144. data/lib/tasks/llm_cost_tracker.rake +14 -73
  145. metadata +81 -55
  146. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  147. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  148. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  149. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  151. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  152. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  153. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  154. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  155. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  156. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  157. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  158. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  159. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  160. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  161. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  168. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  170. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  172. data/lib/llm_cost_tracker/masking.rb +0 -39
  173. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  174. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  175. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  176. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  177. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  178. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  179. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  180. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  181. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  182. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  183. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  184. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  185. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  186. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  187. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  188. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  189. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  190. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  191. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  192. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  193. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  194. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  195. data/lib/llm_cost_tracker/token_usage.rb +0 -93
@@ -1,17 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../billing/line_item"
4
3
  require_relative "model_families"
5
4
 
6
5
  module LlmCostTracker
7
6
  module Providers
8
7
  module Openai
9
8
  module ServiceCharges
10
- RESPONSE_OUTPUT_COMPONENTS = {
11
- "web_search_call" => :web_search_request,
12
- "file_search_call" => :file_search_call,
13
- "code_interpreter_call" => :container_session,
14
- "mcp_call" => :mcp_call
9
+ RESPONSE_OUTPUT_RENAMES = {
10
+ "web_search_call" => "web_search_request",
11
+ "code_interpreter_call" => "container_session"
15
12
  }.freeze
16
13
 
17
14
  module_function
@@ -53,7 +50,7 @@ module LlmCostTracker
53
50
  def chat_completions_used_web_search?(choices)
54
51
  Array(choices).any? do |choice|
55
52
  Array(choice.dig("message", "annotations")).any? do |annotation|
56
- annotation.is_a?(Hash) && annotation["type"] == "url_citation"
53
+ annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
57
54
  end
58
55
  end
59
56
  end
@@ -61,20 +58,22 @@ module LlmCostTracker
61
58
  def billable?(item)
62
59
  return false unless item.is_a?(Hash)
63
60
 
64
- component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
65
- return false unless component
66
- return true unless component == :web_search_request
61
+ dimension = output_dimension(item["type"])
62
+ return false unless dimension
63
+ return true unless dimension == "web_search_request"
67
64
 
68
65
  action_type = item.dig("action", "type")
69
66
  action_type.nil? || action_type == "search"
70
67
  end
71
68
 
72
69
  def store_output_item(output_items, item)
73
- return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
70
+ return unless item.is_a?(Hash)
74
71
 
75
- component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
76
- key = if component == :container_session && item["container_id"]
77
- "#{component}:#{item['container_id']}"
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']}"
78
77
  else
79
78
  item["id"] || "#{item['type']}:#{output_items.length}"
80
79
  end
@@ -84,62 +83,68 @@ module LlmCostTracker
84
83
  def build_line_item(item, request: nil, model: nil)
85
84
  return nil unless item.is_a?(Hash)
86
85
 
87
- component_key = component_key_for(item, request: request, model: model)
88
- return nil unless component_key
86
+ dimension_key = dimension_key_for(item, request: request, model: model)
87
+ return nil unless dimension_key
89
88
 
90
- provider_item_id = if component_key == :container_session
89
+ provider_item_id = if dimension_key == "container_session"
91
90
  item["container_id"] || item["id"]
92
91
  else
93
92
  item["id"]
94
93
  end
95
- Billing::LineItem.build(
96
- component_key: component_key,
94
+ Charges::LineItem.build(
95
+ dimension_key: dimension_key,
97
96
  quantity: 1,
98
- cost_status: Billing::CostStatus::UNKNOWN,
99
- pricing_basis: :provider_usage,
97
+ cost_status: Charges::CostStatus::UNKNOWN,
98
+ pricing_basis: "provider_usage",
100
99
  provider_field: item["provider_field"] || "response.output.#{item['type']}",
101
100
  provider_item_id: provider_item_id,
102
101
  details: line_item_details(item)
103
102
  )
104
103
  end
105
104
 
106
- def component_key_for(item, request:, model:)
107
- component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
108
- return component unless component == :web_search_request
109
- return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
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
110
112
 
111
- reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
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?
112
117
  end
113
118
 
114
119
  def web_search_preview_used?(request)
115
120
  tools = request && (request[:tools] || request["tools"])
116
- return false unless tools.respond_to?(:each)
117
-
118
- tools.any? do |tool|
121
+ Array(tools).any? do |tool|
119
122
  type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
120
123
  type.to_s.include?("web_search_preview")
121
124
  end
122
125
  end
123
126
 
124
127
  def chat_completions_search_model?(model)
125
- return false unless model
126
-
127
- name = model.to_s.split("/", 2).last
128
- ModelFamilies.chat_completions_search?(name)
128
+ name = local_model_name(model)
129
+ name && ModelFamilies.chat_completions_search?(name)
129
130
  end
130
131
 
131
132
  def reasoning_model?(model)
132
- return false unless model
133
+ name = local_model_name(model)
134
+ name && ModelFamilies.reasoning?(name)
135
+ end
133
136
 
134
- name = model.to_s.split("/", 2).last
135
- ModelFamilies.reasoning?(name)
137
+ def local_model_name(model)
138
+ return nil unless model
139
+
140
+ model.to_s.split("/", 2).last
136
141
  end
137
142
 
138
143
  def line_item_details(item)
139
144
  {
140
- "status" => item["status"],
141
- "action_type" => item.dig("action", "type"),
142
- "container_id" => item["container_id"]
145
+ status: item["status"],
146
+ action_type: item.dig("action", "type"),
147
+ container_id: item["container_id"]
143
148
  }.compact
144
149
  end
145
150
 
@@ -151,6 +156,25 @@ module LlmCostTracker
151
156
  end
152
157
  line_items_from_output(output_items, request: request, model: model)
153
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
154
178
  end
155
179
  end
156
180
  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,12 +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
19
  end
23
20
  end
24
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,7 +1,7 @@
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
7
  MIN_COLUMN_WIDTH = 28
@@ -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
@@ -1,47 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/core_ext/object/blank"
4
+ require "active_support/notifications"
4
5
  require "securerandom"
5
6
 
6
7
  require_relative "ingestion"
7
8
  require_relative "ledger"
8
- require_relative "logging"
9
9
  require_relative "pricing"
10
- require_relative "billing/cost_status"
11
10
 
12
11
  module LlmCostTracker
13
- class Tracker
12
+ module Tracker
14
13
  EVENT_NAME = "llm_request.llm_cost_tracker"
15
14
 
16
15
  class << self
17
- def enforce_budget!(provider: nil, model: nil, request: nil)
16
+ def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil, enforce_budget: false)
18
17
  return unless LlmCostTracker.configuration.enabled
19
18
 
20
- Budget.enforce!(provider: provider, model: model, request: request)
21
- end
22
-
23
- def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
24
- return unless LlmCostTracker.configuration.enabled
25
-
26
- pricing_mode = Pricing::Mode.normalize(pricing_mode) || event.pricing_mode
27
- cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
19
+ pricing_mode ||= event.pricing_mode
20
+ calculation = Pricing::Calculation.for(
28
21
  provider: event.provider,
29
22
  model: event.model,
30
23
  tokens: event.token_usage,
31
24
  line_items: event.line_items,
32
- pricing_mode: pricing_mode
25
+ pricing_mode: pricing_mode,
26
+ usage_source: event.usage_source
33
27
  )
34
28
 
35
- if cost_data.nil? && event.token_usage.total_tokens.positive? && priced_line_items.none?(&:priced?)
29
+ if enforce_budget
30
+ Budget.enforce!(provider: event.provider,
31
+ model: event.model,
32
+ estimate: calculation.cost&.total,
33
+ force: true)
34
+ end
35
+
36
+ if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? &&
37
+ calculation.priced_line_items.none?(&:priced?)
36
38
  Pricing::Unknown.process(event.model)
37
39
  end
38
40
 
39
41
  event = build_event(
40
42
  event: event,
41
- pricing_mode: pricing_mode,
42
- cost_data: cost_data,
43
- pricing_snapshot: pricing_snapshot,
44
- line_items: priced_line_items,
43
+ calculation: calculation,
45
44
  metadata: metadata,
46
45
  latency_ms: latency_ms,
47
46
  context_tags: context_tags
@@ -51,7 +50,7 @@ module LlmCostTracker
51
50
  Ingestion::Inbox.save(event)
52
51
  Ingestion::Worker.ensure_started
53
52
  else
54
- Ledger::Store.insert(event, skip_existence_check: true)
53
+ Ledger::Store.insert(event)
55
54
  end
56
55
 
57
56
  yield if block_given?
@@ -71,29 +70,18 @@ module LlmCostTracker
71
70
  Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
72
71
  end
73
72
 
74
- def build_event(event:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
75
- metadata:, latency_ms:, context_tags:)
73
+ def build_event(event:, calculation:, metadata:, latency_ms:, context_tags:)
76
74
  context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
77
- cost = Pricing.combine_with_service_lines(cost_data, line_items)
78
- cost_status = Billing::CostStatus.call(
79
- token_usage: event.token_usage,
80
- usage_source: event.usage_source,
81
- token_cost: cost_data,
82
- token_pricing_partial: Pricing.token_pricing_partial?(event.token_usage, cost_data),
83
- service_line_items: line_items.reject(&:token?),
84
- total_cost: cost&.fetch(:total_cost, nil)
85
- )
86
-
87
75
  event.with(
88
76
  event_id: SecureRandom.uuid,
89
- pricing_mode: pricing_mode,
90
- cost: cost,
77
+ pricing_mode: calculation.mode,
78
+ cost: calculation.cost,
91
79
  tags: build_tags(context_tags: context_tags, metadata: metadata),
92
80
  latency_ms: finite_latency_ms(latency_ms),
93
81
  tracked_at: Time.now.utc,
94
- cost_status: cost_status,
95
- pricing_snapshot: pricing_snapshot,
96
- line_items: line_items
82
+ cost_status: calculation.cost_status,
83
+ pricing_snapshot: calculation.snapshot,
84
+ line_items: calculation.priced_line_items
97
85
  )
98
86
  end
99
87
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "psych"
5
+
6
+ require_relative "dimension"
7
+
8
+ module LlmCostTracker
9
+ module Usage
10
+ module Catalog
11
+ DEFINITIONS_PATH = File.expand_path("dimensions.yml", __dir__)
12
+
13
+ DEFAULT_RATE_BASIS_BY_UNIT = {
14
+ "token" => "per_million_tokens",
15
+ "character" => "per_million_characters",
16
+ "request" => "per_request",
17
+ "session" => "per_session",
18
+ "hour" => "per_hour",
19
+ "minute" => "per_minute",
20
+ "image" => "per_image"
21
+ }.freeze
22
+
23
+ class << self
24
+ delegate :[], :fetch, to: :index
25
+
26
+ def all
27
+ @all ||= load_definitions.freeze
28
+ end
29
+
30
+ def token_priced
31
+ @token_priced ||= all.select(&:token_key).freeze
32
+ end
33
+
34
+ def token_priced_for(kind:, direction:, cache_state:)
35
+ token_priced.find do |dimension|
36
+ dimension.kind == kind && dimension.direction == direction && dimension.cache_state == cache_state
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def index
43
+ @index ||= all.to_h { |dimension| [dimension.key, dimension] }.freeze
44
+ end
45
+
46
+ def load_definitions
47
+ Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
48
+ .map { |attributes| build(attributes) }
49
+ end
50
+
51
+ def build(attributes)
52
+ rate_basis = attributes[:rate_basis] || DEFAULT_RATE_BASIS_BY_UNIT.fetch(attributes.fetch(:unit))
53
+ Dimension.new(**attributes, rate_basis: rate_basis)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end