llm_cost_tracker 0.2.0.alpha2 → 0.2.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 +28 -1
- data/README.md +4 -3
- data/app/assets/llm_cost_tracker/application.css +760 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
- data/app/controllers/llm_cost_tracker/assets_controller.rb +13 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
- data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +42 -0
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
- data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
- data/app/services/llm_cost_tracker/dashboard/filter.rb +0 -3
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
- data/app/services/llm_cost_tracker/pagination.rb +6 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
- data/app/views/llm_cost_tracker/calls/index.html.erb +106 -74
- data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +201 -111
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +178 -78
- data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
- data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
- data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
- data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
- data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
- data/config/routes.rb +3 -0
- data/lib/llm_cost_tracker/assets.rb +24 -0
- data/lib/llm_cost_tracker/engine.rb +2 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +1 -1
- data/lib/llm_cost_tracker/price_registry.rb +17 -6
- data/lib/llm_cost_tracker/pricing.rb +19 -6
- data/lib/llm_cost_tracker/retention.rb +34 -0
- data/lib/llm_cost_tracker/tag_query.rb +7 -2
- data/lib/llm_cost_tracker/tags_column.rb +13 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +1 -0
- data/lib/tasks/llm_cost_tracker.rake +8 -0
- data/llm_cost_tracker.gemspec +1 -2
- metadata +17 -5
- data/PLAN_0.2.md +0 -488
|
@@ -14,17 +14,11 @@ module LlmCostTracker
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
16
|
def ensure_llm_api_calls_table
|
|
17
|
-
return if
|
|
17
|
+
return if LlmCostTracker::LlmApiCall.table_exists?
|
|
18
18
|
|
|
19
19
|
render template: "llm_cost_tracker/shared/setup_required"
|
|
20
20
|
end
|
|
21
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
22
|
def render_database_error(error)
|
|
29
23
|
@error = error
|
|
30
24
|
render "llm_cost_tracker/errors/database", status: :internal_server_error
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class AssetsController < ActionController::Base
|
|
5
|
+
skip_forgery_protection if respond_to?(:skip_forgery_protection)
|
|
6
|
+
|
|
7
|
+
def stylesheet
|
|
8
|
+
path = File.join(LlmCostTracker::Assets.root, LlmCostTracker::Assets::STYLESHEET)
|
|
9
|
+
response.set_header("Cache-Control", "public, max-age=31536000, immutable")
|
|
10
|
+
send_file path, type: "text/css", disposition: "inline"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -4,15 +4,8 @@ require "csv"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
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
7
|
CSV_EXPORT_LIMIT = 10_000
|
|
8
|
+
CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
|
|
16
9
|
|
|
17
10
|
def index
|
|
18
11
|
@sort = params[:sort].to_s
|
|
@@ -46,7 +39,24 @@ module LlmCostTracker
|
|
|
46
39
|
private
|
|
47
40
|
|
|
48
41
|
def calls_order(sort)
|
|
49
|
-
|
|
42
|
+
case sort
|
|
43
|
+
when "expensive"
|
|
44
|
+
"CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{default_order}"
|
|
45
|
+
when "input"
|
|
46
|
+
"input_tokens DESC, #{default_order}"
|
|
47
|
+
when "output"
|
|
48
|
+
"output_tokens DESC, #{default_order}"
|
|
49
|
+
when "slow"
|
|
50
|
+
return default_order unless LlmApiCall.latency_column?
|
|
51
|
+
|
|
52
|
+
"CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{default_order}"
|
|
53
|
+
else
|
|
54
|
+
default_order
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_order
|
|
59
|
+
"tracked_at DESC, id DESC"
|
|
50
60
|
end
|
|
51
61
|
|
|
52
62
|
def render_csv(relation)
|
|
@@ -60,18 +70,25 @@ module LlmCostTracker
|
|
|
60
70
|
relation.each do |call|
|
|
61
71
|
row = [
|
|
62
72
|
call.tracked_at&.utc&.iso8601,
|
|
63
|
-
call.provider,
|
|
64
|
-
call.model,
|
|
73
|
+
csv_safe(call.provider),
|
|
74
|
+
csv_safe(call.model),
|
|
65
75
|
call.input_tokens,
|
|
66
76
|
call.output_tokens,
|
|
67
77
|
call.total_tokens,
|
|
68
78
|
call.total_cost
|
|
69
79
|
]
|
|
70
80
|
row << call.latency_ms if latency
|
|
71
|
-
row << call.parsed_tags.to_json
|
|
81
|
+
row << csv_safe(call.parsed_tags.to_json)
|
|
72
82
|
csv << row
|
|
73
83
|
end
|
|
74
84
|
end
|
|
75
85
|
end
|
|
86
|
+
|
|
87
|
+
def csv_safe(value)
|
|
88
|
+
return value if value.nil?
|
|
89
|
+
|
|
90
|
+
string = value.to_s
|
|
91
|
+
CSV_FORMULA_PREFIXES.include?(string[0]) ? "'#{string}" : string
|
|
92
|
+
end
|
|
76
93
|
end
|
|
77
94
|
end
|
|
@@ -4,12 +4,16 @@ module LlmCostTracker
|
|
|
4
4
|
class DashboardController < ApplicationController
|
|
5
5
|
def index
|
|
6
6
|
@from_date, @to_date = overview_range
|
|
7
|
+
prev_from, prev_to = previous_range
|
|
7
8
|
scope = Dashboard::Filter.call(params: overview_filter_params)
|
|
8
9
|
previous_scope = Dashboard::Filter.call(params: previous_filter_params)
|
|
10
|
+
model_rows = Dashboard::TopModels.call(scope: scope, limit: 10)
|
|
9
11
|
|
|
10
12
|
@stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
|
|
11
13
|
@time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
|
|
12
|
-
@
|
|
14
|
+
@comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
|
|
15
|
+
@spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
|
|
16
|
+
@top_models = model_rows.first(5)
|
|
13
17
|
@providers = Dashboard::ProviderBreakdown.call(scope: scope)
|
|
14
18
|
end
|
|
15
19
|
|
|
@@ -4,6 +4,12 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module ApplicationHelper
|
|
7
|
+
include DashboardFilterHelper
|
|
8
|
+
include DashboardFilterOptionsHelper
|
|
9
|
+
include DashboardQueryHelper
|
|
10
|
+
include ChartHelper
|
|
11
|
+
include PaginationHelper
|
|
12
|
+
|
|
7
13
|
def coverage_percent(numerator, denominator)
|
|
8
14
|
return 0.0 unless denominator.to_i.positive?
|
|
9
15
|
|
|
@@ -46,19 +52,19 @@ module LlmCostTracker
|
|
|
46
52
|
end
|
|
47
53
|
|
|
48
54
|
def delta_badge(delta_percent, mode: :cost)
|
|
49
|
-
return { text: "vs. prior
|
|
55
|
+
return { text: "n/a vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if delta_percent.nil?
|
|
50
56
|
|
|
51
57
|
rounded = delta_percent.round(1)
|
|
52
|
-
return { text: "
|
|
58
|
+
return { text: "0.0% vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if rounded.zero?
|
|
53
59
|
|
|
54
60
|
sign = rounded.positive? ? "+" : ""
|
|
55
61
|
text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
|
|
56
62
|
css_class = if mode == :neutral
|
|
57
|
-
"lct-delta lct-delta-neutral"
|
|
63
|
+
"lct-delta-badge lct-delta-neutral"
|
|
58
64
|
elsif rounded.positive?
|
|
59
|
-
"lct-delta lct-delta-up"
|
|
65
|
+
"lct-delta-badge lct-delta-up"
|
|
60
66
|
else
|
|
61
|
-
"lct-delta lct-delta-down"
|
|
67
|
+
"lct-delta-badge lct-delta-down"
|
|
62
68
|
end
|
|
63
69
|
|
|
64
70
|
{ text: text, css_class: css_class }
|
|
@@ -71,6 +77,18 @@ module LlmCostTracker
|
|
|
71
77
|
"#{[(value.to_f / max) * 100.0, 100.0].min.round(2)}%"
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
def stack_segments(entries)
|
|
81
|
+
total = entries.sum { |entry| entry[:value].to_f }
|
|
82
|
+
return [] unless total.positive?
|
|
83
|
+
|
|
84
|
+
entries.filter_map do |entry|
|
|
85
|
+
value = entry[:value].to_f
|
|
86
|
+
next unless value.positive?
|
|
87
|
+
|
|
88
|
+
entry.merge(percent: (value / total) * 100.0)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
74
92
|
def safe_json(value)
|
|
75
93
|
parsed = value.is_a?(String) ? JSON.parse(value) : value
|
|
76
94
|
JSON.pretty_generate(parsed || {})
|
|
@@ -87,10 +105,33 @@ module LlmCostTracker
|
|
|
87
105
|
summary.join(", ")
|
|
88
106
|
end
|
|
89
107
|
|
|
108
|
+
def tag_chip_entries(tags, limit: 3)
|
|
109
|
+
normalized = normalized_tags(tags)
|
|
110
|
+
return [] if normalized.empty?
|
|
111
|
+
|
|
112
|
+
visible = normalized.first(limit).map do |key, value|
|
|
113
|
+
{ key: key.to_s, value: tag_value_summary(value) }
|
|
114
|
+
end
|
|
115
|
+
visible << { more: normalized.size - limit } if normalized.size > limit
|
|
116
|
+
visible
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def budget_fill_modifier(percent)
|
|
120
|
+
percent = percent.to_f
|
|
121
|
+
return "lct-budget-fill--over" if percent >= 100.0
|
|
122
|
+
return "lct-budget-fill--warn" if percent >= 80.0
|
|
123
|
+
|
|
124
|
+
""
|
|
125
|
+
end
|
|
126
|
+
|
|
90
127
|
def current_query(overrides = {})
|
|
91
128
|
request.query_parameters.symbolize_keys.merge(overrides)
|
|
92
129
|
end
|
|
93
130
|
|
|
131
|
+
def calls_query_for_model(provider:, model:)
|
|
132
|
+
current_query(provider: provider, model: model, page: nil, per: nil, format: nil)
|
|
133
|
+
end
|
|
134
|
+
|
|
94
135
|
private
|
|
95
136
|
|
|
96
137
|
def normalized_tags(tags)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ChartHelper
|
|
5
|
+
def spend_chart_svg(points, comparison_points: nil, height: 180, y_ticks: 3)
|
|
6
|
+
return nil if points.blank?
|
|
7
|
+
|
|
8
|
+
cfg = chart_config(points, comparison_points, height, y_ticks)
|
|
9
|
+
parts = [chart_svg_open(cfg)]
|
|
10
|
+
parts.concat(chart_grid_and_axis(cfg))
|
|
11
|
+
parts << chart_paths(cfg)
|
|
12
|
+
parts.concat(chart_dots(cfg))
|
|
13
|
+
parts.concat(chart_x_labels(cfg))
|
|
14
|
+
parts << "</svg>"
|
|
15
|
+
parts.join.html_safe
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def chart_fmt(value)
|
|
21
|
+
format("%.2f", value)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def chart_config(points, comparison_points, height, y_ticks)
|
|
25
|
+
width = 720
|
|
26
|
+
pad = { top: 16, right: 16, bottom: 28, left: 56 }
|
|
27
|
+
plot_w = width - pad[:left] - pad[:right]
|
|
28
|
+
plot_h = height - pad[:top] - pad[:bottom]
|
|
29
|
+
all_costs = points.map { |p| p[:cost].to_f } + Array(comparison_points).map { |p| p[:cost].to_f }
|
|
30
|
+
max_cost = [all_costs.max.to_f, 0.0001].max
|
|
31
|
+
coords = chart_coords(points, pad, plot_w, plot_h, max_cost)
|
|
32
|
+
comparison_coords = chart_coords(comparison_points, pad, plot_w, plot_h, max_cost) if comparison_points.present?
|
|
33
|
+
|
|
34
|
+
{ width: width, height: height, pad: pad, plot_w: plot_w, plot_h: plot_h,
|
|
35
|
+
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
|
+
end
|
|
38
|
+
|
|
39
|
+
def chart_coords(points, pad, plot_w, plot_h, max_cost)
|
|
40
|
+
n = points.size
|
|
41
|
+
step = n > 1 ? plot_w.to_f / (n - 1) : 0.0
|
|
42
|
+
|
|
43
|
+
points.each_with_index.map do |point, idx|
|
|
44
|
+
x = pad[:left] + (idx * step)
|
|
45
|
+
y = pad[:top] + plot_h - ((point[:cost].to_f / max_cost) * plot_h)
|
|
46
|
+
[x, y]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def chart_svg_open(cfg)
|
|
51
|
+
attrs = [
|
|
52
|
+
%(class="lct-chart"),
|
|
53
|
+
%(viewBox="0 0 #{cfg[:width]} #{cfg[:height]}"),
|
|
54
|
+
%(preserveAspectRatio="none"),
|
|
55
|
+
%(role="img"),
|
|
56
|
+
%(aria-label="Daily spend trend")
|
|
57
|
+
].join(" ")
|
|
58
|
+
"<svg #{attrs}>"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def chart_grid_and_axis(cfg)
|
|
62
|
+
(0..cfg[:y_ticks]).map { |i| chart_tick_line(cfg, i) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def chart_tick_line(cfg, idx)
|
|
66
|
+
pad = cfg[:pad]
|
|
67
|
+
right_x = chart_fmt(pad[:left] + cfg[:plot_w])
|
|
68
|
+
left_x = chart_fmt(pad[:left])
|
|
69
|
+
text_x = chart_fmt(pad[:left] - 8)
|
|
70
|
+
value = cfg[:max_cost] * (cfg[:y_ticks] - idx).to_f / cfg[:y_ticks]
|
|
71
|
+
y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]))
|
|
72
|
+
label_y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]) + 3)
|
|
73
|
+
grid = %(<line class="lct-chart-grid" x1="#{left_x}" x2="#{right_x}" y1="#{y}" y2="#{y}"/>)
|
|
74
|
+
label = format("%.2f", value)
|
|
75
|
+
text = %(<text class="lct-chart-axis" x="#{text_x}" y="#{label_y}" text-anchor="end">$#{label}</text>)
|
|
76
|
+
"#{grid}#{text}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def chart_paths(cfg)
|
|
80
|
+
line = build_line_path(cfg[:coords])
|
|
81
|
+
base_y = cfg[:pad][:top] + cfg[:plot_h]
|
|
82
|
+
area = build_area_path(cfg[:coords], cfg, base_y, line)
|
|
83
|
+
secondary = if cfg[:comparison_coords].present?
|
|
84
|
+
%(<path class="lct-chart-line-secondary" d="#{build_line_path(cfg[:comparison_coords])}"/>)
|
|
85
|
+
else
|
|
86
|
+
""
|
|
87
|
+
end
|
|
88
|
+
%(<path class="lct-chart-area" d="#{area}"/>#{secondary}<path class="lct-chart-line" d="#{line}"/>)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_line_path(coords)
|
|
92
|
+
coords.each_with_index.map do |(x, y), idx|
|
|
93
|
+
"#{idx.zero? ? 'M' : 'L'}#{chart_fmt(x)},#{chart_fmt(y)}"
|
|
94
|
+
end.join(" ")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_area_path(coords, cfg, base_y, line)
|
|
98
|
+
if coords.size == 1
|
|
99
|
+
x, y = coords.first
|
|
100
|
+
left = chart_fmt(cfg[:pad][:left])
|
|
101
|
+
right = chart_fmt(cfg[:pad][:left] + cfg[:plot_w])
|
|
102
|
+
"M#{left},#{chart_fmt(base_y)} L#{chart_fmt(x)},#{chart_fmt(y)} L#{right},#{chart_fmt(base_y)} Z"
|
|
103
|
+
else
|
|
104
|
+
first_x = chart_fmt(coords.first[0])
|
|
105
|
+
last_x = chart_fmt(coords.last[0])
|
|
106
|
+
"#{line} L#{last_x},#{chart_fmt(base_y)} L#{first_x},#{chart_fmt(base_y)} Z"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def chart_dots(cfg)
|
|
111
|
+
cfg[:coords].each_with_index.map { |(pt_x, pt_y), idx| chart_dot(cfg, pt_x, pt_y, idx) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def chart_dot(cfg, pt_x, pt_y, idx)
|
|
115
|
+
point = cfg[:points][idx]
|
|
116
|
+
title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
|
|
117
|
+
circle = %(<circle class="lct-chart-dot" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="3"/>)
|
|
118
|
+
"<g>#{circle}<title>#{title}</title></g>"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def chart_x_labels(cfg)
|
|
122
|
+
indexes = cfg[:n] <= 2 ? (0...cfg[:n]).to_a : [0, cfg[:n] / 2, cfg[:n] - 1].uniq
|
|
123
|
+
label_y = chart_fmt(cfg[:height] - 8)
|
|
124
|
+
indexes.map { |idx| chart_x_label(cfg, idx, label_y) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def chart_x_label(cfg, idx, label_y)
|
|
128
|
+
pt_x, = cfg[:coords][idx]
|
|
129
|
+
label = ERB::Util.html_escape(cfg[:points][idx][:label])
|
|
130
|
+
%(<text class="lct-chart-axis" x="#{chart_fmt(pt_x)}" y="#{label_y}" text-anchor="middle">#{label}</text>)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module DashboardFilterHelper
|
|
5
|
+
FILTER_PARAM_KEYS = %i[from to provider model tag sort page per].freeze
|
|
6
|
+
|
|
7
|
+
def any_filter_applied?
|
|
8
|
+
FILTER_PARAM_KEYS.any? { |key| params[key].present? }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def active_tag_filters
|
|
12
|
+
tag_params = normalized_query_tags(params[:tag])
|
|
13
|
+
return [] unless tag_params.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
tag_params.filter_map do |key, value|
|
|
16
|
+
next if key.blank? || value.blank?
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
label: "Tag",
|
|
20
|
+
value: "#{key}=#{value}",
|
|
21
|
+
path: dashboard_filter_path(current_query(tag: tag_params.except(key.to_s).presence, page: nil))
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dashboard_date_range_label(from, to)
|
|
27
|
+
from_label = short_date_label(from) || "Any time"
|
|
28
|
+
to_label = short_date_label(to) || "Now"
|
|
29
|
+
"#{from_label} - #{to_label}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def short_date_label(value)
|
|
35
|
+
return nil if value.blank?
|
|
36
|
+
|
|
37
|
+
Date.iso8601(value.to_s).strftime("%b %-d, %Y")
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
value.to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module DashboardFilterOptionsHelper
|
|
5
|
+
def provider_filter_options(filter_params: params)
|
|
6
|
+
filter_options_for(:provider, filter_params: filter_params)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def model_filter_options(filter_params: params)
|
|
10
|
+
filter_options_for(:model, filter_params: filter_params)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def filter_options_for(column, filter_params:)
|
|
16
|
+
source = filter_source_hash(filter_params)
|
|
17
|
+
scope_params = source.stringify_keys.merge(
|
|
18
|
+
column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
|
|
19
|
+
)
|
|
20
|
+
values = LlmCostTracker::Dashboard::Filter.call(params: scope_params)
|
|
21
|
+
.where.not(column => [nil, ""])
|
|
22
|
+
.distinct.order(column).pluck(column)
|
|
23
|
+
current = source[column.to_s].presence || source[column].presence
|
|
24
|
+
values.unshift(current) if current && !values.include?(current)
|
|
25
|
+
values
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def filter_source_hash(filter_params)
|
|
29
|
+
return filter_params.to_unsafe_h if filter_params.respond_to?(:to_unsafe_h)
|
|
30
|
+
|
|
31
|
+
filter_params.to_h
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module DashboardQueryHelper
|
|
5
|
+
def dashboard_filter_path(query)
|
|
6
|
+
cleaned = clean_dashboard_query(query)
|
|
7
|
+
return request.path if cleaned.blank?
|
|
8
|
+
|
|
9
|
+
"#{request.path}?#{cleaned.to_query}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def calls_query_for_tag(key:, value:)
|
|
13
|
+
query = current_query(page: nil, per: nil, format: nil)
|
|
14
|
+
tags = normalized_query_tags(query[:tag])
|
|
15
|
+
query[:tag] = tags.merge(key.to_s => value.to_s)
|
|
16
|
+
query
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def normalized_query_tags(tags)
|
|
22
|
+
return {} unless tags
|
|
23
|
+
|
|
24
|
+
tags = tags.to_unsafe_h if tags.respond_to?(:to_unsafe_h)
|
|
25
|
+
tags = tags.to_h if tags.respond_to?(:to_h)
|
|
26
|
+
return {} unless tags.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
tags.transform_keys(&:to_s).transform_values(&:to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def clean_dashboard_query(value)
|
|
32
|
+
return clean_dashboard_hash(value.to_unsafe_h) if value.is_a?(ActionController::Parameters)
|
|
33
|
+
return clean_dashboard_hash(value) if value.is_a?(Hash)
|
|
34
|
+
return clean_dashboard_array(value) if value.is_a?(Array)
|
|
35
|
+
return clean_dashboard_string(value) if value.is_a?(String)
|
|
36
|
+
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def clean_dashboard_hash(hash)
|
|
41
|
+
hash.each_with_object({}) do |(key, nested), cleaned|
|
|
42
|
+
nested = clean_dashboard_query(nested)
|
|
43
|
+
next if nested.nil? || nested == {} || nested == []
|
|
44
|
+
|
|
45
|
+
cleaned[key] = nested
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clean_dashboard_array(array)
|
|
50
|
+
array.filter_map { |item| clean_dashboard_query(item) }.presence
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def clean_dashboard_string(string)
|
|
54
|
+
stripped = string.strip
|
|
55
|
+
stripped.empty? ? nil : stripped
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module PaginationHelper
|
|
5
|
+
PER_PAGE_CHOICES = [25, 50, 100, 200].freeze
|
|
6
|
+
|
|
7
|
+
def pagination_page_items(current, total_pages, window: 1)
|
|
8
|
+
return (1..total_pages).to_a if total_pages <= (window * 2) + 5
|
|
9
|
+
|
|
10
|
+
anchors = [1, total_pages, current, current - window, current + window]
|
|
11
|
+
pages = anchors.grep(1..total_pages).uniq.sort
|
|
12
|
+
pages.each_with_index.flat_map do |page, index|
|
|
13
|
+
gap = index.positive? && page - pages[index - 1] > 1 ? [:gap] : []
|
|
14
|
+
gap << page
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -66,9 +66,6 @@ module LlmCostTracker
|
|
|
66
66
|
|
|
67
67
|
def tag_params
|
|
68
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
69
|
|
|
73
70
|
tags.each_with_object({}) do |(key, value), normalized|
|
|
74
71
|
value = normalized_string(value)
|
|
@@ -74,11 +74,26 @@ module LlmCostTracker
|
|
|
74
74
|
budget = LlmCostTracker.configuration.monthly_budget
|
|
75
75
|
return nil unless budget
|
|
76
76
|
|
|
77
|
+
now = Time.now.utc
|
|
78
|
+
month_start = now.beginning_of_month
|
|
79
|
+
month_end = now.end_of_month
|
|
77
80
|
spent = LlmCostTracker::LlmApiCall.this_month.total_cost
|
|
81
|
+
elapsed_seconds = now - month_start
|
|
82
|
+
total_seconds = month_end - month_start
|
|
83
|
+
projected_spent = if spent.zero? || !elapsed_seconds.positive?
|
|
84
|
+
spent
|
|
85
|
+
else
|
|
86
|
+
spent * (total_seconds / elapsed_seconds)
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
{
|
|
79
90
|
budget: budget.to_f,
|
|
80
91
|
spent: spent,
|
|
81
|
-
percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0
|
|
92
|
+
percent_used: budget.to_f.positive? ? (spent / budget.to_f) * 100.0 : 0.0,
|
|
93
|
+
projected_spent: projected_spent,
|
|
94
|
+
projected_percent_used: budget.to_f.positive? ? (projected_spent / budget.to_f) * 100.0 : 0.0,
|
|
95
|
+
projected_delta: projected_spent - budget.to_f,
|
|
96
|
+
projection_end_label: month_end.strftime("%b %-d")
|
|
82
97
|
}
|
|
83
98
|
end
|
|
84
99
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
SpendAnomalyData = Data.define(
|
|
6
|
+
:provider,
|
|
7
|
+
:model,
|
|
8
|
+
:day,
|
|
9
|
+
:latest_spend,
|
|
10
|
+
:baseline_mean,
|
|
11
|
+
:ratio
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
class SpendAnomaly
|
|
15
|
+
WINDOW_DAYS = 7
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(from:, to:, scope: LlmCostTracker::LlmApiCall.all)
|
|
19
|
+
new(scope: scope, from: from, to: to).alert
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(scope:, from:, to:)
|
|
24
|
+
@scope = scope
|
|
25
|
+
@from = from.to_date
|
|
26
|
+
@to = to.to_date
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def alert
|
|
30
|
+
return nil if from > (to - WINDOW_DAYS)
|
|
31
|
+
|
|
32
|
+
alerts.max_by { |item| [item.ratio || 0.0, item.latest_spend] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :scope, :from, :to
|
|
38
|
+
|
|
39
|
+
def alerts
|
|
40
|
+
daily_spend_by_model.each_with_object([]) do |((provider, model), daily_costs), rows|
|
|
41
|
+
latest_spend = daily_costs.fetch(to, 0.0)
|
|
42
|
+
next unless latest_spend.positive?
|
|
43
|
+
|
|
44
|
+
baseline_days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
|
|
45
|
+
mean = baseline_days.sum / WINDOW_DAYS.to_f
|
|
46
|
+
variance = baseline_days.sum { |value| (value - mean)**2 } / WINDOW_DAYS.to_f
|
|
47
|
+
threshold = mean + (2 * Math.sqrt(variance))
|
|
48
|
+
next unless latest_spend > threshold
|
|
49
|
+
|
|
50
|
+
rows << SpendAnomalyData.new(
|
|
51
|
+
provider: provider,
|
|
52
|
+
model: model,
|
|
53
|
+
day: to,
|
|
54
|
+
latest_spend: latest_spend,
|
|
55
|
+
baseline_mean: mean,
|
|
56
|
+
ratio: mean.positive? ? (latest_spend / mean) : nil
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def daily_spend_by_model
|
|
62
|
+
window = (to - WINDOW_DAYS).beginning_of_day..to.end_of_day
|
|
63
|
+
|
|
64
|
+
grouped = Hash.new { |hash, key| hash[key] = Hash.new(0.0) }
|
|
65
|
+
|
|
66
|
+
scope
|
|
67
|
+
.where(tracked_at: window)
|
|
68
|
+
.pluck(:provider, :model, :tracked_at, :total_cost)
|
|
69
|
+
.each do |provider, model, tracked_at, total_cost|
|
|
70
|
+
next if total_cost.nil?
|
|
71
|
+
|
|
72
|
+
grouped[[provider, model]][tracked_at.to_date] += total_cost.to_f
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
grouped
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|