llm_cost_tracker 0.9.0 → 0.11.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +49 -58
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +83 -102
- data/config/routes.rb +1 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +80 -25
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +27 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +4 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +31 -12
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -28,7 +28,7 @@ module LlmCostTracker
|
|
|
28
28
|
@provider = provider.to_s
|
|
29
29
|
@period_start = parse_date(period_start)
|
|
30
30
|
@period_end = parse_date(period_end)
|
|
31
|
-
@scope =
|
|
31
|
+
@scope = (scope || {}).to_h.transform_keys { |key| key.to_s.to_sym }.slice(*SCOPE_KEYS)
|
|
32
32
|
@currency = (currency || Ledger::Rollups::DEFAULT_CURRENCY).to_s.upcase
|
|
33
33
|
@drilldown_limit = drilldown_limit
|
|
34
34
|
raise ArgumentError, "source must be present" if @source.empty?
|
|
@@ -37,7 +37,7 @@ module LlmCostTracker
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def call
|
|
40
|
-
provider_total =
|
|
40
|
+
provider_total = scoped_cost_invoices_in_window
|
|
41
41
|
.sum(:billed_amount)
|
|
42
42
|
.then { |sum| BigDecimal(sum.to_s) }
|
|
43
43
|
local_index = local_attribution_index_distinct
|
|
@@ -66,7 +66,7 @@ module LlmCostTracker
|
|
|
66
66
|
unmatched_local_calls: cap_by_amount(unmatched_locals, :total_cost),
|
|
67
67
|
unmatched_local_calls_total: unmatched_local_calls_total_count(invoice_basis_values),
|
|
68
68
|
non_cost_rows: cap_by_amount(non_cost_rows, :billed_amount),
|
|
69
|
-
non_cost_rows_total:
|
|
69
|
+
non_cost_rows_total: scoped_non_cost_invoices_relation.count
|
|
70
70
|
)
|
|
71
71
|
end
|
|
72
72
|
|
|
@@ -88,10 +88,10 @@ module LlmCostTracker
|
|
|
88
88
|
relation.to_a
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
def
|
|
91
|
+
def scoped_cost_invoices_in_window
|
|
92
92
|
relation = scoped_invoices_relation
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
.where(period_start: period_start..)
|
|
94
|
+
.where(period_end: ..period_end)
|
|
95
95
|
|
|
96
96
|
connection = ProviderInvoice.connection
|
|
97
97
|
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
@@ -187,10 +187,8 @@ module LlmCostTracker
|
|
|
187
187
|
|
|
188
188
|
def unmatched_provider_rows_from_sql(local_index)
|
|
189
189
|
rows = BASIS_DIMENSION.each_key.flat_map do |basis|
|
|
190
|
-
next [] if basis == PERIOD_ONLY_BASIS
|
|
191
|
-
|
|
192
190
|
column = BASIS_DIMENSION[basis].to_s
|
|
193
|
-
relation =
|
|
191
|
+
relation = scoped_cost_invoices_in_window
|
|
194
192
|
relation = where_match_basis_eq(relation, basis)
|
|
195
193
|
relation = where_metadata_present(relation, column)
|
|
196
194
|
values = local_index[basis].to_a
|
|
@@ -213,10 +211,8 @@ module LlmCostTracker
|
|
|
213
211
|
|
|
214
212
|
def unmatched_provider_rows_total_count(local_index)
|
|
215
213
|
BASIS_DIMENSION.each_key.sum do |basis|
|
|
216
|
-
next 0 if basis == PERIOD_ONLY_BASIS
|
|
217
|
-
|
|
218
214
|
column = BASIS_DIMENSION[basis].to_s
|
|
219
|
-
relation =
|
|
215
|
+
relation = scoped_cost_invoices_in_window
|
|
220
216
|
relation = where_match_basis_eq(relation, basis)
|
|
221
217
|
relation = where_metadata_present(relation, column)
|
|
222
218
|
values = local_index[basis].to_a
|
|
@@ -227,13 +223,9 @@ module LlmCostTracker
|
|
|
227
223
|
|
|
228
224
|
def local_attribution_index_distinct
|
|
229
225
|
BASIS_DIMENSION.each_key.to_h do |basis|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
column = BASIS_DIMENSION[basis]
|
|
234
|
-
values = scoped_calls_relation.where.not(column => nil).distinct.pluck(column)
|
|
235
|
-
[basis, Set.new(values)]
|
|
236
|
-
end
|
|
226
|
+
column = BASIS_DIMENSION[basis]
|
|
227
|
+
values = scoped_calls_relation.where.not(column => nil).distinct.pluck(column)
|
|
228
|
+
[basis, Set.new(values)]
|
|
237
229
|
end
|
|
238
230
|
end
|
|
239
231
|
|
|
@@ -252,38 +244,31 @@ module LlmCostTracker
|
|
|
252
244
|
|
|
253
245
|
def unmatched_local_calls_total_count(invoice_basis_values)
|
|
254
246
|
unmatched = 0
|
|
255
|
-
scoped_calls_relation.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
247
|
+
scoped_calls_relation.in_batches(of: 1_000) do |batch|
|
|
248
|
+
batch.pluck(*ATTRIBUTION_KEYS).each do |row|
|
|
249
|
+
attribution = ATTRIBUTION_KEYS.zip(row).each_with_object({}) do |(key, value), acc|
|
|
250
|
+
acc[key] = value unless value.nil? || value.to_s.empty?
|
|
251
|
+
end
|
|
252
|
+
next if attribution.empty?
|
|
253
|
+
next if local_call_matched?(attribution, invoice_basis_values)
|
|
254
|
+
|
|
255
|
+
unmatched += 1
|
|
259
256
|
end
|
|
260
|
-
next if attribution.empty?
|
|
261
|
-
next if local_call_matched?(attribution, invoice_basis_values)
|
|
262
|
-
|
|
263
|
-
unmatched += 1
|
|
264
257
|
end
|
|
265
258
|
unmatched
|
|
266
259
|
end
|
|
267
260
|
|
|
268
261
|
def invoice_basis_values_distinct_sql
|
|
269
262
|
BASIS_DIMENSION.each_key.to_h do |basis|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
relation = where_metadata_present(relation, column)
|
|
277
|
-
values = pluck_metadata_distinct(relation, column)
|
|
278
|
-
[basis, Set.new(values)]
|
|
279
|
-
end
|
|
263
|
+
column = BASIS_DIMENSION[basis].to_s
|
|
264
|
+
relation = scoped_cost_invoices_in_window
|
|
265
|
+
relation = where_match_basis_eq(relation, basis)
|
|
266
|
+
relation = where_metadata_present(relation, column)
|
|
267
|
+
values = pluck_metadata_distinct(relation, column)
|
|
268
|
+
[basis, Set.new(values)]
|
|
280
269
|
end
|
|
281
270
|
end
|
|
282
271
|
|
|
283
|
-
def non_cost_invoices_total_count
|
|
284
|
-
scoped_non_cost_invoices_relation.count
|
|
285
|
-
end
|
|
286
|
-
|
|
287
272
|
def scoped_non_cost_invoices_relation
|
|
288
273
|
connection = ProviderInvoice.connection
|
|
289
274
|
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
@@ -414,10 +399,6 @@ module LlmCostTracker
|
|
|
414
399
|
((local - provider) * 100 / provider).round(4).to_f
|
|
415
400
|
end
|
|
416
401
|
|
|
417
|
-
def symbolize(hash)
|
|
418
|
-
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
419
|
-
end
|
|
420
|
-
|
|
421
402
|
def parse_date(value)
|
|
422
403
|
return value if value.is_a?(Date)
|
|
423
404
|
|
|
@@ -5,6 +5,7 @@ require "date"
|
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
7
|
require_relative "import_result"
|
|
8
|
+
require_relative "sources/coercion"
|
|
8
9
|
require_relative "../ledger/rollups"
|
|
9
10
|
|
|
10
11
|
module LlmCostTracker
|
|
@@ -81,6 +82,7 @@ module LlmCostTracker
|
|
|
81
82
|
|
|
82
83
|
ProviderInvoiceImport.create!(
|
|
83
84
|
source: source,
|
|
85
|
+
provider: provider,
|
|
84
86
|
cursor: cursor,
|
|
85
87
|
window_start: window&.first,
|
|
86
88
|
window_end: window&.last,
|
|
@@ -119,7 +121,7 @@ module LlmCostTracker
|
|
|
119
121
|
def normalize_rows(rows)
|
|
120
122
|
errors = []
|
|
121
123
|
normalized = rows.each_with_index.filter_map do |row, index|
|
|
122
|
-
attrs = symbolize(row)
|
|
124
|
+
attrs = Sources::Coercion.symbolize(row)
|
|
123
125
|
missing = REQUIRED_FIELDS - attrs.keys
|
|
124
126
|
if missing.any?
|
|
125
127
|
errors << "row #{index}: missing #{missing.join(', ')}"
|
|
@@ -212,12 +214,6 @@ module LlmCostTracker
|
|
|
212
214
|
raw.start_with?(prefix) ? raw : "#{prefix}#{raw}"
|
|
213
215
|
end
|
|
214
216
|
|
|
215
|
-
def symbolize(row)
|
|
216
|
-
return row if row.is_a?(Hash) && row.keys.all?(Symbol)
|
|
217
|
-
|
|
218
|
-
row.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
219
|
-
end
|
|
220
|
-
|
|
221
217
|
def parse_date(value)
|
|
222
218
|
return value if value.is_a?(Date)
|
|
223
219
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bigdecimal"
|
|
4
|
-
require "json"
|
|
5
4
|
require "time"
|
|
6
5
|
|
|
6
|
+
require_relative "coercion"
|
|
7
7
|
require_relative "fingerprint"
|
|
8
|
+
require_relative "../../providers/anthropic/tier_classification"
|
|
8
9
|
|
|
9
10
|
module LlmCostTracker
|
|
10
11
|
module Reconciliation
|
|
@@ -18,13 +19,11 @@ module LlmCostTracker
|
|
|
18
19
|
ROW_TYPE_COST = "cost"
|
|
19
20
|
AUTHORITY_COST_API = "cost_api"
|
|
20
21
|
DEFAULT_METER = "tokens"
|
|
21
|
-
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
22
|
-
private_constant :DATA_RESIDENCY_GEOS
|
|
23
22
|
|
|
24
23
|
module_function
|
|
25
24
|
|
|
26
25
|
def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
|
|
27
|
-
payload = coerce_hash(response)
|
|
26
|
+
payload = Coercion.coerce_hash(response, label: "Anthropic Usage")
|
|
28
27
|
buckets = Array(payload[:data])
|
|
29
28
|
buckets.flat_map do |bucket|
|
|
30
29
|
rows_for_bucket(bucket, authority: authority, row_type: row_type)
|
|
@@ -32,7 +31,7 @@ module LlmCostTracker
|
|
|
32
31
|
end
|
|
33
32
|
|
|
34
33
|
def rows_for_bucket(bucket, authority:, row_type:)
|
|
35
|
-
bucket = symbolize(bucket)
|
|
34
|
+
bucket = Coercion.symbolize(bucket)
|
|
36
35
|
starting_at = bucket[:starting_at]
|
|
37
36
|
ending_at = bucket[:ending_at]
|
|
38
37
|
return [] unless starting_at && ending_at
|
|
@@ -51,7 +50,7 @@ module LlmCostTracker
|
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
def row_for_result(raw, period_start:, period_end:, starting_at:, ending_at:, authority:, row_type:)
|
|
54
|
-
result = symbolize(raw)
|
|
53
|
+
result = Coercion.symbolize(raw)
|
|
55
54
|
raw_amount = result[:amount]
|
|
56
55
|
return nil if raw_amount.nil?
|
|
57
56
|
|
|
@@ -109,7 +108,9 @@ module LlmCostTracker
|
|
|
109
108
|
def pricing_mode_for(result)
|
|
110
109
|
modes = []
|
|
111
110
|
modes << "batch" if result[:service_tier].to_s.downcase == "batch"
|
|
112
|
-
|
|
111
|
+
if LlmCostTracker::Providers::Anthropic::TierClassification.data_residency_geo?(result[:inference_geo])
|
|
112
|
+
modes << "data_residency"
|
|
113
|
+
end
|
|
113
114
|
modes.empty? ? nil : modes.uniq.join("_")
|
|
114
115
|
end
|
|
115
116
|
|
|
@@ -121,19 +122,11 @@ module LlmCostTracker
|
|
|
121
122
|
end
|
|
122
123
|
|
|
123
124
|
def fingerprint_for(result, starting_at:, ending_at:)
|
|
124
|
-
attributes = result.merge(starting_at: normalized_epoch(starting_at),
|
|
125
|
-
ending_at: normalized_epoch(ending_at))
|
|
125
|
+
attributes = result.merge(starting_at: Coercion.normalized_epoch(starting_at),
|
|
126
|
+
ending_at: Coercion.normalized_epoch(ending_at))
|
|
126
127
|
Fingerprint.compute(FINGERPRINT_KEYS, attributes)
|
|
127
128
|
end
|
|
128
129
|
|
|
129
|
-
def normalized_epoch(value)
|
|
130
|
-
return value.to_i if value.is_a?(Numeric)
|
|
131
|
-
|
|
132
|
-
Time.parse(value.to_s).utc.to_i
|
|
133
|
-
rescue ArgumentError
|
|
134
|
-
value.to_s
|
|
135
|
-
end
|
|
136
|
-
|
|
137
130
|
def parse_date(value)
|
|
138
131
|
return value if value.is_a?(Date)
|
|
139
132
|
return Time.at(value).utc.to_date if value.is_a?(Numeric)
|
|
@@ -149,22 +142,6 @@ module LlmCostTracker
|
|
|
149
142
|
end
|
|
150
143
|
(time - 1).utc.to_date
|
|
151
144
|
end
|
|
152
|
-
|
|
153
|
-
def coerce_hash(response)
|
|
154
|
-
return {} if response.nil?
|
|
155
|
-
return symbolize(response) if response.is_a?(Hash)
|
|
156
|
-
|
|
157
|
-
parsed = JSON.parse(response.to_s)
|
|
158
|
-
raise ArgumentError, "Anthropic Usage payload must be a JSON object" unless parsed.is_a?(Hash)
|
|
159
|
-
|
|
160
|
-
symbolize(parsed)
|
|
161
|
-
rescue JSON::ParserError => e
|
|
162
|
-
raise ArgumentError, "Unable to parse Anthropic Usage payload: #{e.message}"
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def symbolize(hash)
|
|
166
|
-
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
167
|
-
end
|
|
168
145
|
end
|
|
169
146
|
end
|
|
170
147
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Reconciliation
|
|
8
|
+
module Sources
|
|
9
|
+
module Coercion
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def symbolize(hash)
|
|
13
|
+
return hash if hash.is_a?(Hash) && hash.keys.all?(Symbol)
|
|
14
|
+
|
|
15
|
+
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def normalized_epoch(value)
|
|
19
|
+
return value.to_i if value.is_a?(Numeric)
|
|
20
|
+
|
|
21
|
+
Time.parse(value.to_s).utc.to_i
|
|
22
|
+
rescue ArgumentError
|
|
23
|
+
value.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def coerce_hash(response, label:)
|
|
27
|
+
return {} if response.nil?
|
|
28
|
+
return symbolize(response) if response.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
parsed = JSON.parse(response.to_s)
|
|
31
|
+
raise ArgumentError, "#{label} payload must be a JSON object" unless parsed.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
symbolize(parsed)
|
|
34
|
+
rescue JSON::ParserError => e
|
|
35
|
+
raise ArgumentError, "Unable to parse #{label} payload: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "time"
|
|
5
4
|
|
|
5
|
+
require_relative "coercion"
|
|
6
6
|
require_relative "fingerprint"
|
|
7
7
|
|
|
8
8
|
module LlmCostTracker
|
|
@@ -17,7 +17,7 @@ module LlmCostTracker
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
19
19
|
def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
|
|
20
|
-
payload = coerce_hash(response)
|
|
20
|
+
payload = Coercion.coerce_hash(response, label: "OpenAI Costs")
|
|
21
21
|
buckets = Array(payload[:data])
|
|
22
22
|
buckets.flat_map do |bucket|
|
|
23
23
|
rows_for_bucket(bucket, authority: authority, row_type: row_type)
|
|
@@ -25,7 +25,7 @@ module LlmCostTracker
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def rows_for_bucket(bucket, authority:, row_type:)
|
|
28
|
-
bucket = symbolize(bucket)
|
|
28
|
+
bucket = Coercion.symbolize(bucket)
|
|
29
29
|
start_time = bucket[:start_time]
|
|
30
30
|
end_time = bucket[:end_time]
|
|
31
31
|
return [] unless start_time && end_time
|
|
@@ -44,8 +44,8 @@ module LlmCostTracker
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def row_for_result(raw, period_start:, period_end:, start_time:, end_time:, authority:, row_type:)
|
|
47
|
-
result = symbolize(raw)
|
|
48
|
-
amount = symbolize(result[:amount] || {})
|
|
47
|
+
result = Coercion.symbolize(raw)
|
|
48
|
+
amount = Coercion.symbolize(result[:amount] || {})
|
|
49
49
|
billed_amount = amount[:value]
|
|
50
50
|
return nil if billed_amount.nil?
|
|
51
51
|
|
|
@@ -93,19 +93,11 @@ module LlmCostTracker
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
def fingerprint_for(result, start_time:, end_time:)
|
|
96
|
-
attributes = result.merge(start_time: normalized_epoch(start_time),
|
|
97
|
-
end_time: normalized_epoch(end_time))
|
|
96
|
+
attributes = result.merge(start_time: Coercion.normalized_epoch(start_time),
|
|
97
|
+
end_time: Coercion.normalized_epoch(end_time))
|
|
98
98
|
Fingerprint.compute(FINGERPRINT_KEYS, attributes)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
def normalized_epoch(value)
|
|
102
|
-
return value.to_i if value.is_a?(Numeric)
|
|
103
|
-
|
|
104
|
-
Time.parse(value.to_s).utc.to_i
|
|
105
|
-
rescue ArgumentError
|
|
106
|
-
value.to_s
|
|
107
|
-
end
|
|
108
|
-
|
|
109
101
|
def epoch_to_date(value)
|
|
110
102
|
return Time.at(Integer(value)).utc.to_date if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
|
|
111
103
|
|
|
@@ -120,22 +112,6 @@ module LlmCostTracker
|
|
|
120
112
|
end
|
|
121
113
|
(time - 1).utc.to_date
|
|
122
114
|
end
|
|
123
|
-
|
|
124
|
-
def coerce_hash(response)
|
|
125
|
-
return {} if response.nil?
|
|
126
|
-
return symbolize(response) if response.is_a?(Hash)
|
|
127
|
-
|
|
128
|
-
parsed = JSON.parse(response.to_s)
|
|
129
|
-
raise ArgumentError, "OpenAI Costs payload must be a JSON object" unless parsed.is_a?(Hash)
|
|
130
|
-
|
|
131
|
-
symbolize(parsed)
|
|
132
|
-
rescue JSON::ParserError => e
|
|
133
|
-
raise ArgumentError, "Unable to parse OpenAI Costs payload: #{e.message}"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def symbolize(hash)
|
|
137
|
-
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
138
|
-
end
|
|
139
115
|
end
|
|
140
116
|
end
|
|
141
117
|
end
|
|
@@ -4,18 +4,18 @@ module LlmCostTracker
|
|
|
4
4
|
class Report
|
|
5
5
|
class Formatter
|
|
6
6
|
TOP_LIMIT = 5
|
|
7
|
-
|
|
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)
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
@@ -15,10 +15,6 @@ module LlmCostTracker
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
Formatter.new(report_data).to_s
|
|
18
|
-
rescue LoadError => e
|
|
19
|
-
"Unable to build LLM cost report: ActiveRecord storage is unavailable (#{e.message})"
|
|
20
|
-
rescue StandardError => e
|
|
21
|
-
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
22
18
|
end
|
|
23
19
|
end
|
|
24
20
|
end
|
|
@@ -32,6 +32,14 @@ module LlmCostTracker
|
|
|
32
32
|
.delete_all
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def prune_inbox(older_than:, now: Time.now.utc)
|
|
36
|
+
cutoff = resolve_cutoff(older_than, now)
|
|
37
|
+
require_relative "ingestion"
|
|
38
|
+
return 0 unless LlmCostTracker::Ingestion::InboxEntry.table_exists?
|
|
39
|
+
|
|
40
|
+
LlmCostTracker::Ingestion::InboxEntry.where(tracked_at: ...cutoff).delete_all
|
|
41
|
+
end
|
|
42
|
+
|
|
35
43
|
private
|
|
36
44
|
|
|
37
45
|
def resolve_cutoff(older_than, now)
|
|
@@ -61,20 +69,24 @@ module LlmCostTracker
|
|
|
61
69
|
|
|
62
70
|
def prune_batch(cutoff, batch_size)
|
|
63
71
|
LlmCostTracker::Call.transaction do
|
|
64
|
-
|
|
72
|
+
cache_rollups = LlmCostTracker.configuration.cache_rollups
|
|
73
|
+
rows = pluck_prunable(cutoff, batch_size, with_rollup_columns: cache_rollups)
|
|
65
74
|
next 0 if rows.empty?
|
|
66
75
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
end
|
|
76
|
+
ids = cache_rollups ? rows.map(&:first) : rows
|
|
77
|
+
deleted = LlmCostTracker::Call.where(id: ids).delete_all
|
|
78
|
+
LlmCostTracker::Ledger::Rollups.decrement!(rows) if cache_rollups && deleted.positive?
|
|
71
79
|
deleted
|
|
72
80
|
end
|
|
73
81
|
end
|
|
74
82
|
|
|
75
|
-
def pluck_prunable(cutoff, batch_size)
|
|
76
|
-
LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
77
|
-
|
|
83
|
+
def pluck_prunable(cutoff, batch_size, with_rollup_columns:)
|
|
84
|
+
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
|
|
78
90
|
end
|
|
79
91
|
end
|
|
80
92
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/string/inflections"
|
|
4
|
-
require "json"
|
|
5
4
|
|
|
6
5
|
module LlmCostTracker
|
|
7
6
|
module Tags
|
|
@@ -24,7 +23,7 @@ module LlmCostTracker
|
|
|
24
23
|
class << self
|
|
25
24
|
def call(tags, config: LlmCostTracker.configuration)
|
|
26
25
|
tags = (tags || {}).to_h
|
|
27
|
-
redacted =
|
|
26
|
+
redacted = config.normalized_redacted_tag_keys
|
|
28
27
|
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
29
28
|
max_count = [config.max_tag_count.to_i, 0].max
|
|
30
29
|
tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
|
|
@@ -32,6 +31,18 @@ module LlmCostTracker
|
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
|
|
34
|
+
def cap(tags, config: LlmCostTracker.configuration)
|
|
35
|
+
tags = (tags || {}).to_h
|
|
36
|
+
max_count = [config.max_tag_count.to_i, 0].max
|
|
37
|
+
return tags if tags.size <= max_count
|
|
38
|
+
|
|
39
|
+
tags.to_a.last(max_count).to_h
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def normalized_key(key)
|
|
43
|
+
key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
44
|
+
end
|
|
45
|
+
|
|
35
46
|
private
|
|
36
47
|
|
|
37
48
|
def sanitized_value(key, value, redacted, limit)
|
|
@@ -92,27 +103,12 @@ module LlmCostTracker
|
|
|
92
103
|
redacted.any? { |candidate| redacted_key_component?(normalized, candidate) }
|
|
93
104
|
end
|
|
94
105
|
|
|
95
|
-
def normalized_key(key)
|
|
96
|
-
key.to_s.underscore.gsub(/[^a-z0-9]+/, "_").delete_prefix("_").delete_suffix("_")
|
|
97
|
-
end
|
|
98
|
-
|
|
99
106
|
def redacted_key_component?(key, candidate)
|
|
100
107
|
key == candidate ||
|
|
101
108
|
key.start_with?("#{candidate}_") ||
|
|
102
109
|
key.end_with?("_#{candidate}") ||
|
|
103
110
|
key.include?("_#{candidate}_")
|
|
104
111
|
end
|
|
105
|
-
|
|
106
|
-
def value_string(value)
|
|
107
|
-
case value
|
|
108
|
-
when Hash, Array
|
|
109
|
-
JSON.generate(value)
|
|
110
|
-
else
|
|
111
|
-
value.to_s
|
|
112
|
-
end
|
|
113
|
-
rescue JSON::GeneratorError, TypeError
|
|
114
|
-
value.to_s
|
|
115
|
-
end
|
|
116
112
|
end
|
|
117
113
|
end
|
|
118
114
|
end
|
|
@@ -21,6 +21,10 @@ module LlmCostTracker
|
|
|
21
21
|
:total_tokens,
|
|
22
22
|
:hidden_output_tokens
|
|
23
23
|
) do
|
|
24
|
+
def priced_quantities
|
|
25
|
+
Billing::Components::TOKEN_PRICED.to_h { |component| [component.key, public_send(component.token_key)] }
|
|
26
|
+
end
|
|
27
|
+
|
|
24
28
|
def self.build_from_tokens(tokens)
|
|
25
29
|
return tokens if tokens.is_a?(self)
|
|
26
30
|
raise ArgumentError, "tokens must be a Hash, got #{tokens.class}" unless tokens.respond_to?(:to_h)
|