llm_cost_tracker 0.14.2 → 0.15.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 +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- metadata +58 -5
|
@@ -6,6 +6,12 @@ module LlmCostTracker
|
|
|
6
6
|
module ApplicationHelper
|
|
7
7
|
TAG_VALUE_SUMMARY_BYTES = 80
|
|
8
8
|
TAG_TOOLTIP_BYTES = 512
|
|
9
|
+
PRICING_STATUS_LABELS = {
|
|
10
|
+
LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
|
|
11
|
+
LlmCostTracker::Charges::CostStatus::FREE => "Free",
|
|
12
|
+
LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
|
|
13
|
+
}.freeze
|
|
14
|
+
NEUTRAL_DELTA_CLASS = "lct-delta-badge lct-delta-neutral"
|
|
9
15
|
|
|
10
16
|
include DashboardFilterOptionsHelper
|
|
11
17
|
include DashboardQueryHelper
|
|
@@ -26,11 +32,13 @@ module LlmCostTracker
|
|
|
26
32
|
:overview
|
|
27
33
|
end
|
|
28
34
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
def sidebar_link_options(section)
|
|
36
|
+
active = dashboard_section == section
|
|
37
|
+
{ class: active ? "lct-sidebar-link lct-active" : "lct-sidebar-link", aria: active ? { current: "page" } : {} }
|
|
38
|
+
end
|
|
32
39
|
|
|
33
|
-
|
|
40
|
+
def coverage_percent(numerator, denominator)
|
|
41
|
+
Dashboard::Percent.of(numerator, denominator)
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
def money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
|
|
@@ -55,11 +63,7 @@ module LlmCostTracker
|
|
|
55
63
|
def pricing_status(call)
|
|
56
64
|
return "Unknown" if call.total_cost.nil?
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
|
|
60
|
-
LlmCostTracker::Charges::CostStatus::FREE => "Free",
|
|
61
|
-
LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
|
|
62
|
-
}.fetch(call.cost_status, "Unknown")
|
|
66
|
+
PRICING_STATUS_LABELS.fetch(call.cost_status, "Unknown")
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
def percent(value)
|
|
@@ -67,22 +71,11 @@ module LlmCostTracker
|
|
|
67
71
|
end
|
|
68
72
|
|
|
69
73
|
def delta_badge(delta_percent, mode: :cost)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return { text: "0.0% vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if rounded.zero?
|
|
74
|
-
|
|
75
|
-
sign = rounded.positive? ? "+" : ""
|
|
76
|
-
text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
|
|
77
|
-
css_class = if mode == :neutral
|
|
78
|
-
"lct-delta-badge lct-delta-neutral"
|
|
79
|
-
elsif rounded.positive?
|
|
80
|
-
"lct-delta-badge lct-delta-up"
|
|
81
|
-
else
|
|
82
|
-
"lct-delta-badge lct-delta-down"
|
|
83
|
-
end
|
|
74
|
+
rounded = delta_percent&.round(1)
|
|
75
|
+
return { text: "n/a vs. prior", css_class: NEUTRAL_DELTA_CLASS } if rounded.nil?
|
|
76
|
+
return { text: "0.0% vs. prior", css_class: NEUTRAL_DELTA_CLASS } if rounded.zero?
|
|
84
77
|
|
|
85
|
-
{ text:
|
|
78
|
+
{ text: "#{format('%+.1f', rounded)}% vs. prior", css_class: delta_badge_class(rounded, mode) }
|
|
86
79
|
end
|
|
87
80
|
|
|
88
81
|
def bar_width(value, max)
|
|
@@ -132,15 +125,13 @@ module LlmCostTracker
|
|
|
132
125
|
truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
|
|
133
126
|
end
|
|
134
127
|
|
|
135
|
-
|
|
136
|
-
request.query_parameters.symbolize_keys.slice(*LlmCostTracker::Dashboard::Params::QUERY_KEYS).merge(overrides)
|
|
137
|
-
end
|
|
128
|
+
private
|
|
138
129
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
end
|
|
130
|
+
def delta_badge_class(rounded, mode)
|
|
131
|
+
return NEUTRAL_DELTA_CLASS if mode == :neutral
|
|
142
132
|
|
|
143
|
-
|
|
133
|
+
rounded.positive? ? "lct-delta-badge lct-delta-up" : "lct-delta-badge lct-delta-down"
|
|
134
|
+
end
|
|
144
135
|
|
|
145
136
|
def truncate_text(string, limit)
|
|
146
137
|
return string if string.bytesize <= limit
|
|
@@ -2,17 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module ChartHelper
|
|
5
|
+
CHART_OPEN_TAG = "<svg class=\"lct-chart\" viewBox=\"0 0 %<width>s %<height>s\" " \
|
|
6
|
+
"preserveAspectRatio=\"xMidYMid meet\" role=\"img\" aria-label=\"Daily spend trend\">"
|
|
7
|
+
CHART_GRADIENT = "<defs><linearGradient id=\"lct-chart-grad\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">" \
|
|
8
|
+
"<stop offset=\"0%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.28\"/>" \
|
|
9
|
+
"<stop offset=\"100%\" stop-color=\"var(--lct-accent)\" stop-opacity=\"0.02\"/>" \
|
|
10
|
+
"</linearGradient></defs>"
|
|
11
|
+
|
|
5
12
|
def spend_chart_svg(points, comparison_points: nil, height: 180, y_ticks: 3)
|
|
6
13
|
return nil if points.blank?
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
chart = Dashboard::SpendChart.new(points, comparison_points: comparison_points, height: height, y_ticks: y_ticks)
|
|
16
|
+
[
|
|
17
|
+
format(CHART_OPEN_TAG, width: Dashboard::SpendChart::WIDTH, height: chart.height),
|
|
18
|
+
"<title>Daily spend trend</title>",
|
|
19
|
+
CHART_GRADIENT,
|
|
20
|
+
*(0..chart.y_ticks).map { |index| chart_tick(chart, index) },
|
|
21
|
+
chart_paths(chart),
|
|
22
|
+
*chart.coords.each_index.map { |index| chart_dot(chart, index) },
|
|
23
|
+
*chart.label_indexes.map { |index| chart_x_label(chart, index) },
|
|
24
|
+
"</svg>"
|
|
25
|
+
].join.html_safe
|
|
16
26
|
end
|
|
17
27
|
|
|
18
28
|
private
|
|
@@ -21,125 +31,64 @@ module LlmCostTracker
|
|
|
21
31
|
format("%.2f", value)
|
|
22
32
|
end
|
|
23
33
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
peak_index = points.each_with_index.max_by { |point, _| point[:cost].to_f }&.last
|
|
35
|
-
{ width: width, height: height, pad: pad, plot_w: plot_w, plot_h: plot_h,
|
|
36
|
-
max_cost: max_cost, n: points.size, y_ticks: y_ticks, points: points, coords: coords,
|
|
37
|
-
comparison_coords: comparison_coords,
|
|
38
|
-
peak_index: peak_index }
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def chart_coords(points, pad, plot_w, plot_h, max_cost)
|
|
42
|
-
n = points.size
|
|
43
|
-
step = n > 1 ? plot_w.to_f / (n - 1) : 0.0
|
|
44
|
-
|
|
45
|
-
points.each_with_index.map do |point, idx|
|
|
46
|
-
x = pad[:left] + (idx * step)
|
|
47
|
-
y = pad[:top] + plot_h - ((point[:cost].to_f / max_cost) * plot_h)
|
|
48
|
-
[x, y]
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def chart_svg_open(cfg)
|
|
53
|
-
attrs = [
|
|
54
|
-
%(class="lct-chart"),
|
|
55
|
-
%(viewBox="0 0 #{cfg[:width]} #{cfg[:height]}"),
|
|
56
|
-
%(preserveAspectRatio="xMidYMid meet"),
|
|
57
|
-
%(role="img"),
|
|
58
|
-
%(aria-label="Daily spend trend")
|
|
59
|
-
].join(" ")
|
|
60
|
-
"<svg #{attrs}>"
|
|
34
|
+
def chart_element(name, attributes, content = nil)
|
|
35
|
+
attrs = attributes.map { |key, value| %( #{key}="#{value}") }.join
|
|
36
|
+
content.nil? ? "<#{name}#{attrs}/>" : "<#{name}#{attrs}>#{content}</#{name}>"
|
|
61
37
|
end
|
|
62
38
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
right_x = chart_fmt(pad[:left] + cfg[:plot_w])
|
|
66
|
-
left_x = chart_fmt(pad[:left])
|
|
67
|
-
text_x = chart_fmt(pad[:left] - 8)
|
|
68
|
-
value = cfg[:max_cost] * (cfg[:y_ticks] - idx).to_f / cfg[:y_ticks]
|
|
69
|
-
tick_y = pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks])
|
|
39
|
+
def chart_tick(chart, index)
|
|
40
|
+
tick_y = chart.tick_y(index)
|
|
70
41
|
y = chart_fmt(tick_y)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"#{grid}#{text}"
|
|
42
|
+
grid = { class: "lct-chart-grid", x1: chart_fmt(chart.left), x2: chart_fmt(chart.right), y1: y, y2: y }
|
|
43
|
+
label = { class: "lct-chart-axis", x: chart_fmt(chart.left - 8), y: chart_fmt(tick_y + 3), "text-anchor": "end" }
|
|
44
|
+
chart_element(:line, grid) + chart_element(:text, label, "$#{chart_fmt(chart.tick_value(index))}")
|
|
75
45
|
end
|
|
76
46
|
|
|
77
|
-
def chart_paths(
|
|
78
|
-
line =
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
-
%(<path class="lct-chart-area" d="#{area}"/>#{secondary}<path class="lct-chart-line" d="#{line}"/>)
|
|
47
|
+
def chart_paths(chart)
|
|
48
|
+
line = chart_line_path(chart.coords)
|
|
49
|
+
paths = [chart_element(:path, class: "lct-chart-area", d: chart_area_path(chart, line))]
|
|
50
|
+
if chart.comparison_coords
|
|
51
|
+
paths << chart_element(:path, class: "lct-chart-line-secondary", d: chart_line_path(chart.comparison_coords))
|
|
52
|
+
end
|
|
53
|
+
paths << chart_element(:path, class: "lct-chart-line", d: line)
|
|
54
|
+
paths.join
|
|
87
55
|
end
|
|
88
56
|
|
|
89
|
-
def
|
|
90
|
-
coords.each_with_index.map do |(x, y),
|
|
91
|
-
"#{
|
|
57
|
+
def chart_line_path(coords)
|
|
58
|
+
coords.each_with_index.map do |(x, y), index|
|
|
59
|
+
"#{index.zero? ? 'M' : 'L'}#{chart_fmt(x)},#{chart_fmt(y)}"
|
|
92
60
|
end.join(" ")
|
|
93
61
|
end
|
|
94
62
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
right = chart_fmt(cfg[:pad][:left] + cfg[:plot_w])
|
|
100
|
-
"M#{left},#{chart_fmt(base_y)} L#{chart_fmt(x)},#{chart_fmt(y)} L#{right},#{chart_fmt(base_y)} Z"
|
|
101
|
-
else
|
|
102
|
-
first_x = chart_fmt(coords.first[0])
|
|
103
|
-
last_x = chart_fmt(coords.last[0])
|
|
104
|
-
"#{line} L#{last_x},#{chart_fmt(base_y)} L#{first_x},#{chart_fmt(base_y)} Z"
|
|
105
|
-
end
|
|
106
|
-
end
|
|
63
|
+
def chart_area_path(chart, line)
|
|
64
|
+
coords = chart.coords
|
|
65
|
+
base = chart_fmt(chart.baseline)
|
|
66
|
+
return "#{line} L#{chart_fmt(coords.last[0])},#{base} L#{chart_fmt(coords.first[0])},#{base} Z" if coords.size > 1
|
|
107
67
|
|
|
108
|
-
|
|
109
|
-
|
|
68
|
+
x, y = coords.first
|
|
69
|
+
"M#{chart_fmt(chart.left)},#{base} L#{chart_fmt(x)},#{chart_fmt(y)} L#{chart_fmt(chart.right)},#{base} Z"
|
|
110
70
|
end
|
|
111
71
|
|
|
112
|
-
def chart_dot(
|
|
113
|
-
point =
|
|
72
|
+
def chart_dot(chart, index)
|
|
73
|
+
point = chart.points[index]
|
|
74
|
+
x, y = chart.coords[index]
|
|
75
|
+
peak = index == chart.peak_index
|
|
76
|
+
circle = { class: peak ? "lct-chart-peak" : "lct-chart-dot", cx: chart_fmt(x), cy: chart_fmt(y), r: peak ? 4 : 3 }
|
|
114
77
|
title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
|
|
115
|
-
|
|
116
|
-
klass = peak ? "lct-chart-peak" : "lct-chart-dot"
|
|
117
|
-
radius = peak ? 4 : 3
|
|
118
|
-
circle = %(<circle class="#{klass}" cx="#{chart_fmt(pt_x)}" cy="#{chart_fmt(pt_y)}" r="#{radius}"/>)
|
|
119
|
-
"<g>#{circle}<title>#{title}</title></g>"
|
|
78
|
+
"<g>#{chart_element(:circle, circle)}<title>#{title}</title></g>"
|
|
120
79
|
end
|
|
121
80
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
81
|
+
def chart_x_label(chart, index)
|
|
82
|
+
x, = chart.coords[index]
|
|
83
|
+
anchor = chart_label_anchor(chart, index)
|
|
84
|
+
attributes = { class: "lct-chart-axis", x: chart_fmt(x), y: chart_fmt(chart.height - 8), "text-anchor": anchor }
|
|
85
|
+
chart_element(:text, attributes, ERB::Util.html_escape(chart.points[index][:label]))
|
|
127
86
|
end
|
|
128
87
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
label_y = chart_fmt(cfg[:height] - 8)
|
|
132
|
-
indexes.map { |idx| chart_x_label(cfg, idx, label_y) }
|
|
133
|
-
end
|
|
88
|
+
def chart_label_anchor(chart, index)
|
|
89
|
+
return "start" if index.zero?
|
|
134
90
|
|
|
135
|
-
|
|
136
|
-
pt_x, = cfg[:coords][idx]
|
|
137
|
-
label = ERB::Util.html_escape(cfg[:points][idx][:label])
|
|
138
|
-
anchor = if idx.zero? then "start"
|
|
139
|
-
elsif idx == cfg[:n] - 1 then "end"
|
|
140
|
-
else "middle"
|
|
141
|
-
end
|
|
142
|
-
%(<text class="lct-chart-axis" x="#{chart_fmt(pt_x)}" y="#{label_y}" text-anchor="#{anchor}">#{label}</text>)
|
|
91
|
+
index == chart.points.size - 1 ? "end" : "middle"
|
|
143
92
|
end
|
|
144
93
|
end
|
|
145
94
|
end
|
|
@@ -2,23 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module DashboardQueryHelper
|
|
5
|
-
|
|
6
|
-
cleaned = clean_dashboard_query(query)
|
|
7
|
-
return request.path if cleaned.blank?
|
|
5
|
+
EMPTY_QUERY_VALUES = [nil, {}, []].freeze
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
def current_query(overrides = {})
|
|
8
|
+
request.query_parameters.symbolize_keys.slice(*Dashboard::Params::QUERY_KEYS).merge(overrides)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def calls_query_for_model(provider:, model:)
|
|
12
|
+
current_query(provider: provider, model: model, page: nil, per: nil, format: nil)
|
|
10
13
|
end
|
|
11
14
|
|
|
12
15
|
def calls_query_for_tag(key:, value:)
|
|
13
16
|
query = current_query(page: nil, per: nil, format: nil)
|
|
14
|
-
tags =
|
|
17
|
+
tags = Dashboard::Params.tag_query(query[:tag])
|
|
15
18
|
query[:tag] = tags.merge(key.to_s => value.to_s)
|
|
16
19
|
query
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def tag_drilldown_allowed?(key)
|
|
20
|
-
tags =
|
|
21
|
-
tags.except(key.to_s).size <
|
|
23
|
+
tags = Dashboard::Params.tag_query(current_query[:tag])
|
|
24
|
+
tags.except(key.to_s).size < Dashboard::Filter::MAX_TAG_FILTERS
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dashboard_filter_path(query)
|
|
28
|
+
cleaned = clean_dashboard_query(query)
|
|
29
|
+
return request.path if cleaned.blank?
|
|
30
|
+
|
|
31
|
+
"#{request.path}?#{cleaned.to_query}"
|
|
22
32
|
end
|
|
23
33
|
|
|
24
34
|
def hidden_query_fields(query, prefix: nil)
|
|
@@ -35,19 +45,22 @@ module LlmCostTracker
|
|
|
35
45
|
private
|
|
36
46
|
|
|
37
47
|
def clean_dashboard_query(value)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
cleaned[key] = nested
|
|
44
|
-
end
|
|
48
|
+
case value
|
|
49
|
+
when Array then value.filter_map { |item| clean_dashboard_query(item) }.presence
|
|
50
|
+
when String then value.strip.presence
|
|
51
|
+
else query_hash?(value) ? clean_query_hash(value) : value
|
|
45
52
|
end
|
|
53
|
+
end
|
|
46
54
|
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
def clean_query_hash(hash)
|
|
56
|
+
Dashboard::Params.to_hash(hash).each_with_object({}) do |(key, value), cleaned|
|
|
57
|
+
value = clean_dashboard_query(value)
|
|
58
|
+
cleaned[key] = value unless EMPTY_QUERY_VALUES.include?(value)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
49
61
|
|
|
50
|
-
|
|
62
|
+
def query_hash?(value)
|
|
63
|
+
value.is_a?(Hash) || value.try(:to_unsafe_h).is_a?(Hash)
|
|
51
64
|
end
|
|
52
65
|
end
|
|
53
66
|
end
|
|
@@ -2,40 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module SortableTableHelper
|
|
5
|
+
SORT_ARROWS = { "asc" => "▲", "desc" => "▼" }.freeze
|
|
6
|
+
ARIA_SORT = { "asc" => "ascending", "desc" => "descending" }.freeze
|
|
7
|
+
|
|
5
8
|
def sortable_header(label, column, num: false, default: false)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
classes
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
link_to(href) { safe_join([label, " ", tag.span(state[:arrow], class: "lct-sort-ind")]) }
|
|
9
|
+
natural = num ? "desc" : "asc"
|
|
10
|
+
direction = sorted_direction(column, natural, default)
|
|
11
|
+
classes = ["lct-sortable", ("lct-num" if num), ("lct-sorted" if direction)].compact
|
|
12
|
+
|
|
13
|
+
href = dashboard_filter_path(current_query(sort: column, dir: next_sort_direction(direction, natural), page: nil))
|
|
14
|
+
tag.th(class: classes.join(" "), "aria-sort": ARIA_SORT.fetch(direction, "none")) do
|
|
15
|
+
link_to(href) { safe_join([label, " ", tag.span(SORT_ARROWS.fetch(direction, "▼"), class: "lct-sort-ind")]) }
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
private
|
|
18
20
|
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
}
|
|
21
|
+
def sorted_direction(column, natural, default)
|
|
22
|
+
return unless (params[:sort].presence || (column if default)) == column
|
|
23
|
+
|
|
24
|
+
requested = params[:dir].to_s
|
|
25
|
+
Dashboard::Sort::DIRECTIONS.include?(requested) ? requested : natural
|
|
33
26
|
end
|
|
34
27
|
|
|
35
|
-
def
|
|
36
|
-
return
|
|
28
|
+
def next_sort_direction(direction, natural)
|
|
29
|
+
return natural unless direction
|
|
37
30
|
|
|
38
|
-
|
|
31
|
+
direction == "asc" ? "desc" : "asc"
|
|
39
32
|
end
|
|
40
33
|
end
|
|
41
34
|
end
|
|
@@ -49,5 +49,35 @@ module LlmCostTracker
|
|
|
49
49
|
call.line_items.map { |item| [item.kind, item.direction, item.cache_state, item.cost] }
|
|
50
50
|
)
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
def call_token_segments(call)
|
|
54
|
+
token_usage_stack_components.map do |component|
|
|
55
|
+
value = call[component.fetch(:token_key)]
|
|
56
|
+
token_stack_segment(component, value, number_with_delimiter(value))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def call_cost_segments(call)
|
|
61
|
+
costs = call_line_item_costs_by_component(call)
|
|
62
|
+
token_usage_stack_components.map do |component|
|
|
63
|
+
value = costs[component.fetch(:price_key)]
|
|
64
|
+
token_stack_segment(component, value, optional_money(value))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def call_service_line_items(call)
|
|
69
|
+
call.line_items.reject { |item| item.unit == "token" }.sort_by(&:position)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def token_stack_segment(component, value, formatted_value)
|
|
75
|
+
{
|
|
76
|
+
label: component.fetch(:label),
|
|
77
|
+
value: value,
|
|
78
|
+
formatted_value: formatted_value,
|
|
79
|
+
css_class: component.fetch(:css_class)
|
|
80
|
+
}
|
|
81
|
+
end
|
|
52
82
|
end
|
|
53
83
|
end
|
|
@@ -69,8 +69,8 @@ module LlmCostTracker
|
|
|
69
69
|
|
|
70
70
|
def cost_by_tag(key, limit: nil)
|
|
71
71
|
cost = qualified(:total_cost)
|
|
72
|
-
label = Ledger::Tags::Breakdown.label_sql(
|
|
73
|
-
raw_value = Ledger::Tags::Breakdown.raw_value_sql(
|
|
72
|
+
label = Ledger::Tags::Breakdown.label_sql(lease_connection)
|
|
73
|
+
raw_value = Ledger::Tags::Breakdown.raw_value_sql(lease_connection)
|
|
74
74
|
relation = Ledger::Tags::Breakdown.join_relation(self, key)
|
|
75
75
|
.select("#{label} AS name", "COALESCE(SUM(#{cost}), 0) AS total_cost")
|
|
76
76
|
.group(Arel.sql(label))
|
|
@@ -93,7 +93,9 @@ module LlmCostTracker
|
|
|
93
93
|
column = column.to_s
|
|
94
94
|
raise ArgumentError, "invalid period column: #{column.inspect}" unless column_names.include?(column)
|
|
95
95
|
|
|
96
|
-
bucket = Ledger::Schema::Adapter.period_bucket_sql(
|
|
96
|
+
bucket = Ledger::Schema::Adapter.period_bucket_sql(
|
|
97
|
+
lease_connection, period, qualified(column), time_zone: time_zone
|
|
98
|
+
)
|
|
97
99
|
group(Arel.sql(bucket))
|
|
98
100
|
end
|
|
99
101
|
|
|
@@ -104,7 +106,7 @@ module LlmCostTracker
|
|
|
104
106
|
end
|
|
105
107
|
|
|
106
108
|
def qualified(column)
|
|
107
|
-
"#{quoted_table_name}.#{
|
|
109
|
+
"#{quoted_table_name}.#{lease_connection.quote_column_name(column)}"
|
|
108
110
|
end
|
|
109
111
|
|
|
110
112
|
private
|
|
@@ -34,15 +34,15 @@ module LlmCostTracker
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def increment_on_duplicate
|
|
37
|
-
return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(
|
|
38
|
-
return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(
|
|
37
|
+
return Arel.sql(mysql_increment_sql) if Ledger::Schema::Adapter.mysql?(lease_connection)
|
|
38
|
+
return Arel.sql(postgres_increment_sql) if Ledger::Schema::Adapter.postgresql?(lease_connection)
|
|
39
39
|
|
|
40
|
-
Ledger::Schema::Adapter.ensure_supported!(
|
|
40
|
+
Ledger::Schema::Adapter.ensure_supported!(lease_connection)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def postgres_increment_sql
|
|
44
|
-
total =
|
|
45
|
-
updated =
|
|
44
|
+
total = lease_connection.quote_column_name("total_cost")
|
|
45
|
+
updated = lease_connection.quote_column_name("updated_at")
|
|
46
46
|
"#{total} = #{quoted_table_name}.#{total} + excluded.#{total}, #{updated} = excluded.#{updated}"
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -51,7 +51,7 @@ module LlmCostTracker
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
def increment_unique_by
|
|
54
|
-
return unless
|
|
54
|
+
return unless lease_connection.supports_insert_conflict_target?
|
|
55
55
|
|
|
56
56
|
%i[period period_start currency provider]
|
|
57
57
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
module CallOrder
|
|
6
|
+
NATURAL_DIRECTIONS = {
|
|
7
|
+
"tracked_at" => "desc",
|
|
8
|
+
"provider" => "asc",
|
|
9
|
+
"model" => "asc",
|
|
10
|
+
"input" => "desc",
|
|
11
|
+
"output" => "desc",
|
|
12
|
+
"cost" => "desc",
|
|
13
|
+
"latency" => "desc"
|
|
14
|
+
}.freeze
|
|
15
|
+
NEWEST_FIRST = { tracked_at: :desc, id: :desc }.freeze
|
|
16
|
+
COST_NULLS_LAST = Arel.sql("CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC")
|
|
17
|
+
LATENCY_NULLS_LAST = Arel.sql("CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC")
|
|
18
|
+
ORDERS = {
|
|
19
|
+
"tracked_at" => ->(dir) { [{ tracked_at: dir, id: dir }] },
|
|
20
|
+
"provider" => ->(dir) { [{ provider: dir, model: :asc, **NEWEST_FIRST }] },
|
|
21
|
+
"model" => ->(dir) { [{ model: dir, **NEWEST_FIRST }] },
|
|
22
|
+
"input" => ->(dir) { [{ input_tokens: dir, **NEWEST_FIRST }] },
|
|
23
|
+
"output" => ->(dir) { [{ output_tokens: dir, **NEWEST_FIRST }] },
|
|
24
|
+
"cost" => ->(dir) { [COST_NULLS_LAST, { total_cost: dir, **NEWEST_FIRST }] },
|
|
25
|
+
"latency" => ->(dir) { [LATENCY_NULLS_LAST, { latency_ms: dir, **NEWEST_FIRST }] }
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def self.call(scope, sort:, direction:)
|
|
29
|
+
choice = Sort.resolve(
|
|
30
|
+
sort, direction.to_s.downcase, natural_directions: NATURAL_DIRECTIONS, fallback: "tracked_at"
|
|
31
|
+
)
|
|
32
|
+
scope.order(*ORDERS.fetch(choice.column).call(choice.direction.to_sym))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Dashboard
|
|
8
|
+
class CallsExport
|
|
9
|
+
MYSQL_HINT = "NO_SKIP_SCAN(llm_cost_tracker_calls)"
|
|
10
|
+
FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
|
|
11
|
+
MASKED_FIELDS = %i[provider_api_key_id provider_workspace_id provider_project_id].freeze
|
|
12
|
+
TEXT_FIELDS = %i[provider model provider_response_id cost_status].freeze
|
|
13
|
+
FIELDS = [
|
|
14
|
+
:tracked_at, :provider, :model, *Usage::TokenUsage.members, :total_cost, :cost_status, :pricing_snapshot,
|
|
15
|
+
:latency_ms, :provider_response_id, :provider_project_id, :provider_api_key_id, :provider_workspace_id,
|
|
16
|
+
:batch, :tags
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def self.call(relation, limit:, batch_size:)
|
|
20
|
+
new(relation, limit: limit, batch_size: batch_size).to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(relation, limit:, batch_size:)
|
|
24
|
+
@relation = relation
|
|
25
|
+
@limit = limit
|
|
26
|
+
@batch_size = batch_size
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_s
|
|
30
|
+
CSV.generate do |csv|
|
|
31
|
+
csv << FIELDS.map(&:to_s)
|
|
32
|
+
each_call { |call| csv << FIELDS.map { |field| value(field, call) } }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
attr_reader :relation, :limit, :batch_size
|
|
39
|
+
|
|
40
|
+
def each_call(&block)
|
|
41
|
+
scope = relation.limit(limit)
|
|
42
|
+
scope = scope.optimizer_hints(MYSQL_HINT) if Ledger::Schema::Adapter.mysql?(scope.lease_connection)
|
|
43
|
+
scope.pluck(:id).each_slice(batch_size) do |ids|
|
|
44
|
+
calls = LlmCostTracker::Call.where(id: ids).preload(:tag_records).index_by(&:id)
|
|
45
|
+
calls.values_at(*ids).compact.each(&block)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def value(field, call)
|
|
50
|
+
case field
|
|
51
|
+
when :tracked_at then call.tracked_at.utc.iso8601
|
|
52
|
+
when *MASKED_FIELDS then formula_safe(Masking.mask_value(field, call[field]))
|
|
53
|
+
when *TEXT_FIELDS then formula_safe(call[field])
|
|
54
|
+
when :pricing_snapshot then formula_safe(Hash(call.pricing_snapshot).deep_stringify_keys.to_json)
|
|
55
|
+
when :tags then formula_safe(call.tag_pairs.to_json)
|
|
56
|
+
else call[field]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def formula_safe(value)
|
|
61
|
+
return if value.nil?
|
|
62
|
+
|
|
63
|
+
string = value.to_s
|
|
64
|
+
FORMULA_PREFIXES.include?(string.lstrip[0]) ? "'#{string}" : string
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|