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
|
@@ -10,6 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
before_action :set_dashboard_security_headers
|
|
12
12
|
before_action :ensure_current_schema
|
|
13
|
+
before_action :assign_dashboard_date_range
|
|
13
14
|
|
|
14
15
|
helper_method :dashboard_csp_nonce
|
|
15
16
|
|
|
@@ -22,7 +23,7 @@ module LlmCostTracker
|
|
|
22
23
|
private
|
|
23
24
|
|
|
24
25
|
def ensure_current_schema
|
|
25
|
-
drift = LlmCostTracker::
|
|
26
|
+
drift = LlmCostTracker::Dashboard::SetupState.current
|
|
26
27
|
return unless drift
|
|
27
28
|
|
|
28
29
|
@setup_message = drift.message
|
|
@@ -30,6 +31,12 @@ module LlmCostTracker
|
|
|
30
31
|
render template: "llm_cost_tracker/shared/setup_required"
|
|
31
32
|
end
|
|
32
33
|
|
|
34
|
+
def assign_dashboard_date_range
|
|
35
|
+
range = LlmCostTracker::Dashboard::DateRange.call(params: params)
|
|
36
|
+
@from_date = range.from
|
|
37
|
+
@to_date = range.to
|
|
38
|
+
end
|
|
39
|
+
|
|
33
40
|
def render_database_error(error)
|
|
34
41
|
@error = error
|
|
35
42
|
render "llm_cost_tracker/errors/database", status: :internal_server_error
|
|
@@ -48,8 +55,13 @@ module LlmCostTracker
|
|
|
48
55
|
nonce = dashboard_csp_nonce
|
|
49
56
|
response.headers["X-Frame-Options"] = "DENY"
|
|
50
57
|
response.headers["Referrer-Policy"] = "same-origin"
|
|
51
|
-
response.headers["Content-Security-Policy"] =
|
|
52
|
-
"default-src 'self'
|
|
58
|
+
response.headers["Content-Security-Policy"] = [
|
|
59
|
+
"default-src 'self'",
|
|
60
|
+
"script-src 'self' 'nonce-#{nonce}'",
|
|
61
|
+
"style-src 'self' 'nonce-#{nonce}'",
|
|
62
|
+
"img-src 'self' data:",
|
|
63
|
+
"frame-ancestors 'none'"
|
|
64
|
+
].join("; ")
|
|
53
65
|
end
|
|
54
66
|
|
|
55
67
|
def dashboard_csp_nonce
|
|
@@ -6,24 +6,31 @@ require "json"
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
class CallsController < ApplicationController
|
|
8
8
|
CSV_EXPORT_LIMIT = 10_000
|
|
9
|
+
CSV_EXPORT_BATCH_SIZE = 500
|
|
9
10
|
CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
|
|
10
|
-
|
|
11
|
+
DEFAULT_TIEBREAKER = { tracked_at: :desc, id: :desc }.freeze
|
|
12
|
+
SORT_OPTIONS = %w[tracked_at provider model input output cost latency].freeze
|
|
13
|
+
NULLS_LAST_GUARD = {
|
|
14
|
+
total_cost: Arel.sql("CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC"),
|
|
15
|
+
latency_ms: Arel.sql("CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC")
|
|
16
|
+
}.freeze
|
|
11
17
|
|
|
12
18
|
def index
|
|
13
19
|
@sort = params[:sort].to_s
|
|
20
|
+
@dir = params[:dir].to_s
|
|
14
21
|
scope = Dashboard::Filter.call(params: params)
|
|
15
|
-
scope = scope.unknown_pricing if
|
|
16
|
-
ordered_scope = scope.order(
|
|
22
|
+
scope = scope.unknown_pricing if params[:cost_status].to_s == "incomplete"
|
|
23
|
+
ordered_scope = scope.order(*calls_order(@sort, @dir))
|
|
17
24
|
|
|
18
25
|
respond_to do |format|
|
|
19
26
|
format.html do
|
|
20
27
|
@page = Dashboard::Pagination.call(params)
|
|
21
|
-
@calls_count = scope.
|
|
28
|
+
@calls_count, @calls_total_cost = scope.pick(Arel.sql("COUNT(*), COALESCE(SUM(total_cost), 0)"))
|
|
22
29
|
@calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
|
|
23
30
|
end
|
|
24
31
|
format.csv do
|
|
25
32
|
response.headers["Cache-Control"] = "no-store"
|
|
26
|
-
send_data render_csv(ordered_scope
|
|
33
|
+
send_data render_csv(ordered_scope),
|
|
27
34
|
type: "text/csv",
|
|
28
35
|
disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
|
|
29
36
|
end
|
|
@@ -31,23 +38,24 @@ module LlmCostTracker
|
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def show
|
|
34
|
-
@call = LlmCostTracker::Call.find(params[:id])
|
|
41
|
+
@call = LlmCostTracker::Call.includes(:line_items, :tag_records).find(params[:id])
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
private
|
|
38
45
|
|
|
39
|
-
def calls_order(sort)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
when
|
|
46
|
-
|
|
47
|
-
when
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
def calls_order(sort, dir)
|
|
47
|
+
column = SORT_OPTIONS.include?(sort) ? sort.to_sym : :tracked_at
|
|
48
|
+
natural = %i[provider model].include?(column) ? :asc : :desc
|
|
49
|
+
direction = Dashboard::Sort::DIRECTIONS.include?(dir.downcase) ? dir.downcase.to_sym : natural
|
|
50
|
+
|
|
51
|
+
case column
|
|
52
|
+
when :tracked_at then [{ tracked_at: direction, id: direction }]
|
|
53
|
+
when :provider then [{ provider: direction, model: :asc, **DEFAULT_TIEBREAKER }]
|
|
54
|
+
when :model then [{ model: direction, **DEFAULT_TIEBREAKER }]
|
|
55
|
+
when :input then [{ input_tokens: direction, **DEFAULT_TIEBREAKER }]
|
|
56
|
+
when :output then [{ output_tokens: direction, **DEFAULT_TIEBREAKER }]
|
|
57
|
+
when :cost then [NULLS_LAST_GUARD[:total_cost], { total_cost: direction, **DEFAULT_TIEBREAKER }]
|
|
58
|
+
when :latency then [NULLS_LAST_GUARD[:latency_ms], { latency_ms: direction, **DEFAULT_TIEBREAKER }]
|
|
51
59
|
end
|
|
52
60
|
end
|
|
53
61
|
|
|
@@ -55,13 +63,24 @@ module LlmCostTracker
|
|
|
55
63
|
fields = csv_fields
|
|
56
64
|
CSV.generate do |csv|
|
|
57
65
|
csv << fields.map(&:to_s)
|
|
58
|
-
|
|
59
|
-
relation.includes(:tag_records).each do |call|
|
|
66
|
+
each_export_batch(relation) do |call|
|
|
60
67
|
csv << fields.map { |field| csv_value(field, call) }
|
|
61
68
|
end
|
|
62
69
|
end
|
|
63
70
|
end
|
|
64
71
|
|
|
72
|
+
def each_export_batch(relation, &)
|
|
73
|
+
offset = 0
|
|
74
|
+
while offset < CSV_EXPORT_LIMIT
|
|
75
|
+
batch_size = [CSV_EXPORT_BATCH_SIZE, CSV_EXPORT_LIMIT - offset].min
|
|
76
|
+
batch = relation.limit(batch_size).offset(offset).preload(:tag_records).to_a
|
|
77
|
+
break if batch.empty?
|
|
78
|
+
|
|
79
|
+
batch.each(&)
|
|
80
|
+
offset += batch.size
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
65
84
|
def csv_fields
|
|
66
85
|
%i[tracked_at provider model] +
|
|
67
86
|
TokenUsage.members +
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class DashboardController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
range = Dashboard::DateRange.call(params: params)
|
|
7
|
-
@from_date = range.from
|
|
8
|
-
@to_date = range.to
|
|
9
6
|
prev_from, prev_to = previous_range
|
|
10
7
|
filter_params = LlmCostTracker::Dashboard::Params.to_hash(params)
|
|
11
8
|
scope = Dashboard::Filter.call(
|
|
@@ -4,10 +4,12 @@ module LlmCostTracker
|
|
|
4
4
|
class ModelsController < ApplicationController
|
|
5
5
|
def index
|
|
6
6
|
@sort = params[:sort].to_s
|
|
7
|
+
@dir = params[:dir].to_s
|
|
7
8
|
@rows = Dashboard::TopModels.call(
|
|
8
9
|
scope: Dashboard::Filter.call(params: params),
|
|
9
10
|
limit: nil,
|
|
10
|
-
sort: @sort
|
|
11
|
+
sort: @sort,
|
|
12
|
+
direction: @dir
|
|
11
13
|
)
|
|
12
14
|
end
|
|
13
15
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class PricingController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@overview = Dashboard::PricingOverview.call
|
|
7
|
+
requested = params[:source].to_s.to_sym
|
|
8
|
+
@active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
|
|
9
|
+
@source_data = @overview.fetch(:sources).fetch(@active_source)
|
|
10
|
+
@provider_filter = params[:provider].to_s.presence
|
|
11
|
+
@rows = @source_data.fetch(:rows)
|
|
12
|
+
@rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
|
|
13
|
+
@providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -30,22 +30,18 @@ module LlmCostTracker
|
|
|
30
30
|
return redirect_to reconciliation_path, alert: "No importer configured for #{source}" if importer.nil?
|
|
31
31
|
|
|
32
32
|
result = importer.call
|
|
33
|
-
if result.
|
|
33
|
+
if result.errors.any?
|
|
34
34
|
LlmCostTracker::Logging.warn(
|
|
35
35
|
"Reconciliation import for #{source} returned #{result.errors.size} row error(s)"
|
|
36
36
|
)
|
|
37
37
|
return redirect_to(
|
|
38
38
|
reconciliation_path,
|
|
39
|
-
alert: "Imported #{result.
|
|
40
|
-
"
|
|
39
|
+
alert: "Imported #{result.total_imported} #{source} rows " \
|
|
40
|
+
"with #{result.errors.size} row error(s); see Rails logs for details."
|
|
41
41
|
)
|
|
42
42
|
end
|
|
43
|
-
|
|
44
|
-
"Imported #{result.total_imported} #{source} rows"
|
|
45
|
-
else
|
|
46
|
-
"Triggered #{source} importer"
|
|
47
|
-
end
|
|
48
|
-
redirect_to reconciliation_path, notice: message
|
|
43
|
+
redirect_to reconciliation_path,
|
|
44
|
+
notice: "Imported #{result.total_imported} #{source} rows"
|
|
49
45
|
rescue StandardError => e
|
|
50
46
|
LlmCostTracker::Logging.warn("Reconciliation import failed for #{source}: #{e.class}: #{e.message}")
|
|
51
47
|
redirect_to reconciliation_path,
|
|
@@ -59,13 +55,7 @@ module LlmCostTracker
|
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
def invoice_scopes
|
|
62
|
-
|
|
63
|
-
provider_expr =
|
|
64
|
-
if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
65
|
-
Arel.sql("metadata->>'provider'")
|
|
66
|
-
else
|
|
67
|
-
Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
|
|
68
|
-
end
|
|
58
|
+
provider_expr = Arel.sql(metadata_provider_sql)
|
|
69
59
|
LlmCostTracker::ProviderInvoice
|
|
70
60
|
.group(:source, provider_expr, :currency)
|
|
71
61
|
.order(:source, :currency)
|
|
@@ -92,14 +82,18 @@ module LlmCostTracker
|
|
|
92
82
|
def scope_invoices(scope)
|
|
93
83
|
relation = LlmCostTracker::ProviderInvoice
|
|
94
84
|
.where(source: scope[:source], currency: scope[:currency])
|
|
95
|
-
connection = LlmCostTracker::ProviderInvoice.connection
|
|
96
85
|
provider = scope[:provider]
|
|
97
86
|
return relation if provider.nil? || provider.empty?
|
|
98
87
|
|
|
88
|
+
relation.where("#{metadata_provider_sql} = ?", provider)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def metadata_provider_sql
|
|
92
|
+
connection = LlmCostTracker::ProviderInvoice.connection
|
|
99
93
|
if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
100
|
-
|
|
94
|
+
"metadata->>'provider'"
|
|
101
95
|
else
|
|
102
|
-
|
|
96
|
+
"JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))"
|
|
103
97
|
end
|
|
104
98
|
end
|
|
105
99
|
end
|
|
@@ -11,7 +11,9 @@ module LlmCostTracker
|
|
|
11
11
|
@value = params[:tag_value].to_s
|
|
12
12
|
|
|
13
13
|
if @value.empty?
|
|
14
|
-
@
|
|
14
|
+
@sort = params[:sort].to_s
|
|
15
|
+
@dir = params[:dir].to_s
|
|
16
|
+
@breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key], sort: @sort, direction: @dir)
|
|
15
17
|
else
|
|
16
18
|
@key = LlmCostTracker::Tags::Key.validate!(
|
|
17
19
|
params[:key],
|
|
@@ -15,6 +15,18 @@ module LlmCostTracker
|
|
|
15
15
|
include TokenUsageHelper
|
|
16
16
|
include InlineStyleHelper
|
|
17
17
|
|
|
18
|
+
def dashboard_section
|
|
19
|
+
path = request.path.to_s
|
|
20
|
+
return :models if path.start_with?(models_path)
|
|
21
|
+
return :calls if path.start_with?(calls_path)
|
|
22
|
+
return :tags if path.start_with?(tags_path)
|
|
23
|
+
return :data_quality if path.start_with?(data_quality_path)
|
|
24
|
+
return :pricing if path.start_with?(pricing_path)
|
|
25
|
+
return :reconciliation if LlmCostTracker.reconciliation_enabled? && path.start_with?(reconciliation_path)
|
|
26
|
+
|
|
27
|
+
:overview
|
|
28
|
+
end
|
|
29
|
+
|
|
18
30
|
def coverage_percent(numerator, denominator)
|
|
19
31
|
denominator = denominator.to_f
|
|
20
32
|
return 0.0 unless denominator.positive?
|
|
@@ -38,7 +50,7 @@ module LlmCostTracker
|
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
def number(value)
|
|
41
|
-
number_with_delimiter(value
|
|
53
|
+
number_with_delimiter(value)
|
|
42
54
|
end
|
|
43
55
|
|
|
44
56
|
def format_date(value)
|
|
@@ -46,14 +58,14 @@ module LlmCostTracker
|
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
def pricing_status(call)
|
|
49
|
-
return "Unknown
|
|
61
|
+
return "Unknown" if call.total_cost.nil?
|
|
50
62
|
return "Estimated" unless call.has_attribute?(:cost_status)
|
|
51
63
|
|
|
52
64
|
{
|
|
53
65
|
LlmCostTracker::Billing::CostStatus::COMPLETE => "Estimated",
|
|
54
66
|
LlmCostTracker::Billing::CostStatus::FREE => "Free",
|
|
55
|
-
LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial
|
|
56
|
-
}.fetch(call.cost_status, "Unknown
|
|
67
|
+
LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial"
|
|
68
|
+
}.fetch(call.cost_status, "Unknown")
|
|
57
69
|
end
|
|
58
70
|
|
|
59
71
|
def percent(value)
|
|
@@ -6,7 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
return nil if points.blank?
|
|
7
7
|
|
|
8
8
|
cfg = chart_config(points, comparison_points, height, y_ticks)
|
|
9
|
-
parts = [chart_svg_open(cfg)]
|
|
9
|
+
parts = [chart_svg_open(cfg), "<title>Daily spend trend</title>", chart_area_gradient_def]
|
|
10
10
|
parts.concat(chart_grid_and_axis(cfg))
|
|
11
11
|
parts << chart_paths(cfg)
|
|
12
12
|
parts.concat(chart_dots(cfg))
|
|
@@ -22,7 +22,7 @@ module LlmCostTracker
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def chart_config(points, comparison_points, height, y_ticks)
|
|
25
|
-
width =
|
|
25
|
+
width = 1180
|
|
26
26
|
pad = { top: 16, right: 16, bottom: 28, left: 56 }
|
|
27
27
|
plot_w = width - pad[:left] - pad[:right]
|
|
28
28
|
plot_h = height - pad[:top] - pad[:bottom]
|
|
@@ -31,9 +31,11 @@ module LlmCostTracker
|
|
|
31
31
|
coords = chart_coords(points, pad, plot_w, plot_h, max_cost)
|
|
32
32
|
comparison_coords = chart_coords(comparison_points, pad, plot_w, plot_h, max_cost) if comparison_points.present?
|
|
33
33
|
|
|
34
|
+
peak_index = points.each_with_index.max_by { |point, _| point[:cost].to_f }&.last
|
|
34
35
|
{ width: width, height: height, pad: pad, plot_w: plot_w, plot_h: plot_h,
|
|
35
36
|
max_cost: max_cost, n: points.size, y_ticks: y_ticks, points: points, coords: coords,
|
|
36
|
-
comparison_points: comparison_points, comparison_coords: comparison_coords
|
|
37
|
+
comparison_points: comparison_points, comparison_coords: comparison_coords,
|
|
38
|
+
peak_index: peak_index }
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def chart_coords(points, pad, plot_w, plot_h, max_cost)
|
|
@@ -51,7 +53,7 @@ module LlmCostTracker
|
|
|
51
53
|
attrs = [
|
|
52
54
|
%(class="lct-chart"),
|
|
53
55
|
%(viewBox="0 0 #{cfg[:width]} #{cfg[:height]}"),
|
|
54
|
-
%(preserveAspectRatio="
|
|
56
|
+
%(preserveAspectRatio="xMidYMid meet"),
|
|
55
57
|
%(role="img"),
|
|
56
58
|
%(aria-label="Daily spend trend")
|
|
57
59
|
].join(" ")
|
|
@@ -114,10 +116,20 @@ module LlmCostTracker
|
|
|
114
116
|
def chart_dot(cfg, pt_x, pt_y, idx)
|
|
115
117
|
point = cfg[:points][idx]
|
|
116
118
|
title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
|
|
117
|
-
|
|
119
|
+
peak = idx == cfg[:peak_index]
|
|
120
|
+
klass = peak ? "lct-chart-peak" : "lct-chart-dot"
|
|
121
|
+
radius = peak ? 4 : 3
|
|
122
|
+
circle = %(<circle class="#{klass}" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="#{radius}"/>)
|
|
118
123
|
"<g>#{circle}<title>#{title}</title></g>"
|
|
119
124
|
end
|
|
120
125
|
|
|
126
|
+
def chart_area_gradient_def
|
|
127
|
+
"<defs><linearGradient id=\"lct-chart-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" \
|
|
128
|
+
"<stop offset=\"0%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.28\"/>" \
|
|
129
|
+
"<stop offset=\"100%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.02\"/>" \
|
|
130
|
+
"</linearGradient></defs>"
|
|
131
|
+
end
|
|
132
|
+
|
|
121
133
|
def chart_x_labels(cfg)
|
|
122
134
|
indexes = cfg[:n] <= 2 ? (0...cfg[:n]).to_a : [0, cfg[:n] / 2, cfg[:n] - 1].uniq
|
|
123
135
|
label_y = chart_fmt(cfg[:height] - 8)
|
|
@@ -127,7 +139,11 @@ module LlmCostTracker
|
|
|
127
139
|
def chart_x_label(cfg, idx, label_y)
|
|
128
140
|
pt_x, = cfg[:coords][idx]
|
|
129
141
|
label = ERB::Util.html_escape(cfg[:points][idx][:label])
|
|
130
|
-
|
|
142
|
+
anchor = if idx.zero? then "start"
|
|
143
|
+
elsif idx == cfg[:n] - 1 then "end"
|
|
144
|
+
else "middle"
|
|
145
|
+
end
|
|
146
|
+
%(<text class="lct-chart-axis" x="#{chart_fmt(pt_x)}" y="#{label_y}" text-anchor="#{anchor}">#{label}</text>)
|
|
131
147
|
end
|
|
132
148
|
end
|
|
133
149
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module SortableTableHelper
|
|
5
|
+
def sortable_header(label, column, num: false)
|
|
6
|
+
state = sortable_state(column, num: num)
|
|
7
|
+
classes = ["lct-sortable"]
|
|
8
|
+
classes << "lct-num" if num
|
|
9
|
+
classes << "lct-sorted" if state[:active]
|
|
10
|
+
|
|
11
|
+
href = dashboard_filter_path(current_query(sort: column, dir: state[:next_dir], page: nil))
|
|
12
|
+
tag.th(class: classes.join(" "), "aria-sort": state[:aria_sort]) do
|
|
13
|
+
link_to(href) { safe_join([label, " ", tag.span(state[:arrow], class: "lct-sort-ind")]) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def sortable_state(column, num:)
|
|
20
|
+
current_sort = params[:sort].to_s
|
|
21
|
+
current_dir = Dashboard::Sort::DIRECTIONS.include?(params[:dir].to_s) ? params[:dir].to_s : nil
|
|
22
|
+
natural_dir = num ? "desc" : "asc"
|
|
23
|
+
active = current_sort == column
|
|
24
|
+
effective_dir = active ? (current_dir || natural_dir) : natural_dir
|
|
25
|
+
flipped = effective_dir == "asc" ? "desc" : "asc"
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
active: active,
|
|
29
|
+
next_dir: active ? flipped : natural_dir,
|
|
30
|
+
arrow: active && effective_dir == "asc" ? "▲" : "▼",
|
|
31
|
+
aria_sort: sortable_aria(active, effective_dir)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sortable_aria(active, effective_dir)
|
|
36
|
+
return "none" unless active
|
|
37
|
+
|
|
38
|
+
effective_dir == "asc" ? "ascending" : "descending"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -8,17 +8,22 @@ module LlmCostTracker
|
|
|
8
8
|
STATES = [STATE_RUNNING, STATE_COMPLETED, STATE_FAILED].freeze
|
|
9
9
|
|
|
10
10
|
scope :for_source, ->(source) { where(source: source.to_s) }
|
|
11
|
+
scope :for_provider, ->(provider) { where(provider: provider.to_s) }
|
|
11
12
|
scope :running, -> { where(state: STATE_RUNNING) }
|
|
12
13
|
scope :completed, -> { where(state: STATE_COMPLETED) }
|
|
13
14
|
scope :failed, -> { where(state: STATE_FAILED) }
|
|
14
15
|
scope :latest, -> { order(started_at: :desc, id: :desc) }
|
|
15
16
|
|
|
16
|
-
def self.resume_cursor_for(source)
|
|
17
|
-
for_source(source)
|
|
17
|
+
def self.resume_cursor_for(source, provider: nil)
|
|
18
|
+
scope = for_source(source)
|
|
19
|
+
scope = scope.for_provider(provider) if provider
|
|
20
|
+
scope.latest.limit(1).pick(:cursor)
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
def self.last_completed_window_for(source)
|
|
21
|
-
for_source(source)
|
|
23
|
+
def self.last_completed_window_for(source, provider: nil)
|
|
24
|
+
scope = for_source(source)
|
|
25
|
+
scope = scope.for_provider(provider) if provider
|
|
26
|
+
scope.completed.latest.limit(1).pick(:window_start, :window_end)
|
|
22
27
|
end
|
|
23
28
|
end
|
|
24
29
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
class PricingOverview
|
|
6
|
+
SOURCES = %i[overrides file bundled].freeze
|
|
7
|
+
RATE_COLUMNS = %i[input output cache_read_input cache_write_input batch_input batch_output].freeze
|
|
8
|
+
Row = Data.define(:provider, :model, :rates)
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def call
|
|
12
|
+
new.call
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
sources = SOURCES.each_with_object({}) do |source, acc|
|
|
18
|
+
built = build_source(source)
|
|
19
|
+
acc[source] = built if built
|
|
20
|
+
end
|
|
21
|
+
{
|
|
22
|
+
sources: sources,
|
|
23
|
+
effective_source: sources.keys.first || :bundled
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def build_source(source)
|
|
30
|
+
case source
|
|
31
|
+
when :overrides then build_overrides
|
|
32
|
+
when :file then build_file
|
|
33
|
+
when :bundled then build_bundled
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_overrides
|
|
38
|
+
prices = LlmCostTracker.configuration.pricing_overrides
|
|
39
|
+
return nil if prices.nil? || prices.empty?
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
label: "Overrides",
|
|
43
|
+
subtitle: "config.pricing_overrides",
|
|
44
|
+
updated_at: nil,
|
|
45
|
+
currency: nil,
|
|
46
|
+
rows: build_rows(prices)
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_file
|
|
51
|
+
path = LlmCostTracker.configuration.prices_file
|
|
52
|
+
return nil unless path && File.exist?(path)
|
|
53
|
+
|
|
54
|
+
prices = Pricing::Registry.file_prices(path)
|
|
55
|
+
return nil if prices.empty?
|
|
56
|
+
|
|
57
|
+
meta = Pricing::Registry.file_metadata(path)
|
|
58
|
+
{
|
|
59
|
+
label: "Custom file",
|
|
60
|
+
subtitle: path.to_s,
|
|
61
|
+
updated_at: meta["updated_at"] || Pricing::Lookup.prices_file_mtime_iso,
|
|
62
|
+
currency: meta["currency"] || Pricing::Lookup::DEFAULT_CURRENCY,
|
|
63
|
+
rows: build_rows(prices)
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_bundled
|
|
68
|
+
prices = Pricing::Registry.builtin_prices
|
|
69
|
+
meta = Pricing::Registry.metadata
|
|
70
|
+
{
|
|
71
|
+
label: "Bundled",
|
|
72
|
+
subtitle: "ships with the gem",
|
|
73
|
+
updated_at: meta["updated_at"],
|
|
74
|
+
currency: meta["currency"] || Pricing::Lookup::DEFAULT_CURRENCY,
|
|
75
|
+
rows: build_rows(prices)
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_rows(prices)
|
|
80
|
+
rows = prices.map do |key, rates|
|
|
81
|
+
provider, model = split_key(key.to_s)
|
|
82
|
+
Row.new(provider: provider, model: model, rates: rates)
|
|
83
|
+
end
|
|
84
|
+
rows.sort_by { |row| [row.provider || "~", row.model] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def split_key(key)
|
|
88
|
+
provider, model = key.split("/", 2)
|
|
89
|
+
return [provider, model] if model
|
|
90
|
+
|
|
91
|
+
[nil, provider]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "llm_cost_tracker/ledger"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Dashboard
|
|
7
|
+
module SetupState
|
|
8
|
+
SetupRequired = Data.define(:message, :details)
|
|
9
|
+
DOCS_HINT = "See docs/upgrading.md for the migration path."
|
|
10
|
+
MUTEX = Mutex.new
|
|
11
|
+
|
|
12
|
+
private_constant :MUTEX, :DOCS_HINT
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def current
|
|
16
|
+
fingerprint = schema_fingerprint
|
|
17
|
+
|
|
18
|
+
MUTEX.synchronize do
|
|
19
|
+
if !defined?(@cache_fingerprint) || @cache_fingerprint != fingerprint
|
|
20
|
+
LlmCostTracker::Call.reset_column_information
|
|
21
|
+
@cached = compute
|
|
22
|
+
@cache_fingerprint = fingerprint
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
@cached
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reset!
|
|
29
|
+
MUTEX.synchronize do
|
|
30
|
+
remove_instance_variable(:@cached) if defined?(@cached)
|
|
31
|
+
remove_instance_variable(:@cache_fingerprint) if defined?(@cache_fingerprint)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
SCHEMA_MIGRATIONS_TABLE = "schema_migrations"
|
|
38
|
+
private_constant :SCHEMA_MIGRATIONS_TABLE
|
|
39
|
+
|
|
40
|
+
def schema_fingerprint
|
|
41
|
+
connection = ActiveRecord::Base.connection
|
|
42
|
+
quoted = connection.quote_table_name(SCHEMA_MIGRATIONS_TABLE)
|
|
43
|
+
connection.query_value("SELECT MAX(version) FROM #{quoted}")
|
|
44
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::ConnectionNotEstablished
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def compute
|
|
49
|
+
LlmCostTracker::Logging.debug("Dashboard::SetupState recomputing")
|
|
50
|
+
return calls_table_missing unless LlmCostTracker::Call.table_exists?
|
|
51
|
+
|
|
52
|
+
core_drift = drift_in(schema_checks_for_current_config)
|
|
53
|
+
return core_drift if core_drift
|
|
54
|
+
return nil unless LlmCostTracker.reconciliation_enabled?
|
|
55
|
+
|
|
56
|
+
reconciliation_drift
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def schema_checks_for_current_config
|
|
60
|
+
return LlmCostTracker::Ledger::Schema::CORE_SCHEMAS unless LlmCostTracker.configuration.cache_rollups
|
|
61
|
+
|
|
62
|
+
LlmCostTracker::Ledger::Schema::CORE_SCHEMAS + [LlmCostTracker::Ledger::Schema::CACHE_ROLLUPS_SCHEMA]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def drift_in(checks)
|
|
66
|
+
checks.each do |schema, table|
|
|
67
|
+
errors = schema.current_schema_errors
|
|
68
|
+
next if errors.empty?
|
|
69
|
+
|
|
70
|
+
message = "The #{table} table does not match the current LLM Cost Tracker schema."
|
|
71
|
+
return SetupRequired.new(message: message, details: errors)
|
|
72
|
+
end
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reconciliation_drift
|
|
77
|
+
connection = ActiveRecord::Base.connection
|
|
78
|
+
LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
|
|
79
|
+
unless connection.data_source_exists?(table)
|
|
80
|
+
return SetupRequired.new(
|
|
81
|
+
message: "The #{table} table is required when reconciliation is enabled.",
|
|
82
|
+
details: ["bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate"]
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
errors = schema.current_schema_errors
|
|
87
|
+
next if errors.empty?
|
|
88
|
+
|
|
89
|
+
message = "The #{table} table does not match the current LLM Cost Tracker schema."
|
|
90
|
+
return SetupRequired.new(message: message, details: errors)
|
|
91
|
+
end
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def calls_table_missing
|
|
96
|
+
SetupRequired.new(
|
|
97
|
+
message: "The llm_cost_tracker_calls table is not available yet.",
|
|
98
|
+
details: nil
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|