llm_cost_tracker 0.1.4 → 0.2.0.alpha2
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 +58 -91
- data/PLAN_0.2.md +488 -0
- data/README.md +140 -320
- data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
- data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
- data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
- data/app/services/llm_cost_tracker/pagination.rb +59 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
- data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
- data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
- data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
- data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
- data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
- data/config/routes.rb +10 -0
- data/lib/llm_cost_tracker/budget.rb +16 -38
- data/lib/llm_cost_tracker/configuration.rb +3 -1
- data/lib/llm_cost_tracker/cost.rb +1 -3
- data/lib/llm_cost_tracker/engine.rb +13 -0
- data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
- data/lib/llm_cost_tracker/errors.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +1 -3
- data/lib/llm_cost_tracker/event_metadata.rb +9 -18
- data/lib/llm_cost_tracker/llm_api_call.rb +4 -17
- data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
- data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
- data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
- data/lib/llm_cost_tracker/parsers/base.rb +3 -8
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
- data/lib/llm_cost_tracker/period_grouping.rb +68 -0
- data/lib/llm_cost_tracker/price_registry.rb +22 -30
- data/lib/llm_cost_tracker/pricing.rb +10 -19
- data/lib/llm_cost_tracker/report.rb +4 -4
- data/lib/llm_cost_tracker/report_data.rb +21 -24
- data/lib/llm_cost_tracker/report_formatter.rb +4 -2
- data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
- data/lib/llm_cost_tracker/tag_key.rb +16 -0
- data/lib/llm_cost_tracker/tracker.rb +35 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +3 -6
- data/llm_cost_tracker.gemspec +13 -9
- metadata +91 -20
- data/.rubocop.yml +0 -44
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
- data/lib/llm_cost_tracker/storage/backends.rb +0 -26
- data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
- data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
- data/lib/llm_cost_tracker/value_object.rb +0 -45
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
layout "llm_cost_tracker/application"
|
|
6
|
+
|
|
7
|
+
before_action :ensure_llm_api_calls_table
|
|
8
|
+
|
|
9
|
+
rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
|
|
10
|
+
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
11
|
+
rescue_from ActiveRecord::StatementInvalid, with: :render_database_error
|
|
12
|
+
rescue_from LlmCostTracker::InvalidFilterError, with: :render_invalid_filter
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def ensure_llm_api_calls_table
|
|
17
|
+
return if llm_api_calls_table_available?
|
|
18
|
+
|
|
19
|
+
render template: "llm_cost_tracker/shared/setup_required"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def llm_api_calls_table_available?
|
|
23
|
+
LlmCostTracker::LlmApiCall.table_exists?
|
|
24
|
+
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::StatementInvalid
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def render_database_error(error)
|
|
29
|
+
@error = error
|
|
30
|
+
render "llm_cost_tracker/errors/database", status: :internal_server_error
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_invalid_filter(error)
|
|
34
|
+
@error_message = error.message
|
|
35
|
+
render "llm_cost_tracker/errors/invalid_filter", status: :bad_request
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_not_found
|
|
39
|
+
render "llm_cost_tracker/errors/not_found", status: :not_found
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class CallsController < ApplicationController
|
|
7
|
+
SORT_ORDERS = {
|
|
8
|
+
"expensive" => "total_cost DESC NULLS LAST, tracked_at DESC",
|
|
9
|
+
"input" => "input_tokens DESC, tracked_at DESC",
|
|
10
|
+
"output" => "output_tokens DESC, tracked_at DESC",
|
|
11
|
+
"slow" => "latency_ms DESC NULLS LAST, tracked_at DESC",
|
|
12
|
+
"unknown_pricing" => "tracked_at DESC, id DESC"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
CSV_EXPORT_LIMIT = 10_000
|
|
16
|
+
|
|
17
|
+
def index
|
|
18
|
+
@sort = params[:sort].to_s
|
|
19
|
+
scope = Dashboard::Filter.call(params: params)
|
|
20
|
+
scope = scope.unknown_pricing if @sort == "unknown_pricing"
|
|
21
|
+
ordered_scope = scope.order(Arel.sql(calls_order(@sort)))
|
|
22
|
+
@latency_available = LlmApiCall.latency_column?
|
|
23
|
+
|
|
24
|
+
respond_to do |format|
|
|
25
|
+
format.html do
|
|
26
|
+
@page = Pagination.call(params)
|
|
27
|
+
@calls_count = scope.count
|
|
28
|
+
@calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
|
|
29
|
+
end
|
|
30
|
+
format.csv do
|
|
31
|
+
send_data render_csv(ordered_scope.limit(CSV_EXPORT_LIMIT)),
|
|
32
|
+
type: "text/csv",
|
|
33
|
+
disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show
|
|
39
|
+
@call = LlmApiCall.find(params[:id])
|
|
40
|
+
@tags = @call.parsed_tags
|
|
41
|
+
@metadata_available = @call.has_attribute?("metadata")
|
|
42
|
+
@metadata = @call.read_attribute("metadata") if @metadata_available
|
|
43
|
+
@latency_available = LlmApiCall.latency_column?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def calls_order(sort)
|
|
49
|
+
SORT_ORDERS[sort] || "tracked_at DESC, id DESC"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_csv(relation)
|
|
53
|
+
latency = LlmApiCall.latency_column?
|
|
54
|
+
CSV.generate do |csv|
|
|
55
|
+
headers = %w[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
|
|
56
|
+
headers << "latency_ms" if latency
|
|
57
|
+
headers << "tags"
|
|
58
|
+
csv << headers
|
|
59
|
+
|
|
60
|
+
relation.each do |call|
|
|
61
|
+
row = [
|
|
62
|
+
call.tracked_at&.utc&.iso8601,
|
|
63
|
+
call.provider,
|
|
64
|
+
call.model,
|
|
65
|
+
call.input_tokens,
|
|
66
|
+
call.output_tokens,
|
|
67
|
+
call.total_tokens,
|
|
68
|
+
call.total_cost
|
|
69
|
+
]
|
|
70
|
+
row << call.latency_ms if latency
|
|
71
|
+
row << call.parsed_tags.to_json
|
|
72
|
+
csv << row
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class DashboardController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@from_date, @to_date = overview_range
|
|
7
|
+
scope = Dashboard::Filter.call(params: overview_filter_params)
|
|
8
|
+
previous_scope = Dashboard::Filter.call(params: previous_filter_params)
|
|
9
|
+
|
|
10
|
+
@stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
|
|
11
|
+
@time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
|
|
12
|
+
@top_models = Dashboard::TopModels.call(scope: scope, limit: 5)
|
|
13
|
+
@providers = Dashboard::ProviderBreakdown.call(scope: scope)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def overview_range
|
|
19
|
+
to_date = parsed_date(params[:to]) || Date.current
|
|
20
|
+
from_date = parsed_date(params[:from]) || (to_date - 29)
|
|
21
|
+
[from_date, to_date]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def previous_range
|
|
25
|
+
span_days = (@to_date - @from_date).to_i + 1
|
|
26
|
+
prev_to = @from_date - 1
|
|
27
|
+
prev_from = prev_to - (span_days - 1)
|
|
28
|
+
[prev_from, prev_to]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def overview_filter_params
|
|
32
|
+
params.to_unsafe_h.merge(
|
|
33
|
+
"from" => @from_date.iso8601,
|
|
34
|
+
"to" => @to_date.iso8601
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def previous_filter_params
|
|
39
|
+
prev_from, prev_to = previous_range
|
|
40
|
+
params.to_unsafe_h.merge(
|
|
41
|
+
"from" => prev_from.iso8601,
|
|
42
|
+
"to" => prev_to.iso8601
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parsed_date(value)
|
|
47
|
+
return nil if value.to_s.strip.empty?
|
|
48
|
+
|
|
49
|
+
Date.iso8601(value.to_s)
|
|
50
|
+
rescue ArgumentError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class ModelsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
scope = Dashboard::Filter.call(params: params)
|
|
7
|
+
@sort = params[:sort].to_s
|
|
8
|
+
@rows = Dashboard::TopModels.call(scope: scope, limit: nil, sort: @sort)
|
|
9
|
+
@latency_available = LlmApiCall.latency_column?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class TagsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
scope = Dashboard::Filter.call(params: params)
|
|
7
|
+
@rows = Dashboard::TagKeyExplorer.call(scope: scope)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
@tag_key = params[:key]
|
|
12
|
+
scope = Dashboard::Filter.call(params: params)
|
|
13
|
+
@rows = Dashboard::TagBreakdown.call(scope: scope, key: @tag_key)
|
|
14
|
+
@total_calls = @rows.sum(&:calls)
|
|
15
|
+
|
|
16
|
+
tagged_rows = @rows.reject { |r| r.value == "(untagged)" }
|
|
17
|
+
@tagged_calls = tagged_rows.sum(&:calls)
|
|
18
|
+
@distinct_values = tagged_rows.size
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module ApplicationHelper
|
|
7
|
+
def coverage_percent(numerator, denominator)
|
|
8
|
+
return 0.0 unless denominator.to_i.positive?
|
|
9
|
+
|
|
10
|
+
(numerator.to_f / denominator) * 100.0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def money(value)
|
|
14
|
+
value = value.to_f
|
|
15
|
+
precision = value.abs < 0.01 && value != 0.0 ? 6 : 2
|
|
16
|
+
|
|
17
|
+
"$#{format("%.#{precision}f", value)}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def optional_money(value)
|
|
21
|
+
value.nil? ? "n/a" : money(value)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def optional_number(value)
|
|
25
|
+
value.nil? ? "n/a" : number(value)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def number(value)
|
|
29
|
+
number_with_delimiter(value.to_i)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def format_tokens(value)
|
|
33
|
+
number(value)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_date(value)
|
|
37
|
+
value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d %H:%M") : value.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pricing_status(call)
|
|
41
|
+
call.total_cost.nil? ? "Unknown pricing" : "Estimated"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def percent(value)
|
|
45
|
+
"#{format('%.1f', value.to_f)}%"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def delta_badge(delta_percent, mode: :cost)
|
|
49
|
+
return { text: "vs. prior: n/a", css_class: "lct-delta lct-delta-neutral" } if delta_percent.nil?
|
|
50
|
+
|
|
51
|
+
rounded = delta_percent.round(1)
|
|
52
|
+
return { text: "= vs. prior", css_class: "lct-delta lct-delta-neutral" } if rounded.zero?
|
|
53
|
+
|
|
54
|
+
sign = rounded.positive? ? "+" : ""
|
|
55
|
+
text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
|
|
56
|
+
css_class = if mode == :neutral
|
|
57
|
+
"lct-delta lct-delta-neutral"
|
|
58
|
+
elsif rounded.positive?
|
|
59
|
+
"lct-delta lct-delta-up"
|
|
60
|
+
else
|
|
61
|
+
"lct-delta lct-delta-down"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
{ text: text, css_class: css_class }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def bar_width(value, max)
|
|
68
|
+
max = max.to_f
|
|
69
|
+
return "0%" unless max.positive?
|
|
70
|
+
|
|
71
|
+
"#{[(value.to_f / max) * 100.0, 100.0].min.round(2)}%"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def safe_json(value)
|
|
75
|
+
parsed = value.is_a?(String) ? JSON.parse(value) : value
|
|
76
|
+
JSON.pretty_generate(parsed || {})
|
|
77
|
+
rescue JSON::ParserError, TypeError
|
|
78
|
+
value.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tags_summary(tags, limit: 3)
|
|
82
|
+
tags = normalized_tags(tags)
|
|
83
|
+
return "(untagged)" if tags.empty?
|
|
84
|
+
|
|
85
|
+
summary = tags.first(limit).map { |key, value| "#{key}=#{tag_value_summary(value)}" }
|
|
86
|
+
summary << "+#{tags.size - limit}" if tags.size > limit
|
|
87
|
+
summary.join(", ")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def current_query(overrides = {})
|
|
91
|
+
request.query_parameters.symbolize_keys.merge(overrides)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def normalized_tags(tags)
|
|
97
|
+
return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
JSON.parse(tags || "{}")
|
|
100
|
+
rescue JSON::ParserError, TypeError
|
|
101
|
+
{}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def tag_value_summary(value)
|
|
105
|
+
case value
|
|
106
|
+
when Hash, Array
|
|
107
|
+
JSON.generate(value)
|
|
108
|
+
else
|
|
109
|
+
value.to_s
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
DataQualityStats = Data.define(
|
|
6
|
+
:total_calls,
|
|
7
|
+
:unknown_pricing_count,
|
|
8
|
+
:untagged_calls_count,
|
|
9
|
+
:missing_latency_count,
|
|
10
|
+
:latency_column_present,
|
|
11
|
+
:unknown_pricing_by_model
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Computes data quality metrics: coverage of cost, tags, and latency.
|
|
15
|
+
class DataQuality
|
|
16
|
+
class << self
|
|
17
|
+
def call(scope: LlmCostTracker::LlmApiCall.all)
|
|
18
|
+
total = scope.count
|
|
19
|
+
latency_present = LlmCostTracker::LlmApiCall.latency_column?
|
|
20
|
+
|
|
21
|
+
DataQualityStats.new(
|
|
22
|
+
total_calls: total,
|
|
23
|
+
unknown_pricing_count: scope.unknown_pricing.count,
|
|
24
|
+
untagged_calls_count: total - scope.with_json_tags.count,
|
|
25
|
+
missing_latency_count: latency_present ? scope.where(latency_ms: nil).count : nil,
|
|
26
|
+
latency_column_present: latency_present,
|
|
27
|
+
unknown_pricing_by_model: scope.unknown_pricing
|
|
28
|
+
.group(:model)
|
|
29
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
30
|
+
.count
|
|
31
|
+
.first(10)
|
|
32
|
+
.to_h
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Dashboard
|
|
7
|
+
# Parses dashboard params into an ActiveRecord relation.
|
|
8
|
+
#
|
|
9
|
+
# Invalid dates are ignored, pagination is handled elsewhere, and invalid
|
|
10
|
+
# tag keys raise InvalidFilterError so controllers can fail closed with HTTP 400.
|
|
11
|
+
class Filter
|
|
12
|
+
class << self
|
|
13
|
+
def call(scope: LlmCostTracker::LlmApiCall.all, params: {})
|
|
14
|
+
new(scope: scope, params: params).relation
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(scope:, params:)
|
|
19
|
+
@scope = scope
|
|
20
|
+
@params = normalize_params(params)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def relation
|
|
24
|
+
filtered_scope = scope
|
|
25
|
+
filtered_scope = apply_date_filters(filtered_scope)
|
|
26
|
+
filtered_scope = apply_exact_filter(filtered_scope, :provider)
|
|
27
|
+
filtered_scope = apply_exact_filter(filtered_scope, :model)
|
|
28
|
+
apply_tag_filters(filtered_scope)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :scope, :params
|
|
34
|
+
|
|
35
|
+
def normalize_params(params)
|
|
36
|
+
return {}.with_indifferent_access if params.nil?
|
|
37
|
+
|
|
38
|
+
raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
39
|
+
raw.with_indifferent_access
|
|
40
|
+
rescue NoMethodError
|
|
41
|
+
{}.with_indifferent_access
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def apply_date_filters(relation)
|
|
45
|
+
from = parse_date(:from)&.beginning_of_day
|
|
46
|
+
to = parse_date(:to)&.end_of_day
|
|
47
|
+
|
|
48
|
+
relation = relation.where(tracked_at: from..) if from
|
|
49
|
+
relation = relation.where(tracked_at: ..to) if to
|
|
50
|
+
relation
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_exact_filter(relation, key)
|
|
54
|
+
value = string_param(key)
|
|
55
|
+
return relation if value.nil?
|
|
56
|
+
|
|
57
|
+
relation.where(key => value)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def apply_tag_filters(relation)
|
|
61
|
+
tags = tag_params
|
|
62
|
+
return relation if tags.empty?
|
|
63
|
+
|
|
64
|
+
relation.by_tags(tags)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def tag_params
|
|
68
|
+
tags = hash_param(:tag)
|
|
69
|
+
tag_key = string_param(:tag_key)
|
|
70
|
+
tag_value = string_param(:tag_value)
|
|
71
|
+
tags = tags.merge(tag_key => tag_value) if tag_key && tag_value
|
|
72
|
+
|
|
73
|
+
tags.each_with_object({}) do |(key, value), normalized|
|
|
74
|
+
value = normalized_string(value)
|
|
75
|
+
next if value.nil?
|
|
76
|
+
|
|
77
|
+
normalized[LlmCostTracker::TagKey.validate!(key, error_class: LlmCostTracker::InvalidFilterError)] = value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def hash_param(key)
|
|
82
|
+
raw = params[key]
|
|
83
|
+
raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
|
|
84
|
+
raw = raw.to_h if raw.respond_to?(:to_h)
|
|
85
|
+
raw.is_a?(Hash) ? raw : {}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_date(key)
|
|
89
|
+
value = string_param(key)
|
|
90
|
+
return nil if value.nil?
|
|
91
|
+
|
|
92
|
+
Date.iso8601(value)
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def string_param(key)
|
|
98
|
+
normalized_string(params[key])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalized_string(value)
|
|
102
|
+
return nil if value.nil?
|
|
103
|
+
|
|
104
|
+
value = value.to_s.strip
|
|
105
|
+
value.empty? ? nil : value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
OverviewStatsData = Data.define(
|
|
6
|
+
:total_cost,
|
|
7
|
+
:total_calls,
|
|
8
|
+
:average_cost_per_call,
|
|
9
|
+
:average_latency_ms,
|
|
10
|
+
:unknown_pricing_count,
|
|
11
|
+
:previous_total_cost,
|
|
12
|
+
:previous_total_calls,
|
|
13
|
+
:cost_delta_percent,
|
|
14
|
+
:calls_delta_percent,
|
|
15
|
+
:monthly_budget_status
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class OverviewStats
|
|
19
|
+
class << self
|
|
20
|
+
def call(scope: LlmCostTracker::LlmApiCall.all, previous_scope: nil)
|
|
21
|
+
current = aggregate(scope)
|
|
22
|
+
total_calls = current.calls_count.to_i
|
|
23
|
+
total_cost = current.total_cost_sum.to_f
|
|
24
|
+
|
|
25
|
+
previous = previous_scope && aggregate(previous_scope)
|
|
26
|
+
prev_cost = previous&.total_cost_sum.to_f
|
|
27
|
+
prev_calls = previous&.calls_count.to_i
|
|
28
|
+
|
|
29
|
+
OverviewStatsData.new(
|
|
30
|
+
total_cost: total_cost,
|
|
31
|
+
total_calls: total_calls,
|
|
32
|
+
average_cost_per_call: total_calls.positive? ? total_cost / total_calls : 0.0,
|
|
33
|
+
average_latency_ms: latency_value(current, scope),
|
|
34
|
+
unknown_pricing_count: current.unknown_pricing_count.to_i,
|
|
35
|
+
previous_total_cost: previous ? prev_cost : nil,
|
|
36
|
+
previous_total_calls: previous ? prev_calls : nil,
|
|
37
|
+
cost_delta_percent: previous ? delta_percent(total_cost, prev_cost) : nil,
|
|
38
|
+
calls_delta_percent: previous ? delta_percent(total_calls, prev_calls) : nil,
|
|
39
|
+
monthly_budget_status: budget_status
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def aggregate(scope)
|
|
46
|
+
scope.select(aggregate_selects(scope)).take
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def aggregate_selects(scope)
|
|
50
|
+
selects = [
|
|
51
|
+
"COUNT(*) AS calls_count",
|
|
52
|
+
"COALESCE(SUM(total_cost), 0) AS total_cost_sum",
|
|
53
|
+
"SUM(CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END) AS unknown_pricing_count"
|
|
54
|
+
]
|
|
55
|
+
selects << "AVG(latency_ms) AS average_latency" if scope.klass.latency_column?
|
|
56
|
+
selects.join(", ")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def latency_value(row, scope)
|
|
60
|
+
return nil unless scope.klass.latency_column?
|
|
61
|
+
|
|
62
|
+
row.average_latency&.to_f
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def delta_percent(current, previous)
|
|
66
|
+
current = current.to_f
|
|
67
|
+
previous = previous.to_f
|
|
68
|
+
return nil if previous.zero?
|
|
69
|
+
|
|
70
|
+
((current - previous) / previous) * 100.0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def budget_status
|
|
74
|
+
budget = LlmCostTracker.configuration.monthly_budget
|
|
75
|
+
return nil unless budget
|
|
76
|
+
|
|
77
|
+
spent = LlmCostTracker::LlmApiCall.this_month.total_cost
|
|
78
|
+
{
|
|
79
|
+
budget: budget.to_f,
|
|
80
|
+
spent: spent,
|
|
81
|
+
percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
ProviderRow = Data.define(:provider, :calls, :total_cost, :share_percent)
|
|
6
|
+
|
|
7
|
+
# Aggregates cost and call counts per provider for a given scope.
|
|
8
|
+
# Sorted by total cost descending; providers with zero cost fall to the bottom
|
|
9
|
+
# but are still returned so users can see calls without pricing.
|
|
10
|
+
class ProviderBreakdown
|
|
11
|
+
def self.call(scope: LlmCostTracker::LlmApiCall.all)
|
|
12
|
+
new(scope: scope).rows
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(scope:)
|
|
16
|
+
@scope = scope
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rows
|
|
20
|
+
grouped = scope
|
|
21
|
+
.group(:provider)
|
|
22
|
+
.select("provider, COUNT(*) AS calls_count, COALESCE(SUM(total_cost), 0) AS total_cost_sum")
|
|
23
|
+
.order(Arel.sql("total_cost_sum DESC, calls_count DESC"))
|
|
24
|
+
.to_a
|
|
25
|
+
|
|
26
|
+
total_cost = grouped.sum { |row| row.total_cost_sum.to_f }
|
|
27
|
+
|
|
28
|
+
grouped.map do |row|
|
|
29
|
+
cost = row.total_cost_sum.to_f
|
|
30
|
+
ProviderRow.new(
|
|
31
|
+
provider: row.provider,
|
|
32
|
+
calls: row.calls_count.to_i,
|
|
33
|
+
total_cost: cost,
|
|
34
|
+
share_percent: total_cost.positive? ? (cost / total_cost) * 100.0 : 0.0
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :scope
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
TagBreakdownRow = Data.define(
|
|
6
|
+
:value,
|
|
7
|
+
:calls,
|
|
8
|
+
:total_cost,
|
|
9
|
+
:average_cost_per_call
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# Aggregates calls grouped by the distinct values of a single tag key.
|
|
13
|
+
# Invalid keys raise InvalidFilterError so controllers can return HTTP 400.
|
|
14
|
+
class TagBreakdown
|
|
15
|
+
class << self
|
|
16
|
+
def call(key:, scope: LlmCostTracker::LlmApiCall.all)
|
|
17
|
+
new(scope: scope, key: key).rows
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(scope:, key:)
|
|
22
|
+
@scope = scope
|
|
23
|
+
@key = LlmCostTracker::TagKey.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rows
|
|
27
|
+
costs = scope.cost_by_tag(key)
|
|
28
|
+
counts = counts_by_tag
|
|
29
|
+
|
|
30
|
+
costs.map do |value, total_cost|
|
|
31
|
+
calls = counts[value].to_i
|
|
32
|
+
total_cost = total_cost.to_f
|
|
33
|
+
|
|
34
|
+
TagBreakdownRow.new(
|
|
35
|
+
value: value,
|
|
36
|
+
calls: calls,
|
|
37
|
+
total_cost: total_cost,
|
|
38
|
+
average_cost_per_call: calls.positive? ? total_cost / calls : 0.0
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :scope, :key
|
|
46
|
+
|
|
47
|
+
def counts_by_tag
|
|
48
|
+
scope.group_by_tag(key).count.each_with_object(Hash.new(0)) do |(raw, count), hash|
|
|
49
|
+
hash[label(raw)] += count.to_i
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def label(value)
|
|
54
|
+
value.nil? || value == "" ? "(untagged)" : value.to_s
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|