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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +14 -1
  4. data/app/assets/llm_cost_tracker/application.css +1 -4
  5. data/app/controllers/llm_cost_tracker/calls_controller.rb +9 -13
  6. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +8 -19
  7. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +1 -2
  8. data/app/controllers/llm_cost_tracker/models_controller.rb +5 -2
  9. data/app/controllers/llm_cost_tracker/tags_controller.rb +2 -4
  10. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -7
  11. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +5 -9
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +10 -10
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -26
  14. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +0 -3
  15. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +0 -2
  16. data/app/services/llm_cost_tracker/pagination.rb +1 -9
  17. data/app/views/layouts/llm_cost_tracker/application.html.erb +1 -16
  18. data/app/views/llm_cost_tracker/calls/index.html.erb +13 -13
  19. data/app/views/llm_cost_tracker/calls/show.html.erb +8 -3
  20. data/app/views/llm_cost_tracker/dashboard/index.html.erb +1 -1
  21. data/app/views/llm_cost_tracker/data_quality/index.html.erb +36 -14
  22. data/app/views/llm_cost_tracker/models/index.html.erb +10 -9
  23. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +0 -1
  24. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +0 -1
  25. data/app/views/llm_cost_tracker/tags/index.html.erb +1 -1
  26. data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
  27. data/lib/llm_cost_tracker/configuration.rb +0 -1
  28. data/lib/llm_cost_tracker/event.rb +1 -0
  29. data/lib/llm_cost_tracker/event_metadata.rb +1 -0
  30. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_provider_response_id_generator.rb +29 -0
  31. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +15 -0
  32. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +2 -0
  33. data/lib/llm_cost_tracker/llm_api_call.rb +6 -2
  34. data/lib/llm_cost_tracker/middleware/faraday.rb +1 -0
  35. data/lib/llm_cost_tracker/parameter_hash.rb +33 -0
  36. data/lib/llm_cost_tracker/parsed_usage.rb +14 -3
  37. data/lib/llm_cost_tracker/parsers/anthropic.rb +47 -28
  38. data/lib/llm_cost_tracker/parsers/gemini.rb +28 -4
  39. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -6
  40. data/lib/llm_cost_tracker/parsers/openai_usage.rb +14 -0
  41. data/lib/llm_cost_tracker/price_registry.rb +22 -7
  42. data/lib/llm_cost_tracker/price_sync/refresh_plan_builder.rb +162 -0
  43. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +55 -0
  44. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +25 -0
  45. data/lib/llm_cost_tracker/price_sync.rb +16 -184
  46. data/lib/llm_cost_tracker/pricing.rb +0 -11
  47. data/lib/llm_cost_tracker/railtie.rb +0 -1
  48. data/lib/llm_cost_tracker/report.rb +0 -5
  49. data/lib/llm_cost_tracker/storage/active_record_store.rb +10 -11
  50. data/lib/llm_cost_tracker/stream_collector.rb +17 -13
  51. data/lib/llm_cost_tracker/tags_column.rb +4 -0
  52. data/lib/llm_cost_tracker/tracker.rb +10 -2
  53. data/lib/llm_cost_tracker/version.rb +1 -1
  54. data/lib/llm_cost_tracker.rb +6 -14
  55. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b20da957651521f022866af9d4735a4ef53d52a2dc3c278b8b2a90e1d7a7f98
4
- data.tar.gz: ea98b2a7505d99c5f78d7756d0adc50224c4fdc88000fa5ec81be4450c9200f1
3
+ metadata.gz: 93ef8bc5c6bc0e850398b7555499a4667d1cc3d8ba2328c1fb926204a794a5a7
4
+ data.tar.gz: e7208b7bf518332040837498b5de7d1e5e6c761a276d6fb732d14133d38d8c74
5
5
  SHA512:
6
- metadata.gz: 9ca709080d46395ac32b9a2931b4b3cb7d4df6016b73bad3579cb1decdd046be21a2fb67c06e96876013a754a113e9ce5987ed0e27792b312716324bdb5f9adb
7
- data.tar.gz: 445b77222180802f208246a2e25b30e5e0a5679d2d5b84a2ba00d1e2fc97a5cf3127521be13f415c6d76bbcc056dd0bdfe6ade937eb9d67d737ce6b6548665fa
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
- Run `bin/rails g llm_cost_tracker:add_streaming` once on existing installs to add the `stream` and `usage_source` columns.
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
- body:has(> .lct-app) { margin: 0; }
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, #{default_order}"
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, #{default_order}"
44
+ "input_tokens DESC, #{DEFAULT_ORDER}"
47
45
  when "output"
48
- "output_tokens DESC, #{default_order}"
46
+ "output_tokens DESC, #{DEFAULT_ORDER}"
49
47
  when "slow"
50
- return default_order unless LlmApiCall.latency_column?
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, #{default_order}"
50
+ "CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
53
51
  else
54
- default_order
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
- scope = Dashboard::Filter.call(params: overview_filter_params)
9
- previous_scope = Dashboard::Filter.call(params: previous_filter_params)
10
- model_rows = Dashboard::TopModels.call(scope: scope, limit: 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 = model_rows.first(5)
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
- scope = Dashboard::Filter.call(params: params)
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(scope: scope, limit: nil, sort: @sort)
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
- scope = Dashboard::Filter.call(params: params)
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
- scope = Dashboard::Filter.call(params: params)
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 = filter_source_hash(filter_params)
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
- return {} unless tags
23
-
24
- tags = tags.to_unsafe_h if tags.respond_to?(:to_unsafe_h)
25
- tags = tags.to_h if tags.respond_to?(:to_h)
26
- return {} unless tags.is_a?(Hash)
27
-
28
- tags.transform_keys(&:to_s).transform_values(&:to_s)
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
- return clean_dashboard_hash(value.to_unsafe_h) if value.is_a?(ActionController::Parameters)
33
- return clean_dashboard_hash(value) if value.is_a?(Hash)
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: streaming_missing_usage_count(scope, stream_present),
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 = normalize_params(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 = string_param(key)
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 = string_param(:stream)
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 = string_param(:usage_source)
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
- raw = params[key]
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 = string_param(key)
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 = normalize_params(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" data-lct-filter-input type="date" name="from" value="<%= params[: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
- <select id="lct-sort" name="sort">
50
- <option value="" <%= "selected" if @sort.blank? %>>Recent first</option>
51
- <option value="expensive" <%= "selected" if @sort == "expensive" %>>Most expensive</option>
52
- <option value="input" <%= "selected" if @sort == "input" %>>Largest input</option>
53
- <option value="output" <%= "selected" if @sort == "output" %>>Largest output</option>
54
- <% if @latency_available %>
55
- <option value="slow" <%= "selected" if @sort == "slow" %>>Slowest</option>
56
- <% end %>
57
- <option value="unknown_pricing" <%= "selected" if @sort == "unknown_pricing" %>>Unknown pricing only</option>
58
- </select>
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: tags %></td>
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(@tags) %></pre>
121
+ <pre class="lct-pre"><%= safe_json(@call.parsed_tags) %></pre>
117
122
  </section>
118
123
 
119
- <% if @metadata_available %>
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" data-lct-filter-input type="date" name="from" value="<%= params[:from] || @from_date.iso8601 %>">
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">