llm_cost_tracker 0.11.0 → 0.13.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -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/data_quality_controller.rb +1 -0
  8. data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
  9. data/app/helpers/llm_cost_tracker/application_helper.rb +12 -19
  10. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
  11. data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
  12. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
  13. data/app/models/llm_cost_tracker/call.rb +28 -63
  14. data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
  15. data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
  16. data/app/models/llm_cost_tracker/call_tag.rb +0 -2
  17. data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +3 -0
  18. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +76 -43
  19. data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
  20. data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
  21. data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
  22. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
  23. data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
  24. data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
  25. data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
  26. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
  27. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
  28. data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
  29. data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
  30. data/app/views/llm_cost_tracker/calls/show.html.erb +33 -25
  31. data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
  32. data/app/views/llm_cost_tracker/data_quality/index.html.erb +73 -117
  33. data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
  34. data/app/views/llm_cost_tracker/pricing/index.html.erb +3 -3
  35. data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
  36. data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
  37. data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
  38. data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
  39. data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
  40. data/config/routes.rb +2 -3
  41. data/lib/llm_cost_tracker/budget.rb +24 -26
  42. data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
  43. data/lib/llm_cost_tracker/capture/sse.rb +1 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +32 -39
  45. data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
  46. data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
  47. data/lib/llm_cost_tracker/charges/cost.rb +27 -0
  48. data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
  49. data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +41 -44
  50. data/lib/llm_cost_tracker/check.rb +5 -0
  51. data/lib/llm_cost_tracker/configuration.rb +13 -44
  52. data/lib/llm_cost_tracker/currency.rb +5 -0
  53. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
  54. data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
  55. data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
  56. data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
  57. data/lib/llm_cost_tracker/doctor.rb +5 -69
  58. data/lib/llm_cost_tracker/engine.rb +4 -4
  59. data/lib/llm_cost_tracker/event.rb +12 -18
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +4 -3
  64. data/lib/llm_cost_tracker/ingestion/batch.rb +61 -12
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
  66. data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
  67. data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
  68. data/lib/llm_cost_tracker/ingestion.rb +24 -36
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +93 -106
  70. data/lib/llm_cost_tracker/integrations/base.rb +39 -57
  71. data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
  72. data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
  73. data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
  74. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
  75. data/lib/llm_cost_tracker/integrations.rb +32 -25
  76. data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
  77. data/lib/llm_cost_tracker/ledger/period.rb +5 -10
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
  80. data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
  81. data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
  82. data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
  83. data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
  84. data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
  85. data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
  86. data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
  87. data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
  88. data/lib/llm_cost_tracker/ledger/store.rb +24 -41
  89. data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
  90. data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
  91. data/lib/llm_cost_tracker/ledger.rb +8 -18
  92. data/lib/llm_cost_tracker/logging.rb +4 -21
  93. data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
  94. data/lib/llm_cost_tracker/parsers.rb +139 -26
  95. data/lib/llm_cost_tracker/prices.json +1821 -13
  96. data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
  97. data/lib/llm_cost_tracker/pricing/calculation.rb +279 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
  99. data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
  100. data/lib/llm_cost_tracker/pricing/matcher.rb +98 -0
  101. data/lib/llm_cost_tracker/pricing/mode.rb +44 -56
  102. data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
  103. data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
  104. data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
  105. data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
  106. data/lib/llm_cost_tracker/pricing/source.rb +7 -0
  107. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
  108. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
  109. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
  110. data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
  111. data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
  112. data/lib/llm_cost_tracker/pricing.rb +10 -278
  113. data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
  114. data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
  115. data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
  116. data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
  117. data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
  118. data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
  119. data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
  120. data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
  121. data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
  122. data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
  123. data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
  124. data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
  125. data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
  126. data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
  127. data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
  128. data/lib/llm_cost_tracker/providers.rb +35 -0
  129. data/lib/llm_cost_tracker/railtie.rb +0 -3
  130. data/lib/llm_cost_tracker/report/data.rb +3 -4
  131. data/lib/llm_cost_tracker/report/formatter.rb +1 -1
  132. data/lib/llm_cost_tracker/report.rb +1 -1
  133. data/lib/llm_cost_tracker/retention.rb +6 -19
  134. data/lib/llm_cost_tracker/tags/context.rb +9 -6
  135. data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
  136. data/lib/llm_cost_tracker/timing.rb +2 -4
  137. data/lib/llm_cost_tracker/tracker.rb +23 -36
  138. data/lib/llm_cost_tracker/usage/catalog.rb +69 -0
  139. data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
  140. data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +6 -52
  141. data/lib/llm_cost_tracker/usage/source.rb +14 -0
  142. data/lib/llm_cost_tracker/usage/token_usage.rb +45 -0
  143. data/lib/llm_cost_tracker/version.rb +1 -1
  144. data/lib/llm_cost_tracker.rb +43 -52
  145. data/lib/tasks/llm_cost_tracker.rake +14 -73
  146. metadata +81 -55
  147. data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
  148. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
  149. data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
  150. data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
  151. data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
  152. data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
  153. data/lib/llm_cost_tracker/billing/components.rb +0 -95
  154. data/lib/llm_cost_tracker/capture/stream.rb +0 -9
  155. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
  156. data/lib/llm_cost_tracker/doctor/check.rb +0 -7
  157. data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
  158. data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
  159. data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
  160. data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
  161. data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
  162. data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
  163. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
  164. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
  165. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
  166. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
  167. data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
  168. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
  169. data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
  170. data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
  171. data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
  172. data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
  173. data/lib/llm_cost_tracker/masking.rb +0 -39
  174. data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
  175. data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
  176. data/lib/llm_cost_tracker/parsers/base.rb +0 -131
  177. data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
  178. data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
  179. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
  180. data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
  181. data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
  182. data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
  183. data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
  184. data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
  185. data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
  186. data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
  187. data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
  188. data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
  189. data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
  190. data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
  191. data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
  192. data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
  193. data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
  194. data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
  195. data/lib/llm_cost_tracker/reconciliation.rb +0 -118
  196. 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,45 @@
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, 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
+ calculation = Pricing::Calculation.for(
28
20
  provider: event.provider,
29
21
  model: event.model,
30
22
  tokens: event.token_usage,
31
23
  line_items: event.line_items,
32
- pricing_mode: pricing_mode
24
+ pricing_mode: event.pricing_mode,
25
+ usage_source: event.usage_source
33
26
  )
34
27
 
35
- if cost_data.nil? && event.token_usage.total_tokens.positive? && priced_line_items.none?(&:priced?)
28
+ if enforce_budget
29
+ Budget.enforce!(provider: event.provider,
30
+ model: event.model,
31
+ estimate: calculation.cost&.total,
32
+ force: true)
33
+ end
34
+
35
+ if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? &&
36
+ calculation.priced_line_items.none?(&:priced?)
36
37
  Pricing::Unknown.process(event.model)
37
38
  end
38
39
 
39
40
  event = build_event(
40
41
  event: event,
41
- pricing_mode: pricing_mode,
42
- cost_data: cost_data,
43
- pricing_snapshot: pricing_snapshot,
44
- line_items: priced_line_items,
42
+ calculation: calculation,
45
43
  metadata: metadata,
46
44
  latency_ms: latency_ms,
47
45
  context_tags: context_tags
@@ -51,7 +49,7 @@ module LlmCostTracker
51
49
  Ingestion::Inbox.save(event)
52
50
  Ingestion::Worker.ensure_started
53
51
  else
54
- Ledger::Store.insert(event, skip_existence_check: true)
52
+ Ledger::Store.insert(event)
55
53
  end
56
54
 
57
55
  yield if block_given?
@@ -71,29 +69,18 @@ module LlmCostTracker
71
69
  Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
72
70
  end
73
71
 
74
- def build_event(event:, pricing_mode:, cost_data:, pricing_snapshot:, line_items:,
75
- metadata:, latency_ms:, context_tags:)
72
+ def build_event(event:, calculation:, metadata:, latency_ms:, context_tags:)
76
73
  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
74
  event.with(
88
75
  event_id: SecureRandom.uuid,
89
- pricing_mode: pricing_mode,
90
- cost: cost,
76
+ pricing_mode: calculation.mode,
77
+ cost: calculation.cost,
91
78
  tags: build_tags(context_tags: context_tags, metadata: metadata),
92
79
  latency_ms: finite_latency_ms(latency_ms),
93
80
  tracked_at: Time.now.utc,
94
- cost_status: cost_status,
95
- pricing_snapshot: pricing_snapshot,
96
- line_items: line_items
81
+ cost_status: calculation.cost_status,
82
+ pricing_snapshot: calculation.snapshot,
83
+ line_items: calculation.priced_line_items
97
84
  )
98
85
  end
99
86