llm_cost_tracker 0.14.1 → 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 +198 -0
- data/LICENSE.txt +28 -0
- data/README.md +38 -35
- data/app/assets/llm_cost_tracker/application.css +16 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -96
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/models_controller.rb +2 -4
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -13
- data/app/helpers/llm_cost_tracker/application_helper.rb +24 -54
- data/app/helpers/llm_cost_tracker/chart_helper.rb +58 -113
- 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 +36 -26
- data/app/models/llm_cost_tracker/call.rb +15 -17
- 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 +67 -222
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -10
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +20 -18
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +8 -10
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +2 -4
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +3 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -5
- 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 +7 -32
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -3
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -4
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +2 -5
- data/app/views/llm_cost_tracker/tags/show.html.erb +6 -6
- 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 +63 -25
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +22 -171
- data/lib/llm_cost_tracker/capture/event_window.rb +17 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +26 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +99 -107
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +36 -55
- data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +36 -34
- data/lib/llm_cost_tracker/configuration/budgets.rb +50 -19
- 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/configuration/pricing.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +5 -9
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +26 -27
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor/schema_check.rb +1 -2
- data/lib/llm_cost_tracker/doctor.rb +42 -22
- data/lib/llm_cost_tracker/engine.rb +8 -1
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +28 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +5 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -10
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +0 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_indexes_generator.rb +3 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_per_tag_budgets_generator.rb +3 -8
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +22 -19
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +23 -36
- 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 +82 -90
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +98 -93
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +27 -23
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +54 -35
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +135 -121
- 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 -243
- data/lib/llm_cost_tracker/integrations.rb +1 -20
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -9
- data/lib/llm_cost_tracker/ledger/period/totals.rb +15 -11
- data/lib/llm_cost_tracker/ledger/rollups.rb +34 -18
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +33 -5
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -4
- data/lib/llm_cost_tracker/ledger/store.rb +19 -37
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -6
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +3 -12
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -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 -284
- data/lib/llm_cost_tracker/parsers.rb +19 -40
- data/lib/llm_cost_tracker/prices.json +3241 -187
- data/lib/llm_cost_tracker/pricing/backfill.rb +98 -35
- 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 +113 -161
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +11 -10
- data/lib/llm_cost_tracker/pricing/estimator.rb +5 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +61 -30
- data/lib/llm_cost_tracker/pricing/mode.rb +8 -32
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/price_key.rb +5 -3
- data/lib/llm_cost_tracker/pricing/rate.rb +2 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +39 -123
- data/lib/llm_cost_tracker/pricing/service_rates.rb +1 -8
- 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_diff.rb +13 -9
- 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 +24 -119
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +59 -24
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +12 -4
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +140 -50
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +6 -13
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +167 -106
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +65 -55
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +12 -1
- 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 +12 -13
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +100 -98
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +131 -137
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +78 -55
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +17 -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/retention.rb +1 -3
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +45 -31
- data/lib/llm_cost_tracker/usage/catalog.rb +13 -4
- data/lib/llm_cost_tracker/usage/dimension.rb +1 -1
- data/lib/llm_cost_tracker/usage/dimensions.yml +85 -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 +58 -25
- data/llm_cost_tracker.gemspec +64 -0
- metadata +65 -11
|
@@ -5,13 +5,11 @@ module LlmCostTracker
|
|
|
5
5
|
MAX_ROWS = 200
|
|
6
6
|
|
|
7
7
|
def index
|
|
8
|
-
@sort = params[:sort].to_s
|
|
9
|
-
@dir = params[:dir].to_s
|
|
10
8
|
@rows = Dashboard::TopModels.call(
|
|
11
9
|
scope: Dashboard::Filter.call(params: params),
|
|
12
10
|
limit: MAX_ROWS,
|
|
13
|
-
sort:
|
|
14
|
-
direction:
|
|
11
|
+
sort: params[:sort].to_s,
|
|
12
|
+
direction: params[:dir].to_s
|
|
15
13
|
)
|
|
16
14
|
end
|
|
17
15
|
end
|
|
@@ -4,13 +4,24 @@ module LlmCostTracker
|
|
|
4
4
|
class PricingController < ApplicationController
|
|
5
5
|
def index
|
|
6
6
|
@overview = Dashboard::PricingOverview.call
|
|
7
|
-
|
|
8
|
-
@active_source = @overview.fetch(:sources).key?(requested) ? requested : @overview.fetch(:effective_source)
|
|
7
|
+
@active_source = requested_source || @overview.fetch(:effective_source)
|
|
9
8
|
@source_data = @overview.fetch(:sources).fetch(@active_source)
|
|
10
9
|
@provider_filter = Dashboard::Params.scalar(params[:provider], :provider).presence
|
|
11
|
-
@rows = @source_data.fetch(:rows)
|
|
12
|
-
@rows = @rows.select { |row| row.provider == @provider_filter } if @provider_filter
|
|
13
10
|
@providers = @source_data.fetch(:rows).map(&:provider).compact.uniq.sort
|
|
11
|
+
@rows = provider_rows(@source_data.fetch(:rows))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def requested_source
|
|
17
|
+
source = params[:source].to_s.to_sym
|
|
18
|
+
source if @overview.fetch(:sources).key?(source)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def provider_rows(rows)
|
|
22
|
+
return rows unless @provider_filter
|
|
23
|
+
|
|
24
|
+
rows.select { |row| row.provider == @provider_filter }
|
|
14
25
|
end
|
|
15
26
|
end
|
|
16
27
|
end
|
|
@@ -10,21 +10,29 @@ module LlmCostTracker
|
|
|
10
10
|
@value = Dashboard::Params.scalar(params[:tag_value], :tag_value)
|
|
11
11
|
|
|
12
12
|
if @value.empty?
|
|
13
|
-
|
|
14
|
-
@dir = params[:dir].to_s
|
|
15
|
-
@breakdown = Dashboard::TagBreakdown.call(
|
|
16
|
-
scope: Dashboard::Filter.call(params: params), key: params[:key], sort: @sort, direction: @dir
|
|
17
|
-
)
|
|
13
|
+
assign_breakdown
|
|
18
14
|
else
|
|
19
|
-
|
|
20
|
-
params[:key],
|
|
21
|
-
error_class: LlmCostTracker::InvalidFilterError
|
|
22
|
-
)
|
|
23
|
-
value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
|
|
24
|
-
@value_total_cost = value_scope.sum(:total_cost).to_f
|
|
25
|
-
@value_calls = value_scope.count
|
|
26
|
-
@value_points = Dashboard::TimeSeries.call(scope: value_scope)
|
|
15
|
+
assign_value_trend
|
|
27
16
|
end
|
|
28
17
|
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def assign_breakdown
|
|
22
|
+
@breakdown = Dashboard::TagBreakdown.call(
|
|
23
|
+
scope: Dashboard::Filter.call(params: params),
|
|
24
|
+
key: params[:key],
|
|
25
|
+
sort: params[:sort].to_s,
|
|
26
|
+
direction: params[:dir].to_s
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assign_value_trend
|
|
31
|
+
@key = LlmCostTracker::Tags::Key.validate!(params[:key], error_class: LlmCostTracker::InvalidFilterError)
|
|
32
|
+
value_scope = Dashboard::Filter.call(params: params, tags: { @key => @value })
|
|
33
|
+
@value_total_cost = value_scope.sum(:total_cost).to_f
|
|
34
|
+
@value_calls = value_scope.count
|
|
35
|
+
@value_points = Dashboard::TimeSeries.call(scope: value_scope, from: @from_date, to: @to_date)
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
end
|
|
@@ -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
|
|
@@ -13,6 +19,7 @@ module LlmCostTracker
|
|
|
13
19
|
include PaginationHelper
|
|
14
20
|
include TokenUsageHelper
|
|
15
21
|
include InlineStyleHelper
|
|
22
|
+
include SortableTableHelper
|
|
16
23
|
|
|
17
24
|
def dashboard_section
|
|
18
25
|
path = request.path.to_s
|
|
@@ -25,11 +32,13 @@ module LlmCostTracker
|
|
|
25
32
|
:overview
|
|
26
33
|
end
|
|
27
34
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
def coverage_percent(numerator, denominator)
|
|
41
|
+
Dashboard::Percent.of(numerator, denominator)
|
|
33
42
|
end
|
|
34
43
|
|
|
35
44
|
def money(value, currency: LlmCostTracker::DEFAULT_CURRENCY)
|
|
@@ -54,11 +63,7 @@ module LlmCostTracker
|
|
|
54
63
|
def pricing_status(call)
|
|
55
64
|
return "Unknown" if call.total_cost.nil?
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
LlmCostTracker::Charges::CostStatus::COMPLETE => "Estimated",
|
|
59
|
-
LlmCostTracker::Charges::CostStatus::FREE => "Free",
|
|
60
|
-
LlmCostTracker::Charges::CostStatus::PARTIAL => "Partial"
|
|
61
|
-
}.fetch(call.cost_status, "Unknown")
|
|
66
|
+
PRICING_STATUS_LABELS.fetch(call.cost_status, "Unknown")
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
def percent(value)
|
|
@@ -66,22 +71,11 @@ module LlmCostTracker
|
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
def delta_badge(delta_percent, mode: :cost)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return { text: "0.0% vs. prior", css_class: "lct-delta-badge lct-delta-neutral" } if rounded.zero?
|
|
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?
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
text = "#{sign}#{format('%.1f', rounded)}% vs. prior"
|
|
76
|
-
css_class = if mode == :neutral
|
|
77
|
-
"lct-delta-badge lct-delta-neutral"
|
|
78
|
-
elsif rounded.positive?
|
|
79
|
-
"lct-delta-badge lct-delta-up"
|
|
80
|
-
else
|
|
81
|
-
"lct-delta-badge lct-delta-down"
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
{ text: text, css_class: css_class }
|
|
78
|
+
{ text: "#{format('%+.1f', rounded)}% vs. prior", css_class: delta_badge_class(rounded, mode) }
|
|
85
79
|
end
|
|
86
80
|
|
|
87
81
|
def bar_width(value, max)
|
|
@@ -120,13 +114,10 @@ module LlmCostTracker
|
|
|
120
114
|
end
|
|
121
115
|
|
|
122
116
|
def tag_chip_entries(tags, limit: 3)
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
visible = normalized.first(limit).map do |key, value|
|
|
127
|
-
{ key: key.to_s, value: tag_value_summary(value) }
|
|
117
|
+
visible = tags.first(limit).map do |key, value|
|
|
118
|
+
{ key: key.to_s, value: truncate_text(value.to_s, TAG_VALUE_SUMMARY_BYTES) }
|
|
128
119
|
end
|
|
129
|
-
visible << { more:
|
|
120
|
+
visible << { more: tags.size - limit } if tags.size > limit
|
|
130
121
|
visible
|
|
131
122
|
end
|
|
132
123
|
|
|
@@ -134,33 +125,12 @@ module LlmCostTracker
|
|
|
134
125
|
truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
|
|
135
126
|
end
|
|
136
127
|
|
|
137
|
-
def current_query(overrides = {})
|
|
138
|
-
request.query_parameters.symbolize_keys.slice(*LlmCostTracker::Dashboard::Params::QUERY_KEYS).merge(overrides)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def calls_query_for_model(provider:, model:)
|
|
142
|
-
current_query(provider: provider, model: model, page: nil, per: nil, format: nil)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
128
|
private
|
|
146
129
|
|
|
147
|
-
def
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
JSON.parse(tags || "{}")
|
|
151
|
-
rescue JSON::ParserError, TypeError
|
|
152
|
-
{}
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def tag_value_summary(value)
|
|
156
|
-
string = case value
|
|
157
|
-
when Hash, Array
|
|
158
|
-
JSON.generate(value)
|
|
159
|
-
else
|
|
160
|
-
value.to_s
|
|
161
|
-
end
|
|
130
|
+
def delta_badge_class(rounded, mode)
|
|
131
|
+
return NEUTRAL_DELTA_CLASS if mode == :neutral
|
|
162
132
|
|
|
163
|
-
|
|
133
|
+
rounded.positive? ? "lct-delta-badge lct-delta-up" : "lct-delta-badge lct-delta-down"
|
|
164
134
|
end
|
|
165
135
|
|
|
166
136
|
def truncate_text(string, 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,129 +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_points: comparison_points, 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}>"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def chart_grid_and_axis(cfg)
|
|
64
|
-
(0..cfg[:y_ticks]).map { |i| chart_tick_line(cfg, i) }
|
|
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}>"
|
|
65
37
|
end
|
|
66
38
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]))
|
|
74
|
-
label_y = chart_fmt(pad[:top] + (cfg[:plot_h] * idx.to_f / cfg[:y_ticks]) + 3)
|
|
75
|
-
grid = %(<line class="lct-chart-grid" x1="#{left_x}" x2="#{right_x}" y1="#{y}" y2="#{y}"/>)
|
|
76
|
-
label = format("%.2f", value)
|
|
77
|
-
text = %(<text class="lct-chart-axis" x="#{text_x}" y="#{label_y}" text-anchor="end">$#{label}</text>)
|
|
78
|
-
"#{grid}#{text}"
|
|
39
|
+
def chart_tick(chart, index)
|
|
40
|
+
tick_y = chart.tick_y(index)
|
|
41
|
+
y = chart_fmt(tick_y)
|
|
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))}")
|
|
79
45
|
end
|
|
80
46
|
|
|
81
|
-
def chart_paths(
|
|
82
|
-
line =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
end
|
|
90
|
-
%(<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
|
|
91
55
|
end
|
|
92
56
|
|
|
93
|
-
def
|
|
94
|
-
coords.each_with_index.map do |(x, y),
|
|
95
|
-
"#{
|
|
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)}"
|
|
96
60
|
end.join(" ")
|
|
97
61
|
end
|
|
98
62
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
right = chart_fmt(cfg[:pad][:left] + cfg[:plot_w])
|
|
104
|
-
"M#{left},#{chart_fmt(base_y)} L#{chart_fmt(x)},#{chart_fmt(y)} L#{right},#{chart_fmt(base_y)} Z"
|
|
105
|
-
else
|
|
106
|
-
first_x = chart_fmt(coords.first[0])
|
|
107
|
-
last_x = chart_fmt(coords.last[0])
|
|
108
|
-
"#{line} L#{last_x},#{chart_fmt(base_y)} L#{first_x},#{chart_fmt(base_y)} Z"
|
|
109
|
-
end
|
|
110
|
-
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
|
|
111
67
|
|
|
112
|
-
|
|
113
|
-
|
|
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"
|
|
114
70
|
end
|
|
115
71
|
|
|
116
|
-
def chart_dot(
|
|
117
|
-
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 }
|
|
118
77
|
title = ERB::Util.html_escape("#{point[:label]}: #{money(point[:cost])}")
|
|
119
|
-
|
|
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}"/>)
|
|
123
|
-
"<g>#{circle}<title>#{title}</title></g>"
|
|
78
|
+
"<g>#{chart_element(:circle, circle)}<title>#{title}</title></g>"
|
|
124
79
|
end
|
|
125
80
|
|
|
126
|
-
def
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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]))
|
|
131
86
|
end
|
|
132
87
|
|
|
133
|
-
def
|
|
134
|
-
|
|
135
|
-
label_y = chart_fmt(cfg[:height] - 8)
|
|
136
|
-
indexes.map { |idx| chart_x_label(cfg, idx, label_y) }
|
|
137
|
-
end
|
|
88
|
+
def chart_label_anchor(chart, index)
|
|
89
|
+
return "start" if index.zero?
|
|
138
90
|
|
|
139
|
-
|
|
140
|
-
pt_x, = cfg[:coords][idx]
|
|
141
|
-
label = ERB::Util.html_escape(cfg[:points][idx][:label])
|
|
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>)
|
|
91
|
+
index == chart.points.size - 1 ? "end" : "middle"
|
|
147
92
|
end
|
|
148
93
|
end
|
|
149
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
|
|
@@ -33,41 +33,51 @@ module LlmCostTracker
|
|
|
33
33
|
}.freeze
|
|
34
34
|
|
|
35
35
|
def token_usage_stack_components
|
|
36
|
-
|
|
37
|
-
component.
|
|
36
|
+
LlmCostTracker::Usage::Catalog.token_priced.map do |component|
|
|
37
|
+
token_key = component.token_key
|
|
38
|
+
{
|
|
39
|
+
token_key: token_key,
|
|
40
|
+
price_key: component.key,
|
|
41
|
+
label: COMPONENT_LABELS.fetch(token_key),
|
|
42
|
+
css_class: STACK_CLASSES[token_key]
|
|
43
|
+
}
|
|
38
44
|
end
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
def call_line_item_costs_by_component(call)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
LlmCostTracker::Usage::Catalog.costs_by_component(
|
|
49
|
+
call.line_items.map { |item| [item.kind, item.direction, item.cache_state, item.cost] }
|
|
50
|
+
)
|
|
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))
|
|
47
65
|
end
|
|
48
66
|
end
|
|
49
67
|
|
|
68
|
+
def call_service_line_items(call)
|
|
69
|
+
call.line_items.reject { |item| item.unit == "token" }.sort_by(&:position)
|
|
70
|
+
end
|
|
71
|
+
|
|
50
72
|
private
|
|
51
73
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
label: labels.fetch(token_key),
|
|
60
|
-
css_class: STACK_CLASSES[token_key]
|
|
61
|
-
}
|
|
62
|
-
end + [
|
|
63
|
-
{
|
|
64
|
-
token_key: :hidden_output_tokens,
|
|
65
|
-
cost_key: nil,
|
|
66
|
-
price_key: nil,
|
|
67
|
-
label: labels.fetch(:hidden_output_tokens),
|
|
68
|
-
css_class: nil
|
|
69
|
-
}
|
|
70
|
-
]
|
|
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
|
+
}
|
|
71
81
|
end
|
|
72
82
|
end
|
|
73
83
|
end
|