llm_cost_tracker 0.7.3 → 0.8.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/.ruby-version +1 -0
- data/CHANGELOG.md +66 -1
- data/README.md +58 -225
- data/app/assets/llm_cost_tracker/application.css +218 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +20 -7
- data/app/models/llm_cost_tracker/call.rb +169 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
- data/app/models/llm_cost_tracker/call_tag.rb +16 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +121 -30
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +63 -0
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +5 -37
- data/lib/llm_cost_tracker/billing/components.rb +53 -0
- data/lib/llm_cost_tracker/billing/components.yml +117 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +23 -35
- data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
- data/lib/llm_cost_tracker/configuration.rb +36 -19
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +31 -0
- data/lib/llm_cost_tracker/doctor.rb +43 -45
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +10 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
- data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
- data/lib/llm_cost_tracker/ingestion.rb +28 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
- data/lib/llm_cost_tracker/integrations/base.rb +36 -29
- data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
- data/lib/llm_cost_tracker/integrations.rb +2 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +96 -13
- data/lib/llm_cost_tracker/ledger/tags/query.rb +4 -10
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +4 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
- data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
- data/lib/llm_cost_tracker/parsers/base.rb +8 -3
- data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +105 -20
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
- data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync.rb +57 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +190 -26
- data/lib/llm_cost_tracker/railtie.rb +0 -8
- data/lib/llm_cost_tracker/report/data.rb +16 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +8 -8
- data/lib/llm_cost_tracker/tags/context.rb +2 -4
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +56 -42
- data/lib/llm_cost_tracker/tracker.rb +67 -24
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +36 -35
- data/lib/tasks/llm_cost_tracker.rake +22 -17
- metadata +36 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -1,44 +1,98 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "llm_cost_tracker/
|
|
3
|
+
require "llm_cost_tracker/billing/components"
|
|
4
4
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
5
5
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
module Dashboard
|
|
8
8
|
class DataQuality
|
|
9
|
+
UnknownPricingRow = ::Data.define(:model, :calls, :share_percent)
|
|
10
|
+
Summary = ::Data.define(:total, :unknown_pricing_count, :untagged_calls_count, :missing_latency_count,
|
|
11
|
+
:streaming_count, :streaming_missing_usage, :missing_provider_response_id_count,
|
|
12
|
+
:calls_with_pricing, :tagged_calls, :calls_with_latency, :streams_with_usage,
|
|
13
|
+
:calls_with_provider_response_id, :unknown_pricing_share, :untagged_share,
|
|
14
|
+
:missing_latency_share, :streaming_share, :streaming_missing_usage_share,
|
|
15
|
+
:cost_coverage, :tag_coverage, :latency_coverage, :stream_coverage,
|
|
16
|
+
:provider_response_id_coverage)
|
|
17
|
+
|
|
9
18
|
class << self
|
|
10
|
-
def call(scope: LlmCostTracker::
|
|
19
|
+
def call(scope: LlmCostTracker::Call.all)
|
|
11
20
|
scope.unscope(:order).select(aggregate_selects(scope)).take
|
|
12
21
|
end
|
|
13
22
|
|
|
14
|
-
def
|
|
23
|
+
def summary(stats)
|
|
24
|
+
total = stats.total_calls.to_i
|
|
25
|
+
unknown_pricing_count = stats.unknown_pricing_count.to_i
|
|
26
|
+
untagged_calls_count = stats.untagged_calls_count.to_i
|
|
27
|
+
missing_latency_count = stats.missing_latency_count.to_i
|
|
28
|
+
streaming_count = stats.streaming_count.to_i
|
|
29
|
+
streaming_missing_usage = stats.streaming_missing_usage_count.to_i
|
|
30
|
+
missing_provider_response_id_count = stats.missing_provider_response_id_count.to_i
|
|
31
|
+
calls_with_pricing = total - unknown_pricing_count
|
|
32
|
+
tagged_calls = total - untagged_calls_count
|
|
33
|
+
calls_with_latency = total - missing_latency_count
|
|
34
|
+
streams_with_usage = streaming_count - streaming_missing_usage
|
|
35
|
+
calls_with_provider_response_id = total - missing_provider_response_id_count
|
|
36
|
+
|
|
37
|
+
Summary.new(
|
|
38
|
+
total, unknown_pricing_count, untagged_calls_count, missing_latency_count, streaming_count,
|
|
39
|
+
streaming_missing_usage, missing_provider_response_id_count, calls_with_pricing, tagged_calls,
|
|
40
|
+
calls_with_latency, streams_with_usage, calls_with_provider_response_id,
|
|
41
|
+
percentage(unknown_pricing_count, total), percentage(untagged_calls_count, total),
|
|
42
|
+
percentage(missing_latency_count, total), percentage(streaming_count, total),
|
|
43
|
+
percentage(streaming_missing_usage, streaming_count), percentage(calls_with_pricing, total),
|
|
44
|
+
percentage(tagged_calls, total), percentage(calls_with_latency, total),
|
|
45
|
+
percentage(streams_with_usage, streaming_count), percentage(calls_with_provider_response_id, total)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unknown_pricing_by_model(scope, total_calls:)
|
|
15
50
|
scope.unknown_pricing
|
|
16
51
|
.group(:model)
|
|
17
52
|
.order(Arel.sql("COUNT(*) DESC"))
|
|
18
53
|
.select("model, COUNT(*) AS calls")
|
|
19
54
|
.limit(10)
|
|
55
|
+
.map do |row|
|
|
56
|
+
calls = row.calls.to_i
|
|
57
|
+
UnknownPricingRow.new(model: row.model, calls: calls, share_percent: percentage(calls, total_calls))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def service_charge_rows(scope)
|
|
62
|
+
call_table = LlmCostTracker::Call.quoted_table_name
|
|
63
|
+
line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
|
|
64
|
+
relation = LlmCostTracker::CallLineItem
|
|
65
|
+
.where.not(unit: "token")
|
|
66
|
+
.joins(:call)
|
|
67
|
+
.merge(scope.unscope(:select, :order))
|
|
68
|
+
|
|
69
|
+
relation
|
|
70
|
+
.group("#{call_table}.provider", "#{line_item_table}.kind", "#{line_item_table}.cost_status")
|
|
71
|
+
.order(Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0) DESC"), Arel.sql("COUNT(*) DESC"))
|
|
72
|
+
.select(
|
|
73
|
+
"#{call_table}.provider AS provider",
|
|
74
|
+
"#{line_item_table}.kind AS component",
|
|
75
|
+
"#{line_item_table}.cost_status AS cost_status",
|
|
76
|
+
"COUNT(*) AS charges_count",
|
|
77
|
+
"COALESCE(SUM(#{line_item_table}.quantity), 0) AS quantity",
|
|
78
|
+
"COALESCE(SUM(#{line_item_table}.cost), 0) AS total_cost"
|
|
79
|
+
)
|
|
80
|
+
.limit(10)
|
|
20
81
|
end
|
|
21
82
|
|
|
22
|
-
def usage_rows(stats)
|
|
83
|
+
def usage_rows(stats, component_costs: {})
|
|
23
84
|
billable_tokens = stats.billable_tokens.to_f
|
|
24
85
|
|
|
25
|
-
rows =
|
|
26
|
-
|
|
27
|
-
cost_key = component.cost_key
|
|
28
|
-
token_value = stats[token_key].to_i
|
|
29
|
-
share_percent = if billable_tokens.positive?
|
|
30
|
-
(token_value.to_f / billable_tokens) * 100.0
|
|
31
|
-
else
|
|
32
|
-
0.0
|
|
33
|
-
end
|
|
86
|
+
rows = Billing::Components::TOKEN_PRICED.map do |component|
|
|
87
|
+
token_value = stats[component.token_key].to_i
|
|
34
88
|
|
|
35
89
|
{
|
|
36
|
-
price_key: component.
|
|
37
|
-
token_key: token_key,
|
|
38
|
-
cost_key: cost_key,
|
|
90
|
+
price_key: component.key,
|
|
91
|
+
token_key: component.token_key,
|
|
92
|
+
cost_key: component.cost_key,
|
|
39
93
|
token_value: token_value,
|
|
40
|
-
cost_value:
|
|
41
|
-
share_percent:
|
|
94
|
+
cost_value: component_costs[component.key],
|
|
95
|
+
share_percent: percentage(token_value, billable_tokens),
|
|
42
96
|
share_basis: nil
|
|
43
97
|
}
|
|
44
98
|
end
|
|
@@ -56,6 +110,21 @@ module LlmCostTracker
|
|
|
56
110
|
]
|
|
57
111
|
end
|
|
58
112
|
|
|
113
|
+
def component_costs(scope)
|
|
114
|
+
line_item_table = LlmCostTracker::CallLineItem.quoted_table_name
|
|
115
|
+
rows = LlmCostTracker::CallLineItem
|
|
116
|
+
.where(unit: "token")
|
|
117
|
+
.joins(:call)
|
|
118
|
+
.merge(scope.unscope(:select, :order, :group))
|
|
119
|
+
.group("#{line_item_table}.kind", "#{line_item_table}.direction",
|
|
120
|
+
"#{line_item_table}.cache_state")
|
|
121
|
+
.pluck(Arel.sql("#{line_item_table}.kind"),
|
|
122
|
+
Arel.sql("#{line_item_table}.direction"),
|
|
123
|
+
Arel.sql("#{line_item_table}.cache_state"),
|
|
124
|
+
Arel.sql("COALESCE(SUM(#{line_item_table}.cost), 0)"))
|
|
125
|
+
index_costs_by_component(rows)
|
|
126
|
+
end
|
|
127
|
+
|
|
59
128
|
def hidden_output_summary(stats)
|
|
60
129
|
output_tokens = stats.output_tokens.to_i
|
|
61
130
|
return unless output_tokens.positive?
|
|
@@ -69,10 +138,27 @@ module LlmCostTracker
|
|
|
69
138
|
|
|
70
139
|
private
|
|
71
140
|
|
|
141
|
+
def index_costs_by_component(rows)
|
|
142
|
+
rows.each_with_object({}) do |(kind, direction, cache_state, cost), accumulator|
|
|
143
|
+
component = Billing::Components::TOKEN_PRICED.find do |item|
|
|
144
|
+
item.kind.to_s == kind.to_s &&
|
|
145
|
+
item.direction.to_s == direction.to_s &&
|
|
146
|
+
item.cache_state.to_s == cache_state.to_s
|
|
147
|
+
end
|
|
148
|
+
accumulator[component.key] = cost if component
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def percentage(numerator, denominator)
|
|
153
|
+
return 0.0 unless denominator.positive?
|
|
154
|
+
|
|
155
|
+
(numerator.to_f / denominator) * 100.0
|
|
156
|
+
end
|
|
157
|
+
|
|
72
158
|
def aggregate_selects(scope)
|
|
73
159
|
selects = [
|
|
74
160
|
"COUNT(*) AS total_calls",
|
|
75
|
-
"#{conditional_count_sql(
|
|
161
|
+
"#{conditional_count_sql(unknown_pricing_predicate(scope))} AS unknown_pricing_count",
|
|
76
162
|
"#{tagged_calls_sql(scope)} AS tagged_calls_count",
|
|
77
163
|
"COUNT(*) - #{tagged_calls_sql(scope)} AS untagged_calls_count",
|
|
78
164
|
"#{conditional_count_sql('latency_ms IS NULL')} AS missing_latency_count",
|
|
@@ -92,11 +178,11 @@ module LlmCostTracker
|
|
|
92
178
|
end
|
|
93
179
|
|
|
94
180
|
def usage_sum_columns
|
|
95
|
-
|
|
181
|
+
Billing::Components::TOKEN_PRICED.map(&:token_key) + [:hidden_output_tokens]
|
|
96
182
|
end
|
|
97
183
|
|
|
98
184
|
def billable_tokens_select(scope)
|
|
99
|
-
|
|
185
|
+
Billing::Components::TOKEN_PRICED
|
|
100
186
|
.map { |component| column_sum(scope, component.token_key) }
|
|
101
187
|
.join(" + ")
|
|
102
188
|
end
|
|
@@ -108,6 +194,15 @@ module LlmCostTracker
|
|
|
108
194
|
"CASE WHEN #{output} > 0 THEN #{hidden_output} * 100.0 / #{output} ELSE 0 END"
|
|
109
195
|
end
|
|
110
196
|
|
|
197
|
+
def unknown_pricing_predicate(scope)
|
|
198
|
+
values = [
|
|
199
|
+
LlmCostTracker::Billing::CostStatus::UNKNOWN,
|
|
200
|
+
LlmCostTracker::Billing::CostStatus::PARTIAL
|
|
201
|
+
].map { |value| scope.connection.quote(value) }
|
|
202
|
+
|
|
203
|
+
"total_cost IS NULL OR cost_status IN (#{values.join(', ')})"
|
|
204
|
+
end
|
|
205
|
+
|
|
111
206
|
def column_sum(scope, column)
|
|
112
207
|
"COALESCE(SUM(#{scope.connection.quote_column_name(column)}), 0)"
|
|
113
208
|
end
|
|
@@ -127,15 +222,11 @@ module LlmCostTracker
|
|
|
127
222
|
end
|
|
128
223
|
|
|
129
224
|
def tagged_calls_sql(scope)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"COALESCE(SUM(CASE WHEN #{column} <> '{}'::jsonb THEN 1 ELSE 0 END), 0)"
|
|
136
|
-
else
|
|
137
|
-
"COALESCE(SUM(CASE WHEN JSON_LENGTH(#{column}) > 0 THEN 1 ELSE 0 END), 0)"
|
|
138
|
-
end
|
|
225
|
+
calls_table = scope.klass.quoted_table_name
|
|
226
|
+
tags_table = LlmCostTracker::CallTag.quoted_table_name
|
|
227
|
+
|
|
228
|
+
"COALESCE(SUM(CASE WHEN EXISTS (SELECT 1 FROM #{tags_table} " \
|
|
229
|
+
"WHERE #{tags_table}.llm_cost_tracker_call_id = #{calls_table}.id) THEN 1 ELSE 0 END), 0)"
|
|
139
230
|
end
|
|
140
231
|
end
|
|
141
232
|
end
|
|
@@ -13,7 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def self.parse(params, key)
|
|
16
|
-
value = LlmCostTracker::Dashboard::Params.
|
|
16
|
+
value = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys[key].to_s.strip.presence
|
|
17
17
|
return nil unless value
|
|
18
18
|
|
|
19
19
|
Date.iso8601(value)
|
|
@@ -6,14 +6,14 @@ module LlmCostTracker
|
|
|
6
6
|
module Dashboard
|
|
7
7
|
class Filter
|
|
8
8
|
class << self
|
|
9
|
-
def call(scope: LlmCostTracker::
|
|
9
|
+
def call(scope: LlmCostTracker::Call.all, params: {})
|
|
10
10
|
new(scope: scope, params: params).relation
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def initialize(scope:, params:)
|
|
15
15
|
@scope = scope
|
|
16
|
-
@params = LlmCostTracker::Dashboard::Params.
|
|
16
|
+
@params = LlmCostTracker::Dashboard::Params.to_hash(params).symbolize_keys
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def relation
|
|
@@ -6,18 +6,25 @@ module LlmCostTracker
|
|
|
6
6
|
module Dashboard
|
|
7
7
|
class OverviewStats
|
|
8
8
|
class << self
|
|
9
|
-
def call(scope: LlmCostTracker::
|
|
10
|
-
scope.select(aggregate_selects
|
|
9
|
+
def call(scope: LlmCostTracker::Call.all, previous_scope: nil)
|
|
10
|
+
return scope.select(aggregate_selects).take unless previous_scope
|
|
11
|
+
|
|
12
|
+
scope.klass
|
|
13
|
+
.from("(#{scope.unscope(:select, :order).to_sql}) AS current_calls")
|
|
14
|
+
.joins("CROSS JOIN (#{previous_aggregate_sql(previous_scope)}) AS previous_stats")
|
|
15
|
+
.select(aggregate_selects(table_name: "current_calls", previous: true))
|
|
16
|
+
.take
|
|
11
17
|
end
|
|
12
18
|
|
|
13
19
|
def monthly_budget_status
|
|
14
20
|
budget = LlmCostTracker.configuration.monthly_budget
|
|
15
21
|
return nil unless budget
|
|
16
22
|
|
|
23
|
+
budget = budget.to_f
|
|
17
24
|
now = Time.now.utc
|
|
18
25
|
month_start = now.beginning_of_month
|
|
19
26
|
month_end = now.end_of_month
|
|
20
|
-
spent = LlmCostTracker::Ledger::Period::Totals.call(%i[
|
|
27
|
+
spent = LlmCostTracker::Ledger::Period::Totals.call(%i[month], time: now).fetch(:month)
|
|
21
28
|
elapsed_seconds = now - month_start
|
|
22
29
|
total_seconds = month_end - month_start
|
|
23
30
|
projected_spent = if spent.zero? || !elapsed_seconds.positive?
|
|
@@ -25,39 +32,65 @@ module LlmCostTracker
|
|
|
25
32
|
else
|
|
26
33
|
spent * (total_seconds / elapsed_seconds)
|
|
27
34
|
end
|
|
35
|
+
percent_used = budget.positive? ? (spent / budget) * 100.0 : 0.0
|
|
36
|
+
projected_percent_used = budget.positive? ? (projected_spent / budget) * 100.0 : 0.0
|
|
37
|
+
projected_delta = projected_spent - budget
|
|
28
38
|
|
|
29
39
|
{
|
|
30
|
-
budget: budget
|
|
40
|
+
budget: budget,
|
|
31
41
|
spent: spent,
|
|
32
|
-
percent_used:
|
|
42
|
+
percent_used: percent_used,
|
|
33
43
|
projected_spent: projected_spent,
|
|
34
|
-
projected_percent_used:
|
|
35
|
-
projected_delta:
|
|
36
|
-
projection_end_label: month_end.strftime("%b %-d")
|
|
44
|
+
projected_percent_used: projected_percent_used,
|
|
45
|
+
projected_delta: projected_delta,
|
|
46
|
+
projection_end_label: month_end.strftime("%b %-d"),
|
|
47
|
+
fill_modifier: budget_fill_modifier(percent_used),
|
|
48
|
+
progress_percent: clamped_percent(percent_used),
|
|
49
|
+
projected_marker_percent: clamped_percent(projected_percent_used),
|
|
50
|
+
projected_delta_amount: projected_delta.abs,
|
|
51
|
+
projected_delta_direction: projected_delta.positive? ? "over" : "under",
|
|
52
|
+
projected_delta_status_class: projected_delta_status_class(projected_delta)
|
|
37
53
|
}
|
|
38
54
|
end
|
|
39
55
|
|
|
56
|
+
UNKNOWN_PRICING_COST_STATUSES = [
|
|
57
|
+
LlmCostTracker::Billing::CostStatus::UNKNOWN,
|
|
58
|
+
LlmCostTracker::Billing::CostStatus::PARTIAL
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
40
61
|
private
|
|
41
62
|
|
|
42
|
-
def aggregate_selects(
|
|
63
|
+
def aggregate_selects(table_name: nil, previous: false)
|
|
64
|
+
total_cost = table_name ? "#{table_name}.total_cost" : "total_cost"
|
|
65
|
+
latency_ms = table_name ? "#{table_name}.latency_ms" : "latency_ms"
|
|
66
|
+
cost_status = table_name ? "#{table_name}.cost_status" : "cost_status"
|
|
43
67
|
average_cost_sql = <<~SQL.squish
|
|
44
68
|
CASE WHEN COUNT(*) > 0
|
|
45
|
-
THEN COALESCE(SUM(total_cost), 0) * 1.0 / COUNT(*)
|
|
69
|
+
THEN COALESCE(SUM(#{total_cost}), 0) * 1.0 / COUNT(*)
|
|
46
70
|
ELSE 0 END
|
|
47
71
|
SQL
|
|
72
|
+
unknown_pricing_sql = <<~SQL.squish
|
|
73
|
+
SUM(CASE WHEN #{total_cost} IS NULL OR
|
|
74
|
+
#{cost_status} IN (#{UNKNOWN_PRICING_COST_STATUSES.map { |s| connection.quote(s) }.join(', ')})
|
|
75
|
+
THEN 1 ELSE 0 END)
|
|
76
|
+
SQL
|
|
48
77
|
selects = [
|
|
49
78
|
"COUNT(*) AS total_calls",
|
|
50
|
-
"COALESCE(SUM(total_cost), 0) AS total_cost",
|
|
79
|
+
"COALESCE(SUM(#{total_cost}), 0) AS total_cost",
|
|
51
80
|
"#{average_cost_sql} AS average_cost_per_call",
|
|
52
|
-
"
|
|
53
|
-
"AVG(latency_ms) AS average_latency_ms"
|
|
81
|
+
"#{unknown_pricing_sql} AS unknown_pricing_count",
|
|
82
|
+
"AVG(#{latency_ms}) AS average_latency_ms"
|
|
54
83
|
]
|
|
55
|
-
selects.concat(previous_selects(
|
|
84
|
+
selects.concat(previous_selects(previous))
|
|
56
85
|
selects.join(", ")
|
|
57
86
|
end
|
|
58
87
|
|
|
59
|
-
def
|
|
60
|
-
|
|
88
|
+
def connection
|
|
89
|
+
LlmCostTracker::Call.connection
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def previous_selects(previous)
|
|
93
|
+
unless previous
|
|
61
94
|
return [
|
|
62
95
|
"NULL AS previous_total_cost",
|
|
63
96
|
"NULL AS previous_total_calls",
|
|
@@ -66,11 +99,11 @@ module LlmCostTracker
|
|
|
66
99
|
]
|
|
67
100
|
end
|
|
68
101
|
|
|
69
|
-
previous_cost_sql =
|
|
70
|
-
previous_calls_sql =
|
|
102
|
+
previous_cost_sql = "MAX(previous_stats.total_cost)"
|
|
103
|
+
previous_calls_sql = "MAX(previous_stats.total_calls)"
|
|
71
104
|
cost_delta_sql = <<~SQL.squish
|
|
72
105
|
CASE WHEN (#{previous_cost_sql}) = 0 THEN NULL
|
|
73
|
-
ELSE ((COALESCE(SUM(total_cost), 0) - (#{previous_cost_sql})) * 100.0 / (#{previous_cost_sql}))
|
|
106
|
+
ELSE ((COALESCE(SUM(current_calls.total_cost), 0) - (#{previous_cost_sql})) * 100.0 / (#{previous_cost_sql}))
|
|
74
107
|
END
|
|
75
108
|
SQL
|
|
76
109
|
calls_delta_sql = <<~SQL.squish
|
|
@@ -86,8 +119,28 @@ module LlmCostTracker
|
|
|
86
119
|
]
|
|
87
120
|
end
|
|
88
121
|
|
|
89
|
-
def
|
|
90
|
-
scope
|
|
122
|
+
def previous_aggregate_sql(scope)
|
|
123
|
+
scope
|
|
124
|
+
.unscope(:select, :order)
|
|
125
|
+
.select("COALESCE(SUM(total_cost), 0) AS total_cost", "COUNT(*) AS total_calls")
|
|
126
|
+
.to_sql
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def clamped_percent(value)
|
|
130
|
+
value.clamp(0.0, 100.0)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def budget_fill_modifier(percent)
|
|
134
|
+
return "lct-budget-fill--over" if percent >= 100.0
|
|
135
|
+
return "lct-budget-fill--warn" if percent >= 80.0
|
|
136
|
+
|
|
137
|
+
""
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def projected_delta_status_class(delta)
|
|
141
|
+
return "lct-budget-projection-status--over" if delta.positive?
|
|
142
|
+
|
|
143
|
+
"lct-budget-projection-status--under"
|
|
91
144
|
end
|
|
92
145
|
end
|
|
93
146
|
end
|
|
@@ -10,7 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
attr_reader :page, :per
|
|
11
11
|
|
|
12
12
|
def self.call(params)
|
|
13
|
-
params = Params.
|
|
13
|
+
params = Params.to_hash(params).symbolize_keys
|
|
14
14
|
new(
|
|
15
15
|
page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
|
|
16
16
|
per: integer_param(params, :per, default: DEFAULT_PER, min: 1, max: MAX_PER)
|
|
@@ -46,13 +46,15 @@ module LlmCostTracker
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def next_page?(total_count)
|
|
49
|
-
|
|
49
|
+
total_count = total_count.to_i
|
|
50
|
+
offset + per < total_count
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
def total_pages(total_count)
|
|
53
|
-
|
|
54
|
+
total_count = total_count.to_i
|
|
55
|
+
return MIN_PAGE unless total_count.positive?
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
((total_count + per - 1) / per)
|
|
56
58
|
end
|
|
57
59
|
end
|
|
58
60
|
end
|
|
@@ -17,8 +17,14 @@ module LlmCostTracker
|
|
|
17
17
|
{}
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def
|
|
21
|
-
to_hash(value).
|
|
20
|
+
def tag_query(value)
|
|
21
|
+
to_hash(value).each_with_object({}) do |(key, tag_value), tags|
|
|
22
|
+
key = key.to_s
|
|
23
|
+
tag_value = tag_value.to_s
|
|
24
|
+
next if key.blank? || tag_value.blank?
|
|
25
|
+
|
|
26
|
+
tags[key] = tag_value
|
|
27
|
+
end
|
|
22
28
|
end
|
|
23
29
|
end
|
|
24
30
|
end
|
|
@@ -6,7 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
WINDOW_DAYS = 7
|
|
7
7
|
|
|
8
8
|
class << self
|
|
9
|
-
def call(from:, to:, scope: LlmCostTracker::
|
|
9
|
+
def call(from:, to:, scope: LlmCostTracker::Call.all)
|
|
10
10
|
new(scope: scope, from: from, to: to).alert
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -28,13 +28,14 @@ module LlmCostTracker
|
|
|
28
28
|
attr_reader :scope, :from, :to
|
|
29
29
|
|
|
30
30
|
def alerts
|
|
31
|
+
window_days = WINDOW_DAYS.to_f
|
|
31
32
|
daily_spend_by_model.each_with_object([]) do |((provider, model), daily_costs), rows|
|
|
32
33
|
latest_spend = daily_costs.fetch(to, 0.0)
|
|
33
34
|
next unless latest_spend.positive?
|
|
34
35
|
|
|
35
36
|
baseline_days = ((to - WINDOW_DAYS)...to).map { |day| daily_costs.fetch(day, 0.0) }
|
|
36
|
-
mean = baseline_days.sum /
|
|
37
|
-
variance = baseline_days.sum { |value| (value - mean)**2 } /
|
|
37
|
+
mean = baseline_days.sum / window_days
|
|
38
|
+
variance = baseline_days.sum { |value| (value - mean)**2 } / window_days
|
|
38
39
|
threshold = mean + (2 * Math.sqrt(variance))
|
|
39
40
|
next unless latest_spend > threshold
|
|
40
41
|
|
|
@@ -4,9 +4,10 @@ module LlmCostTracker
|
|
|
4
4
|
module Dashboard
|
|
5
5
|
class TagBreakdown
|
|
6
6
|
DEFAULT_LIMIT = 100
|
|
7
|
+
Row = Data.define(:value, :calls, :total_cost, :average_cost_per_call, :share_percent)
|
|
7
8
|
|
|
8
9
|
class << self
|
|
9
|
-
def call(key:, scope: LlmCostTracker::
|
|
10
|
+
def call(key:, scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
|
|
10
11
|
new(scope: scope, key: key, limit: limit)
|
|
11
12
|
end
|
|
12
13
|
end
|
|
@@ -21,7 +22,19 @@ module LlmCostTracker
|
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def rows
|
|
24
|
-
@rows ||=
|
|
25
|
+
@rows ||= begin
|
|
26
|
+
total = tagged_calls
|
|
27
|
+
scope.klass.find_by_sql(rows_sql).map do |row|
|
|
28
|
+
calls = row.calls.to_i
|
|
29
|
+
Row.new(
|
|
30
|
+
value: row.value,
|
|
31
|
+
calls: calls,
|
|
32
|
+
total_cost: row.total_cost,
|
|
33
|
+
average_cost_per_call: row.average_cost_per_call,
|
|
34
|
+
share_percent: percentage(calls, total)
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
25
38
|
end
|
|
26
39
|
|
|
27
40
|
def total_calls
|
|
@@ -46,13 +59,14 @@ module LlmCostTracker
|
|
|
46
59
|
|
|
47
60
|
def rows_sql
|
|
48
61
|
<<~SQL.squish
|
|
49
|
-
SELECT #{
|
|
62
|
+
SELECT #{tag_value_column} AS value,
|
|
50
63
|
COUNT(*) AS calls,
|
|
51
64
|
COALESCE(SUM(sub.total_cost), 0) AS total_cost,
|
|
52
65
|
COALESCE(SUM(sub.total_cost), 0) / NULLIF(COUNT(*), 0) AS average_cost_per_call
|
|
53
66
|
FROM (#{scope.to_sql}) AS sub
|
|
67
|
+
INNER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
|
|
54
68
|
WHERE #{tag_present_predicate}
|
|
55
|
-
GROUP BY #{
|
|
69
|
+
GROUP BY #{tag_value_column}
|
|
56
70
|
ORDER BY total_cost DESC, calls DESC, value ASC
|
|
57
71
|
LIMIT #{limit}
|
|
58
72
|
SQL
|
|
@@ -61,18 +75,37 @@ module LlmCostTracker
|
|
|
61
75
|
def summary_sql
|
|
62
76
|
<<~SQL.squish
|
|
63
77
|
SELECT COUNT(*) AS total_calls,
|
|
64
|
-
|
|
65
|
-
COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{
|
|
78
|
+
COUNT(t.#{quote_column('value')}) AS tagged_calls,
|
|
79
|
+
COUNT(DISTINCT CASE WHEN #{tag_present_predicate} THEN #{tag_value_column} END) AS distinct_values
|
|
66
80
|
FROM (#{scope.to_sql}) AS sub
|
|
81
|
+
LEFT OUTER JOIN #{call_tag_table} t ON t.llm_cost_tracker_call_id = sub.id AND t.#{quote_column('key')} = #{quoted_key}
|
|
67
82
|
SQL
|
|
68
83
|
end
|
|
69
84
|
|
|
70
85
|
def tag_present_predicate
|
|
71
|
-
"#{
|
|
86
|
+
"#{tag_value_column} IS NOT NULL AND #{tag_value_column} != ''"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def tag_value_column
|
|
90
|
+
"t.#{quote_column('value')}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def call_tag_table
|
|
94
|
+
LlmCostTracker::CallTag.quoted_table_name
|
|
72
95
|
end
|
|
73
96
|
|
|
74
|
-
def
|
|
75
|
-
|
|
97
|
+
def quote_column(name)
|
|
98
|
+
scope.connection.quote_column_name(name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def quoted_key
|
|
102
|
+
scope.connection.quote(key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def percentage(numerator, denominator)
|
|
106
|
+
return 0.0 unless denominator.positive?
|
|
107
|
+
|
|
108
|
+
(numerator / denominator.to_f) * 100.0
|
|
76
109
|
end
|
|
77
110
|
end
|
|
78
111
|
end
|
|
@@ -6,14 +6,14 @@ module LlmCostTracker
|
|
|
6
6
|
DEFAULT_LIMIT = 100
|
|
7
7
|
|
|
8
8
|
class << self
|
|
9
|
-
def call(scope: LlmCostTracker::
|
|
9
|
+
def call(scope: LlmCostTracker::Call.all, limit: DEFAULT_LIMIT)
|
|
10
10
|
new(scope: scope, limit: limit).rows
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def initialize(scope:, limit:)
|
|
15
15
|
@scope = scope
|
|
16
|
-
@connection = LlmCostTracker::
|
|
16
|
+
@connection = LlmCostTracker::Call.connection
|
|
17
17
|
limit = limit.to_i
|
|
18
18
|
@limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
|
19
19
|
end
|
|
@@ -29,50 +29,27 @@ module LlmCostTracker
|
|
|
29
29
|
|
|
30
30
|
attr_reader :scope, :connection, :limit
|
|
31
31
|
|
|
32
|
-
def subquery
|
|
33
|
-
scope.to_sql
|
|
34
|
-
end
|
|
35
|
-
|
|
36
32
|
def build_sql
|
|
37
|
-
|
|
38
|
-
return mysql_sql if Ledger::Schema::Adapter.mysql?(connection)
|
|
39
|
-
|
|
40
|
-
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
41
|
-
end
|
|
33
|
+
tags_table = LlmCostTracker::CallTag.quoted_table_name
|
|
42
34
|
|
|
43
|
-
def mysql_sql
|
|
44
35
|
<<~SQL.squish
|
|
45
|
-
SELECT
|
|
36
|
+
SELECT t.#{key_column} AS #{key_column},
|
|
46
37
|
COUNT(*) AS calls_count,
|
|
47
|
-
COUNT(DISTINCT
|
|
48
|
-
FROM (#{
|
|
49
|
-
JOIN
|
|
50
|
-
|
|
51
|
-
'$[*]' COLUMNS(
|
|
52
|
-
key VARCHAR(255) PATH '$'
|
|
53
|
-
)
|
|
54
|
-
) AS jt
|
|
55
|
-
WHERE sub.tags IS NOT NULL
|
|
56
|
-
AND sub.tags != ''
|
|
57
|
-
GROUP BY jt.key
|
|
38
|
+
COUNT(DISTINCT t.#{value_column}) AS distinct_values
|
|
39
|
+
FROM (#{scope.to_sql}) AS sub
|
|
40
|
+
INNER JOIN #{tags_table} t ON t.llm_cost_tracker_call_id = sub.id
|
|
41
|
+
GROUP BY t.#{key_column}
|
|
58
42
|
ORDER BY calls_count DESC
|
|
59
43
|
LIMIT #{limit}
|
|
60
44
|
SQL
|
|
61
45
|
end
|
|
62
46
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
jsonb_object_keys(sub.tags::jsonb) AS key
|
|
70
|
-
WHERE sub.tags IS NOT NULL
|
|
71
|
-
AND sub.tags::jsonb <> '{}'::jsonb
|
|
72
|
-
GROUP BY key
|
|
73
|
-
ORDER BY calls_count DESC
|
|
74
|
-
LIMIT #{limit}
|
|
75
|
-
SQL
|
|
47
|
+
def key_column
|
|
48
|
+
connection.quote_column_name("key")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value_column
|
|
52
|
+
connection.quote_column_name("value")
|
|
76
53
|
end
|
|
77
54
|
end
|
|
78
55
|
end
|
|
@@ -8,7 +8,7 @@ module LlmCostTracker
|
|
|
8
8
|
DEFAULT_DAYS = 30
|
|
9
9
|
|
|
10
10
|
class << self
|
|
11
|
-
def call(scope: LlmCostTracker::
|
|
11
|
+
def call(scope: LlmCostTracker::Call.all, from: nil, to: Date.current)
|
|
12
12
|
new(scope: scope, from: from, to: to).points
|
|
13
13
|
end
|
|
14
14
|
end
|