llm_cost_tracker 0.3.0 → 0.3.1
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 +16 -0
- data/README.md +14 -1
- data/app/assets/llm_cost_tracker/application.css +1 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
- data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
- data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +10 -10
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
- data/app/services/llm_cost_tracker/pagination.rb +1 -9
- data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
- data/app/views/llm_cost_tracker/calls/index.html.erb +13 -13
- data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
- data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +0 -1
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/event_metadata.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
- data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
- data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
- data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
- data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
- data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
- data/lib/llm_cost_tracker/price_registry.rb +22 -7
- data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
- data/lib/llm_cost_tracker/price_sync.rb +16 -184
- data/lib/llm_cost_tracker/pricing.rb +0 -11
- data/lib/llm_cost_tracker/railtie.rb +0 -1
- data/lib/llm_cost_tracker/report.rb +0 -5
- data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
- data/lib/llm_cost_tracker/stream_collector.rb +17 -13
- data/lib/llm_cost_tracker/tags_column.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +10 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +6 -14
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93ef8bc5c6bc0e850398b7555499a4667d1cc3d8ba2328c1fb926204a794a5a7
|
|
4
|
+
data.tar.gz: e7208b7bf518332040837498b5de7d1e5e6c761a276d6fb732d14133d38d8c74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d19b85e0a4398332a0161f75bc561b79c6ebf12546fe21013b12f2b7f5ff931179fcb8d5610faccd4d84063bf10f4297bd1688df04c24e41ffb63d4ff38b851
|
|
7
|
+
data.tar.gz: e7b4f3a2164cc9f6e9545e123fe4aeabac356ab66becfc94466f8f25d54329ed5af4056339cfda1c71e20bcba1c4de30a9922ff3b7b5664528bde515834e19f1
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.1] - 2026-04-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `provider_response_id` persistence, parser extraction, and Data Quality coverage for provider-issued response object IDs.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Simplified dashboard helpers, filter normalization, and view templates without changing dashboard behavior.
|
|
16
|
+
- Split `PriceSync` internals into smaller components and removed redundant internal wrapper layers.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Removed inline dashboard JavaScript to keep the engine server-rendered.
|
|
21
|
+
- Reset ActiveRecord model column information in storage specs to avoid stale schema state across recreated tables.
|
|
22
|
+
|
|
7
23
|
## [0.3.0] - 2026-04-22
|
|
8
24
|
|
|
9
25
|
### Added
|
data/README.md
CHANGED
|
@@ -78,6 +78,8 @@ Anthropic emits usage in `message_start` + `message_delta` events. Gemini's `:st
|
|
|
78
78
|
|
|
79
79
|
Streamed calls are stored with `stream: true` and `usage_source: "stream_final"`. If the provider never sends final usage, the call is still recorded with `usage_source: "unknown"` so those calls surface on the Data Quality page.
|
|
80
80
|
|
|
81
|
+
When the provider emits a stable response object ID, LLM Cost Tracker stores it as `provider_response_id`. OpenAI and Anthropic are covered end-to-end; Gemini is best effort and may vary by endpoint or API version.
|
|
82
|
+
|
|
81
83
|
For non-Faraday clients (raw `Net::HTTP`, custom SSE code, Azure OpenAI), use the explicit helper:
|
|
82
84
|
|
|
83
85
|
```ruby
|
|
@@ -92,7 +94,16 @@ LlmCostTracker.track_stream(provider: "openai", model: "gpt-4o") do |stream|
|
|
|
92
94
|
end
|
|
93
95
|
```
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
If your custom streaming client exposes the provider's response object ID after the stream starts, set it explicitly:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
LlmCostTracker.track_stream(provider: "anthropic", model: "claude-sonnet-4-6") do |stream|
|
|
101
|
+
stream.provider_response_id = response.id
|
|
102
|
+
stream.usage(input_tokens: 120, output_tokens: 45)
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Run `bin/rails g llm_cost_tracker:add_streaming` once on existing installs to add the `stream` and `usage_source` columns. Run `bin/rails g llm_cost_tracker:add_provider_response_id` to persist provider-issued response IDs.
|
|
96
107
|
|
|
97
108
|
### Manual tracking
|
|
98
109
|
|
|
@@ -102,6 +113,7 @@ LlmCostTracker.track(
|
|
|
102
113
|
model: "claude-sonnet-4-6",
|
|
103
114
|
input_tokens: 1500,
|
|
104
115
|
output_tokens: 320,
|
|
116
|
+
provider_response_id: "msg_01XFDUDYJgAACzvnptvVoYEL",
|
|
105
117
|
cache_read_input_tokens: 1200,
|
|
106
118
|
feature: "summarizer",
|
|
107
119
|
user_id: current_user.id
|
|
@@ -430,6 +442,7 @@ The gem is designed for multi-threaded hosts — Puma with `max_threads > 1` and
|
|
|
430
442
|
|
|
431
443
|
- `:block_requests` is a best-effort guardrail, not a hard cap. Concurrent workers can pass preflight simultaneously and collectively overshoot the budget. Use an external quota system if you need a transactional cap.
|
|
432
444
|
- Streaming capture relies on the provider emitting a final-usage event (OpenAI needs `stream_options: { include_usage: true }`); missing events are recorded with `usage_source: "unknown"` so they surface on the Data Quality page.
|
|
445
|
+
- `provider_response_id` is stored only when the provider exposes a stable response object ID. Missing IDs stay `nil` and surface on the Data Quality page.
|
|
433
446
|
- Anthropic cache TTL variants (1h vs 5min writes) not modeled separately.
|
|
434
447
|
- OpenAI reasoning tokens included in output totals; separate reasoning-token attribution not stored.
|
|
435
448
|
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
.lct-body { margin: 0; }
|
|
38
38
|
|
|
39
39
|
.lct-app {
|
|
40
40
|
background: var(--lct-bg);
|
|
@@ -183,7 +183,6 @@
|
|
|
183
183
|
|
|
184
184
|
.lct-stat-sub { color: var(--lct-muted); font-size: var(--fs-xs); margin: 4px 0 0; }
|
|
185
185
|
|
|
186
|
-
/* Shared "small uppercase-ish label" recipe */
|
|
187
186
|
.lct-stat-label,
|
|
188
187
|
.lct-field label,
|
|
189
188
|
.lct-dl dt,
|
|
@@ -201,7 +200,6 @@
|
|
|
201
200
|
.lct-chip-label { color: var(--lct-accent); font-weight: 700; }
|
|
202
201
|
.lct-field label { color: var(--lct-text); font-size: var(--fs-md); font-weight: 500; }
|
|
203
202
|
|
|
204
|
-
/* Shared "muted body copy" recipe */
|
|
205
203
|
.lct-section-copy,
|
|
206
204
|
.lct-stat-copy,
|
|
207
205
|
.lct-banner-copy,
|
|
@@ -285,7 +283,6 @@
|
|
|
285
283
|
.lct-calls-table td:last-child,
|
|
286
284
|
.lct-calls-table th:last-child { text-align: right; }
|
|
287
285
|
|
|
288
|
-
/* Track + fill primitives — shared by bar / budget / stack */
|
|
289
286
|
.lct-bar-track,
|
|
290
287
|
.lct-budget-track,
|
|
291
288
|
.lct-stack-track {
|
|
@@ -6,6 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
class CallsController < ApplicationController
|
|
7
7
|
CSV_EXPORT_LIMIT = 10_000
|
|
8
8
|
CSV_FORMULA_PREFIXES = ["=", "+", "-", "@", "\t", "\r"].freeze
|
|
9
|
+
DEFAULT_ORDER = "tracked_at DESC, id DESC"
|
|
9
10
|
|
|
10
11
|
def index
|
|
11
12
|
@sort = params[:sort].to_s
|
|
@@ -30,9 +31,6 @@ module LlmCostTracker
|
|
|
30
31
|
|
|
31
32
|
def show
|
|
32
33
|
@call = LlmApiCall.find(params[:id])
|
|
33
|
-
@tags = @call.parsed_tags
|
|
34
|
-
@metadata_available = @call.has_attribute?("metadata")
|
|
35
|
-
@metadata = @call.read_attribute("metadata") if @metadata_available
|
|
36
34
|
@latency_available = LlmApiCall.latency_column?
|
|
37
35
|
end
|
|
38
36
|
|
|
@@ -41,29 +39,26 @@ module LlmCostTracker
|
|
|
41
39
|
def calls_order(sort)
|
|
42
40
|
case sort
|
|
43
41
|
when "expensive"
|
|
44
|
-
"CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{
|
|
42
|
+
"CASE WHEN total_cost IS NULL THEN 1 ELSE 0 END ASC, total_cost DESC, #{DEFAULT_ORDER}"
|
|
45
43
|
when "input"
|
|
46
|
-
"input_tokens DESC, #{
|
|
44
|
+
"input_tokens DESC, #{DEFAULT_ORDER}"
|
|
47
45
|
when "output"
|
|
48
|
-
"output_tokens DESC, #{
|
|
46
|
+
"output_tokens DESC, #{DEFAULT_ORDER}"
|
|
49
47
|
when "slow"
|
|
50
|
-
return
|
|
48
|
+
return DEFAULT_ORDER unless LlmApiCall.latency_column?
|
|
51
49
|
|
|
52
|
-
"CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{
|
|
50
|
+
"CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
|
|
53
51
|
else
|
|
54
|
-
|
|
52
|
+
DEFAULT_ORDER
|
|
55
53
|
end
|
|
56
54
|
end
|
|
57
55
|
|
|
58
|
-
def default_order
|
|
59
|
-
"tracked_at DESC, id DESC"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
56
|
def render_csv(relation)
|
|
63
57
|
latency = LlmApiCall.latency_column?
|
|
64
58
|
CSV.generate do |csv|
|
|
65
59
|
headers = %w[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
|
|
66
60
|
headers << "latency_ms" if latency
|
|
61
|
+
headers << "provider_response_id" if LlmApiCall.provider_response_id_column?
|
|
67
62
|
headers << "tags"
|
|
68
63
|
csv << headers
|
|
69
64
|
|
|
@@ -78,6 +73,7 @@ module LlmCostTracker
|
|
|
78
73
|
call.total_cost
|
|
79
74
|
]
|
|
80
75
|
row << call.latency_ms if latency
|
|
76
|
+
row << csv_safe(call.provider_response_id) if LlmApiCall.provider_response_id_column?
|
|
81
77
|
row << csv_safe(call.parsed_tags.to_json)
|
|
82
78
|
csv << row
|
|
83
79
|
end
|
|
@@ -5,15 +5,19 @@ module LlmCostTracker
|
|
|
5
5
|
def index
|
|
6
6
|
@from_date, @to_date = overview_range
|
|
7
7
|
prev_from, prev_to = previous_range
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
filter_params = LlmCostTracker::ParameterHash.to_hash(params)
|
|
9
|
+
scope = Dashboard::Filter.call(
|
|
10
|
+
params: filter_params.merge("from" => @from_date.iso8601, "to" => @to_date.iso8601)
|
|
11
|
+
)
|
|
12
|
+
previous_scope = Dashboard::Filter.call(
|
|
13
|
+
params: filter_params.merge("from" => prev_from.iso8601, "to" => prev_to.iso8601)
|
|
14
|
+
)
|
|
11
15
|
|
|
12
16
|
@stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
|
|
13
17
|
@time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
|
|
14
18
|
@comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
|
|
15
19
|
@spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
|
|
16
|
-
@top_models =
|
|
20
|
+
@top_models = Dashboard::TopModels.call(scope: scope)
|
|
17
21
|
@providers = Dashboard::ProviderBreakdown.call(scope: scope)
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -32,21 +36,6 @@ module LlmCostTracker
|
|
|
32
36
|
[prev_from, prev_to]
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
def overview_filter_params
|
|
36
|
-
params.to_unsafe_h.merge(
|
|
37
|
-
"from" => @from_date.iso8601,
|
|
38
|
-
"to" => @to_date.iso8601
|
|
39
|
-
)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def previous_filter_params
|
|
43
|
-
prev_from, prev_to = previous_range
|
|
44
|
-
params.to_unsafe_h.merge(
|
|
45
|
-
"from" => prev_from.iso8601,
|
|
46
|
-
"to" => prev_to.iso8601
|
|
47
|
-
)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
39
|
def parsed_date(value)
|
|
51
40
|
return nil if value.to_s.strip.empty?
|
|
52
41
|
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class DataQualityController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
|
|
7
|
-
@stats = Dashboard::DataQuality.call(scope: scope)
|
|
6
|
+
@stats = Dashboard::DataQuality.call(scope: Dashboard::Filter.call(params: params))
|
|
8
7
|
end
|
|
9
8
|
end
|
|
10
9
|
end
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class ModelsController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
scope = Dashboard::Filter.call(params: params)
|
|
7
6
|
@sort = params[:sort].to_s
|
|
8
|
-
@rows = Dashboard::TopModels.call(
|
|
7
|
+
@rows = Dashboard::TopModels.call(
|
|
8
|
+
scope: Dashboard::Filter.call(params: params),
|
|
9
|
+
limit: nil,
|
|
10
|
+
sort: @sort
|
|
11
|
+
)
|
|
9
12
|
@latency_available = LlmApiCall.latency_column?
|
|
10
13
|
end
|
|
11
14
|
end
|
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class TagsController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
|
|
7
|
-
@rows = Dashboard::TagKeyExplorer.call(scope: scope)
|
|
6
|
+
@rows = Dashboard::TagKeyExplorer.call(scope: Dashboard::Filter.call(params: params))
|
|
8
7
|
end
|
|
9
8
|
|
|
10
9
|
def show
|
|
11
10
|
@tag_key = params[:key]
|
|
12
|
-
|
|
13
|
-
@rows = Dashboard::TagBreakdown.call(scope: scope, key: @tag_key)
|
|
11
|
+
@rows = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
|
|
14
12
|
@total_calls = @rows.sum(&:calls)
|
|
15
13
|
|
|
16
14
|
tagged_rows = @rows.reject { |r| r.value == "(untagged)" }
|
|
@@ -13,7 +13,7 @@ module LlmCostTracker
|
|
|
13
13
|
private
|
|
14
14
|
|
|
15
15
|
def filter_options_for(column, filter_params:)
|
|
16
|
-
source =
|
|
16
|
+
source = LlmCostTracker::ParameterHash.to_hash(filter_params)
|
|
17
17
|
scope_params = source.stringify_keys.merge(
|
|
18
18
|
column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
|
|
19
19
|
)
|
|
@@ -24,11 +24,5 @@ module LlmCostTracker
|
|
|
24
24
|
values.unshift(current) if current && !values.include?(current)
|
|
25
25
|
values
|
|
26
26
|
end
|
|
27
|
-
|
|
28
|
-
def filter_source_hash(filter_params)
|
|
29
|
-
return filter_params.to_unsafe_h if filter_params.respond_to?(:to_unsafe_h)
|
|
30
|
-
|
|
31
|
-
filter_params.to_h
|
|
32
|
-
end
|
|
33
27
|
end
|
|
34
28
|
end
|
|
@@ -19,18 +19,14 @@ module LlmCostTracker
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
21
|
def normalized_query_tags(tags)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
tags = tags.to_unsafe_h if tags.respond_to?(:to_unsafe_h)
|
|
25
|
-
tags = tags.to_h if tags.respond_to?(:to_h)
|
|
26
|
-
return {} unless tags.is_a?(Hash)
|
|
27
|
-
|
|
28
|
-
tags.transform_keys(&:to_s).transform_values(&:to_s)
|
|
22
|
+
LlmCostTracker::ParameterHash.to_hash(tags).transform_keys(&:to_s).transform_values(&:to_s)
|
|
29
23
|
end
|
|
30
24
|
|
|
31
25
|
def clean_dashboard_query(value)
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
if LlmCostTracker::ParameterHash.hash_like?(value)
|
|
27
|
+
return clean_dashboard_hash(LlmCostTracker::ParameterHash.to_hash(value))
|
|
28
|
+
end
|
|
29
|
+
|
|
34
30
|
return clean_dashboard_array(value) if value.is_a?(Array)
|
|
35
31
|
return clean_dashboard_string(value) if value.is_a?(String)
|
|
36
32
|
|
|
@@ -11,6 +11,8 @@ module LlmCostTracker
|
|
|
11
11
|
:streaming_count,
|
|
12
12
|
:streaming_missing_usage_count,
|
|
13
13
|
:stream_column_present,
|
|
14
|
+
:missing_provider_response_id_count,
|
|
15
|
+
:provider_response_id_column_present,
|
|
14
16
|
:unknown_pricing_by_model
|
|
15
17
|
)
|
|
16
18
|
|
|
@@ -20,6 +22,7 @@ module LlmCostTracker
|
|
|
20
22
|
total = scope.count
|
|
21
23
|
latency_present = LlmCostTracker::LlmApiCall.latency_column?
|
|
22
24
|
stream_present = LlmCostTracker::LlmApiCall.stream_column?
|
|
25
|
+
provider_response_id_present = LlmCostTracker::LlmApiCall.provider_response_id_column?
|
|
23
26
|
|
|
24
27
|
DataQualityStats.new(
|
|
25
28
|
total_calls: total,
|
|
@@ -28,8 +31,14 @@ module LlmCostTracker
|
|
|
28
31
|
missing_latency_count: latency_present ? scope.where(latency_ms: nil).count : nil,
|
|
29
32
|
latency_column_present: latency_present,
|
|
30
33
|
streaming_count: stream_present ? scope.streaming.count : nil,
|
|
31
|
-
streaming_missing_usage_count:
|
|
34
|
+
streaming_missing_usage_count: if stream_present && LlmCostTracker::LlmApiCall.usage_source_column?
|
|
35
|
+
scope.streaming_missing_usage.count
|
|
36
|
+
end,
|
|
32
37
|
stream_column_present: stream_present,
|
|
38
|
+
missing_provider_response_id_count: (
|
|
39
|
+
provider_response_id_present ? scope.missing_provider_response_id.count : nil
|
|
40
|
+
),
|
|
41
|
+
provider_response_id_column_present: provider_response_id_present,
|
|
33
42
|
unknown_pricing_by_model: scope.unknown_pricing
|
|
34
43
|
.group(:model)
|
|
35
44
|
.order(Arel.sql("COUNT(*) DESC"))
|
|
@@ -38,15 +47,6 @@ module LlmCostTracker
|
|
|
38
47
|
.to_h
|
|
39
48
|
)
|
|
40
49
|
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
def streaming_missing_usage_count(scope, stream_present)
|
|
45
|
-
return nil unless stream_present
|
|
46
|
-
return nil unless LlmCostTracker::LlmApiCall.usage_source_column?
|
|
47
|
-
|
|
48
|
-
scope.streaming_missing_usage.count
|
|
49
|
-
end
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
end
|
|
@@ -4,10 +4,6 @@ require "date"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Dashboard
|
|
7
|
-
# Parses dashboard params into an ActiveRecord relation.
|
|
8
|
-
#
|
|
9
|
-
# Invalid dates are ignored, pagination is handled elsewhere, and invalid
|
|
10
|
-
# tag keys raise InvalidFilterError so controllers can fail closed with HTTP 400.
|
|
11
7
|
class Filter
|
|
12
8
|
class << self
|
|
13
9
|
def call(scope: LlmCostTracker::LlmApiCall.all, params: {})
|
|
@@ -17,7 +13,7 @@ module LlmCostTracker
|
|
|
17
13
|
|
|
18
14
|
def initialize(scope:, params:)
|
|
19
15
|
@scope = scope
|
|
20
|
-
@params =
|
|
16
|
+
@params = LlmCostTracker::ParameterHash.with_indifferent_access(params)
|
|
21
17
|
end
|
|
22
18
|
|
|
23
19
|
def relation
|
|
@@ -34,15 +30,6 @@ module LlmCostTracker
|
|
|
34
30
|
|
|
35
31
|
attr_reader :scope, :params
|
|
36
32
|
|
|
37
|
-
def normalize_params(params)
|
|
38
|
-
return {}.with_indifferent_access if params.nil?
|
|
39
|
-
|
|
40
|
-
raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
41
|
-
raw.with_indifferent_access
|
|
42
|
-
rescue NoMethodError
|
|
43
|
-
{}.with_indifferent_access
|
|
44
|
-
end
|
|
45
|
-
|
|
46
33
|
def apply_date_filters(relation)
|
|
47
34
|
from = parse_date(:from)&.beginning_of_day
|
|
48
35
|
to = parse_date(:to)&.end_of_day
|
|
@@ -53,7 +40,7 @@ module LlmCostTracker
|
|
|
53
40
|
end
|
|
54
41
|
|
|
55
42
|
def apply_exact_filter(relation, key)
|
|
56
|
-
value =
|
|
43
|
+
value = normalized_string(params[key])
|
|
57
44
|
return relation if value.nil?
|
|
58
45
|
|
|
59
46
|
relation.where(key => value)
|
|
@@ -67,7 +54,7 @@ module LlmCostTracker
|
|
|
67
54
|
end
|
|
68
55
|
|
|
69
56
|
def apply_stream_filter(relation)
|
|
70
|
-
value =
|
|
57
|
+
value = normalized_string(params[:stream])
|
|
71
58
|
return relation if value.nil?
|
|
72
59
|
return relation unless relation.klass.stream_column?
|
|
73
60
|
|
|
@@ -79,7 +66,7 @@ module LlmCostTracker
|
|
|
79
66
|
end
|
|
80
67
|
|
|
81
68
|
def apply_usage_source_filter(relation)
|
|
82
|
-
value =
|
|
69
|
+
value = normalized_string(params[:usage_source])
|
|
83
70
|
return relation if value.nil?
|
|
84
71
|
return relation unless relation.klass.usage_source_column?
|
|
85
72
|
|
|
@@ -98,14 +85,11 @@ module LlmCostTracker
|
|
|
98
85
|
end
|
|
99
86
|
|
|
100
87
|
def hash_param(key)
|
|
101
|
-
|
|
102
|
-
raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
|
|
103
|
-
raw = raw.to_h if raw.respond_to?(:to_h)
|
|
104
|
-
raw.is_a?(Hash) ? raw : {}
|
|
88
|
+
LlmCostTracker::ParameterHash.to_hash(params[key])
|
|
105
89
|
end
|
|
106
90
|
|
|
107
91
|
def parse_date(key)
|
|
108
|
-
value =
|
|
92
|
+
value = normalized_string(params[key])
|
|
109
93
|
return nil if value.nil?
|
|
110
94
|
|
|
111
95
|
Date.iso8601(value)
|
|
@@ -113,10 +97,6 @@ module LlmCostTracker
|
|
|
113
97
|
nil
|
|
114
98
|
end
|
|
115
99
|
|
|
116
|
-
def string_param(key)
|
|
117
|
-
normalized_string(params[key])
|
|
118
|
-
end
|
|
119
|
-
|
|
120
100
|
def normalized_string(value)
|
|
121
101
|
return nil if value.nil?
|
|
122
102
|
|
|
@@ -4,9 +4,6 @@ module LlmCostTracker
|
|
|
4
4
|
module Dashboard
|
|
5
5
|
ProviderRow = Data.define(:provider, :calls, :total_cost, :share_percent)
|
|
6
6
|
|
|
7
|
-
# Aggregates cost and call counts per provider for a given scope.
|
|
8
|
-
# Sorted by total cost descending; providers with zero cost fall to the bottom
|
|
9
|
-
# but are still returned so users can see calls without pricing.
|
|
10
7
|
class ProviderBreakdown
|
|
11
8
|
def self.call(scope: LlmCostTracker::LlmApiCall.all)
|
|
12
9
|
new(scope: scope).rows
|
|
@@ -9,8 +9,6 @@ module LlmCostTracker
|
|
|
9
9
|
:average_cost_per_call
|
|
10
10
|
)
|
|
11
11
|
|
|
12
|
-
# Aggregates calls grouped by the distinct values of a single tag key.
|
|
13
|
-
# Invalid keys raise InvalidFilterError so controllers can return HTTP 400.
|
|
14
12
|
class TagBreakdown
|
|
15
13
|
class << self
|
|
16
14
|
def call(key:, scope: LlmCostTracker::LlmApiCall.all)
|
|
@@ -9,21 +9,13 @@ module LlmCostTracker
|
|
|
9
9
|
attr_reader :page, :per
|
|
10
10
|
|
|
11
11
|
def self.call(params)
|
|
12
|
-
params =
|
|
12
|
+
params = LlmCostTracker::ParameterHash.with_indifferent_access(params)
|
|
13
13
|
new(
|
|
14
14
|
page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
|
|
15
15
|
per: integer_param(params, :per, default: DEFAULT_PER, min: 1, max: MAX_PER)
|
|
16
16
|
)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def self.normalize_params(params)
|
|
20
|
-
return {}.with_indifferent_access if params.nil?
|
|
21
|
-
|
|
22
|
-
raw = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
23
|
-
raw.with_indifferent_access
|
|
24
|
-
end
|
|
25
|
-
private_class_method :normalize_params
|
|
26
|
-
|
|
27
19
|
def self.integer_param(params, key, default:, min:, max: nil)
|
|
28
20
|
value = Integer(params[key], 10)
|
|
29
21
|
value = [value, min].max
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>LLM Cost Tracker</title>
|
|
7
7
|
<%= stylesheet_link_tag stylesheet_path %>
|
|
8
8
|
</head>
|
|
9
|
-
<body>
|
|
9
|
+
<body class="lct-body">
|
|
10
10
|
<div class="lct-app">
|
|
11
11
|
<main class="lct-shell">
|
|
12
12
|
<header class="lct-header">
|
|
@@ -25,20 +25,5 @@
|
|
|
25
25
|
<%= yield %>
|
|
26
26
|
</main>
|
|
27
27
|
</div>
|
|
28
|
-
<script>
|
|
29
|
-
document.addEventListener("keydown", function(event) {
|
|
30
|
-
if (event.key !== "/" || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
31
|
-
|
|
32
|
-
var target = event.target;
|
|
33
|
-
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
|
34
|
-
|
|
35
|
-
var input = document.querySelector("[data-lct-filter-input]");
|
|
36
|
-
if (!input) return;
|
|
37
|
-
|
|
38
|
-
event.preventDefault();
|
|
39
|
-
input.focus();
|
|
40
|
-
if (typeof input.select === "function") input.select();
|
|
41
|
-
});
|
|
42
|
-
</script>
|
|
43
28
|
</body>
|
|
44
29
|
</html>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<div class="lct-filter-row lct-filter-row-with-sort">
|
|
11
11
|
<div class="lct-field">
|
|
12
12
|
<label for="lct-from">From</label>
|
|
13
|
-
<input id="lct-from"
|
|
13
|
+
<input id="lct-from" type="date" name="from" value="<%= params[:from] %>">
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
16
|
<div class="lct-field">
|
|
@@ -46,16 +46,17 @@
|
|
|
46
46
|
|
|
47
47
|
<div class="lct-field">
|
|
48
48
|
<label for="lct-sort">Sort</label>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
49
|
+
<%= select_tag :sort,
|
|
50
|
+
options_for_select(
|
|
51
|
+
[["Recent first", ""],
|
|
52
|
+
["Most expensive", "expensive"],
|
|
53
|
+
["Largest input", "input"],
|
|
54
|
+
["Largest output", "output"]] +
|
|
55
|
+
(@latency_available ? [["Slowest", "slow"]] : []) +
|
|
56
|
+
[["Unknown pricing only", "unknown_pricing"]],
|
|
57
|
+
@sort
|
|
58
|
+
),
|
|
59
|
+
id: "lct-sort" %>
|
|
59
60
|
</div>
|
|
60
61
|
|
|
61
62
|
<div class="lct-filter-actions">
|
|
@@ -112,7 +113,6 @@
|
|
|
112
113
|
</thead>
|
|
113
114
|
<tbody>
|
|
114
115
|
<% @calls.each do |call| %>
|
|
115
|
-
<% tags = call.parsed_tags %>
|
|
116
116
|
<tr>
|
|
117
117
|
<td class="lct-nowrap"><%= format_date(call.tracked_at) %></td>
|
|
118
118
|
<td><%= call.provider %></td>
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
<% if @latency_available %>
|
|
125
125
|
<td class="lct-num<%= ' lct-num-muted' if call.latency_ms.nil? %>"><%= call.latency_ms ? "#{number(call.latency_ms)}ms" : "n/a" %></td>
|
|
126
126
|
<% end %>
|
|
127
|
-
<td><%= render "llm_cost_tracker/shared/tag_chips", tags:
|
|
127
|
+
<td><%= render "llm_cost_tracker/shared/tag_chips", tags: call.parsed_tags %></td>
|
|
128
128
|
<td><%= link_to "Details", call_path(call), class: "lct-button lct-button-secondary lct-button-compact" %></td>
|
|
129
129
|
</tr>
|
|
130
130
|
<% end %>
|
|
@@ -73,6 +73,11 @@
|
|
|
73
73
|
<dt>Pricing Status</dt>
|
|
74
74
|
<dd><%= pricing_status(@call) %></dd>
|
|
75
75
|
|
|
76
|
+
<% if LlmCostTracker::LlmApiCall.provider_response_id_column? %>
|
|
77
|
+
<dt>Provider Response ID</dt>
|
|
78
|
+
<dd><%= @call.provider_response_id.presence || "n/a" %></dd>
|
|
79
|
+
<% end %>
|
|
80
|
+
|
|
76
81
|
<% if @call.has_attribute?("created_at") %>
|
|
77
82
|
<dt>Created At</dt>
|
|
78
83
|
<dd><%= format_date(@call.created_at) %></dd>
|
|
@@ -113,12 +118,12 @@
|
|
|
113
118
|
|
|
114
119
|
<section class="lct-panel">
|
|
115
120
|
<h2 class="lct-section-title">Tags</h2>
|
|
116
|
-
<pre class="lct-pre"><%= safe_json(@
|
|
121
|
+
<pre class="lct-pre"><%= safe_json(@call.parsed_tags) %></pre>
|
|
117
122
|
</section>
|
|
118
123
|
|
|
119
|
-
<% if @
|
|
124
|
+
<% if @call.has_attribute?("metadata") %>
|
|
120
125
|
<section class="lct-panel">
|
|
121
126
|
<h2 class="lct-section-title">Metadata</h2>
|
|
122
|
-
<pre class="lct-pre"><%= safe_json(@metadata) %></pre>
|
|
127
|
+
<pre class="lct-pre"><%= safe_json(@call.read_attribute("metadata")) %></pre>
|
|
123
128
|
</section>
|
|
124
129
|
<% end %>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<div class="lct-filter-row lct-filter-row-basic">
|
|
6
6
|
<div class="lct-field">
|
|
7
7
|
<label for="lct-overview-from">From</label>
|
|
8
|
-
<input id="lct-overview-from"
|
|
8
|
+
<input id="lct-overview-from" type="date" name="from" value="<%= params[:from] || @from_date.iso8601 %>">
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
11
|
<div class="lct-field">
|