llm_cost_tracker 0.1.4 → 0.2.0.alpha2

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -91
  3. data/PLAN_0.2.md +488 -0
  4. data/README.md +140 -320
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +42 -0
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +77 -0
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +54 -0
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -0
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +12 -0
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -0
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +113 -0
  12. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +38 -0
  13. data/app/services/llm_cost_tracker/dashboard/filter.rb +109 -0
  14. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +87 -0
  15. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +44 -0
  16. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +58 -0
  17. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +125 -0
  18. data/app/services/llm_cost_tracker/dashboard/time_series.rb +44 -0
  19. data/app/services/llm_cost_tracker/dashboard/top_models.rb +89 -0
  20. data/app/services/llm_cost_tracker/pagination.rb +59 -0
  21. data/app/views/layouts/llm_cost_tracker/application.html.erb +342 -0
  22. data/app/views/llm_cost_tracker/calls/index.html.erb +127 -0
  23. data/app/views/llm_cost_tracker/calls/show.html.erb +67 -0
  24. data/app/views/llm_cost_tracker/dashboard/index.html.erb +145 -0
  25. data/app/views/llm_cost_tracker/data_quality/index.html.erb +110 -0
  26. data/app/views/llm_cost_tracker/errors/database.html.erb +8 -0
  27. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +4 -0
  28. data/app/views/llm_cost_tracker/errors/not_found.html.erb +5 -0
  29. data/app/views/llm_cost_tracker/models/index.html.erb +95 -0
  30. data/app/views/llm_cost_tracker/shared/_bar.html.erb +5 -0
  31. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +6 -0
  32. data/app/views/llm_cost_tracker/tags/index.html.erb +34 -0
  33. data/app/views/llm_cost_tracker/tags/show.html.erb +69 -0
  34. data/config/routes.rb +10 -0
  35. data/lib/llm_cost_tracker/budget.rb +16 -38
  36. data/lib/llm_cost_tracker/configuration.rb +3 -1
  37. data/lib/llm_cost_tracker/cost.rb +1 -3
  38. data/lib/llm_cost_tracker/engine.rb +13 -0
  39. data/lib/llm_cost_tracker/engine_compatibility.rb +15 -0
  40. data/lib/llm_cost_tracker/errors.rb +2 -0
  41. data/lib/llm_cost_tracker/event.rb +1 -3
  42. data/lib/llm_cost_tracker/event_metadata.rb +9 -18
  43. data/lib/llm_cost_tracker/llm_api_call.rb +4 -17
  44. data/lib/llm_cost_tracker/middleware/faraday.rb +4 -4
  45. data/lib/llm_cost_tracker/parsed_usage.rb +5 -9
  46. data/lib/llm_cost_tracker/parsers/anthropic.rb +4 -5
  47. data/lib/llm_cost_tracker/parsers/base.rb +3 -8
  48. data/lib/llm_cost_tracker/parsers/gemini.rb +3 -3
  49. data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
  50. data/lib/llm_cost_tracker/parsers/registry.rb +5 -12
  51. data/lib/llm_cost_tracker/period_grouping.rb +68 -0
  52. data/lib/llm_cost_tracker/price_registry.rb +22 -30
  53. data/lib/llm_cost_tracker/pricing.rb +10 -19
  54. data/lib/llm_cost_tracker/report.rb +4 -4
  55. data/lib/llm_cost_tracker/report_data.rb +21 -24
  56. data/lib/llm_cost_tracker/report_formatter.rb +4 -2
  57. data/lib/llm_cost_tracker/storage/active_record_store.rb +1 -3
  58. data/lib/llm_cost_tracker/tag_key.rb +16 -0
  59. data/lib/llm_cost_tracker/tracker.rb +35 -1
  60. data/lib/llm_cost_tracker/version.rb +1 -1
  61. data/lib/llm_cost_tracker.rb +3 -6
  62. data/llm_cost_tracker.gemspec +13 -9
  63. metadata +91 -20
  64. data/.rubocop.yml +0 -44
  65. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -19
  66. data/lib/llm_cost_tracker/storage/backends.rb +0 -26
  67. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -16
  68. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -28
  69. data/lib/llm_cost_tracker/value_object.rb +0 -45
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LlmCostTracker
6
+ module Dashboard
7
+ TagKeyRow = Data.define(:key, :calls_count, :distinct_values)
8
+
9
+ class TagKeyExplorer
10
+ RUBY_FALLBACK_LIMIT = 50_000
11
+
12
+ class << self
13
+ def call(scope: LlmCostTracker::LlmApiCall.all)
14
+ new(scope: scope).rows
15
+ end
16
+ end
17
+
18
+ def initialize(scope:)
19
+ @scope = scope
20
+ @connection = LlmCostTracker::LlmApiCall.connection
21
+ end
22
+
23
+ def rows
24
+ sql = build_sql
25
+ return ruby_fallback_rows if sql.nil?
26
+
27
+ results = @connection.select_all(sql).to_a
28
+ results.map do |row|
29
+ TagKeyRow.new(
30
+ key: row["key"].to_s,
31
+ calls_count: row["calls_count"].to_i,
32
+ distinct_values: row["distinct_values"].to_i
33
+ )
34
+ end
35
+ rescue StandardError => e
36
+ LlmCostTracker::Logging.warn("Tag key discovery failed (#{connection.adapter_name}): #{e.class}: #{e.message}")
37
+ []
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :scope, :connection
43
+
44
+ def subquery
45
+ scope.to_sql
46
+ end
47
+
48
+ def build_sql
49
+ case connection.adapter_name
50
+ when /postgres/i then postgresql_sql
51
+ when /mysql/i then nil
52
+ else sqlite_sql
53
+ end
54
+ end
55
+
56
+ def ruby_fallback_rows
57
+ calls_counter = Hash.new(0)
58
+ values_per_key = Hash.new { |h, k| h[k] = Set.new }
59
+
60
+ scope.limit(RUBY_FALLBACK_LIMIT).pluck(:tags).each do |raw|
61
+ tags = parse_tags(raw)
62
+ next if tags.empty?
63
+
64
+ tags.each do |key, value|
65
+ calls_counter[key] += 1
66
+ values_per_key[key] << value.to_s
67
+ end
68
+ end
69
+
70
+ calls_counter
71
+ .sort_by { |_, count| -count }
72
+ .map do |key, count|
73
+ TagKeyRow.new(key: key.to_s, calls_count: count, distinct_values: values_per_key[key].size)
74
+ end
75
+ rescue StandardError => e
76
+ LlmCostTracker::Logging.warn("Tag key Ruby fallback failed: #{e.class}: #{e.message}")
77
+ []
78
+ end
79
+
80
+ def parse_tags(raw)
81
+ case raw
82
+ when Hash then raw
83
+ when String
84
+ return {} if raw.strip.empty?
85
+
86
+ parsed = JSON.parse(raw)
87
+ parsed.is_a?(Hash) ? parsed : {}
88
+ else
89
+ {}
90
+ end
91
+ rescue JSON::ParserError
92
+ {}
93
+ end
94
+
95
+ def postgresql_sql
96
+ <<~SQL.squish
97
+ SELECT key,
98
+ COUNT(*) AS calls_count,
99
+ COUNT(DISTINCT (sub.tags::jsonb)->>key) AS distinct_values
100
+ FROM (#{subquery}) AS sub,
101
+ jsonb_object_keys(sub.tags::jsonb) AS key
102
+ WHERE sub.tags IS NOT NULL
103
+ AND sub.tags::jsonb <> '{}'::jsonb
104
+ GROUP BY key
105
+ ORDER BY calls_count DESC
106
+ SQL
107
+ end
108
+
109
+ def sqlite_sql
110
+ <<~SQL.squish
111
+ SELECT je.key AS key,
112
+ COUNT(*) AS calls_count,
113
+ COUNT(DISTINCT je.value) AS distinct_values
114
+ FROM (#{subquery}) AS sub,
115
+ json_each(sub.tags) AS je
116
+ WHERE sub.tags IS NOT NULL
117
+ AND sub.tags != '{}'
118
+ AND sub.tags != ''
119
+ GROUP BY je.key
120
+ ORDER BY calls_count DESC
121
+ SQL
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module LlmCostTracker
6
+ module Dashboard
7
+ class TimeSeries
8
+ DEFAULT_DAYS = 30
9
+
10
+ class << self
11
+ def call(scope: LlmCostTracker::LlmApiCall.all, from: nil, to: Date.current)
12
+ new(scope: scope, from: from, to: to).points
13
+ end
14
+ end
15
+
16
+ def initialize(scope:, from:, to:)
17
+ @scope = scope
18
+ @to = to.to_date
19
+ @from = from ? from.to_date : (@to - (DEFAULT_DAYS - 1))
20
+ end
21
+
22
+ def points
23
+ costs = scoped_costs
24
+
25
+ (from..to).map do |date|
26
+ label = date.iso8601
27
+ { label: label, cost: costs.fetch(label, 0.0) }
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :scope, :from, :to
34
+
35
+ def scoped_costs
36
+ scope
37
+ .where(tracked_at: from.beginning_of_day..to.end_of_day)
38
+ .group_by_period(:day)
39
+ .sum(:total_cost)
40
+ .transform_values(&:to_f)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ module Dashboard
5
+ TopModel = Data.define(
6
+ :provider,
7
+ :model,
8
+ :calls,
9
+ :total_cost,
10
+ :average_cost_per_call,
11
+ :input_tokens,
12
+ :output_tokens,
13
+ :average_latency_ms
14
+ )
15
+
16
+ class TopModels
17
+ DEFAULT_LIMIT = 5
18
+ SORT_OPTIONS = {
19
+ "cost" => "total_cost_sum DESC",
20
+ "calls" => "calls_count DESC",
21
+ "avg_cost" => "total_cost_sum / calls_count DESC",
22
+ "latency" => "average_latency DESC NULLS LAST"
23
+ }.freeze
24
+ DEFAULT_SORT = "cost"
25
+
26
+ class << self
27
+ def call(scope: LlmCostTracker::LlmApiCall.all, limit: DEFAULT_LIMIT, sort: DEFAULT_SORT)
28
+ new(scope: scope, limit: limit, sort: sort).rows
29
+ end
30
+ end
31
+
32
+ def initialize(scope:, limit:, sort: DEFAULT_SORT)
33
+ @scope = scope
34
+ @limit = limit
35
+ @sort = SORT_OPTIONS.key?(sort.to_s) ? sort.to_s : DEFAULT_SORT
36
+ end
37
+
38
+ def rows
39
+ grouped_rows.map do |row|
40
+ calls = row.calls_count.to_i
41
+ total_cost = row.total_cost_sum.to_f
42
+
43
+ TopModel.new(
44
+ provider: row.provider,
45
+ model: row.model,
46
+ calls: calls,
47
+ total_cost: total_cost,
48
+ average_cost_per_call: calls.positive? ? total_cost / calls : 0.0,
49
+ input_tokens: row.input_tokens_sum.to_i,
50
+ output_tokens: row.output_tokens_sum.to_i,
51
+ average_latency_ms: average_latency(row)
52
+ )
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :scope, :limit, :sort
59
+
60
+ def grouped_rows
61
+ order_sql = sort == "latency" && !scope.klass.latency_column? ? "total_cost_sum DESC" : SORT_OPTIONS[sort]
62
+ scope
63
+ .group(:provider, :model)
64
+ .select(selects)
65
+ .order(Arel.sql(order_sql))
66
+ .then { |r| limit ? r.limit(limit) : r }
67
+ end
68
+
69
+ def selects
70
+ columns = [
71
+ "provider",
72
+ "model",
73
+ "COUNT(*) AS calls_count",
74
+ "COALESCE(SUM(total_cost), 0) AS total_cost_sum",
75
+ "COALESCE(SUM(input_tokens), 0) AS input_tokens_sum",
76
+ "COALESCE(SUM(output_tokens), 0) AS output_tokens_sum"
77
+ ]
78
+ columns << "AVG(latency_ms) AS average_latency" if scope.klass.latency_column?
79
+ columns.join(", ")
80
+ end
81
+
82
+ def average_latency(row)
83
+ return nil unless scope.klass.latency_column?
84
+
85
+ row.average_latency&.to_f
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Pagination
5
+ DEFAULT_PER = 50
6
+ MAX_PER = 200
7
+ MIN_PAGE = 1
8
+
9
+ attr_reader :page, :per
10
+
11
+ def self.call(params)
12
+ params = normalize_params(params)
13
+ new(
14
+ page: integer_param(params, :page, default: MIN_PAGE, min: MIN_PAGE),
15
+ per: integer_param(params, :per, default: DEFAULT_PER, min: 1, max: MAX_PER)
16
+ )
17
+ end
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
+ def self.integer_param(params, key, default:, min:, max: nil)
28
+ value = Integer(params[key], 10)
29
+ value = [value, min].max
30
+ value = [value, max].min if max
31
+ value
32
+ rescue ArgumentError, TypeError
33
+ default
34
+ end
35
+ private_class_method :integer_param
36
+
37
+ def initialize(page:, per:)
38
+ @page = page
39
+ @per = per
40
+ freeze
41
+ end
42
+
43
+ def limit
44
+ per
45
+ end
46
+
47
+ def offset
48
+ (page - 1) * per
49
+ end
50
+
51
+ def prev_page?
52
+ page > MIN_PAGE
53
+ end
54
+
55
+ def next_page?(total_count)
56
+ offset + per < total_count.to_i
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,342 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>LLM Costs</title>
7
+ <style>
8
+ :root {
9
+ --lct-bg: #f7f8fa;
10
+ --lct-panel: #ffffff;
11
+ --lct-border: #d9dee7;
12
+ --lct-text: #1f2937;
13
+ --lct-muted: #64748b;
14
+ --lct-accent: #0f766e;
15
+ --lct-accent-soft: #ccfbf1;
16
+ --lct-danger: #b91c1c;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ background: var(--lct-bg);
22
+ color: var(--lct-text);
23
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
+ }
25
+
26
+ .lct-shell {
27
+ max-width: 1120px;
28
+ margin: 0 auto;
29
+ padding: 24px;
30
+ }
31
+
32
+ .lct-header {
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ gap: 24px;
37
+ margin-bottom: 24px;
38
+ }
39
+
40
+ .lct-title {
41
+ margin: 0;
42
+ font-size: 24px;
43
+ line-height: 1.2;
44
+ }
45
+
46
+ .lct-nav {
47
+ display: flex;
48
+ gap: 12px;
49
+ flex-wrap: wrap;
50
+ }
51
+
52
+ .lct-nav a {
53
+ color: var(--lct-muted);
54
+ text-decoration: none;
55
+ font-size: 14px;
56
+ }
57
+
58
+ .lct-nav a:hover {
59
+ color: var(--lct-accent);
60
+ }
61
+
62
+ .lct-nav .lct-active {
63
+ color: var(--lct-accent);
64
+ font-weight: 700;
65
+ }
66
+
67
+ .lct-panel {
68
+ background: var(--lct-panel);
69
+ border: 1px solid var(--lct-border);
70
+ border-radius: 8px;
71
+ padding: 24px;
72
+ margin-bottom: 20px;
73
+ }
74
+
75
+ .lct-muted {
76
+ color: var(--lct-muted);
77
+ }
78
+
79
+ .lct-code {
80
+ background: #eef2f7;
81
+ border-radius: 6px;
82
+ padding: 2px 6px;
83
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
84
+ font-size: 0.95em;
85
+ }
86
+
87
+ .lct-grid {
88
+ display: grid;
89
+ gap: 16px;
90
+ }
91
+
92
+ .lct-stats {
93
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
94
+ }
95
+
96
+ .lct-two-col {
97
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
98
+ }
99
+
100
+ .lct-stat {
101
+ background: var(--lct-panel);
102
+ border: 1px solid var(--lct-border);
103
+ border-radius: 8px;
104
+ padding: 18px;
105
+ }
106
+
107
+ .lct-stat-label {
108
+ color: var(--lct-muted);
109
+ font-size: 13px;
110
+ margin: 0 0 8px;
111
+ }
112
+
113
+ .lct-stat-value {
114
+ font-size: 24px;
115
+ font-weight: 700;
116
+ margin: 0;
117
+ }
118
+
119
+ .lct-section-title {
120
+ margin: 0 0 16px;
121
+ font-size: 18px;
122
+ }
123
+
124
+ .lct-table {
125
+ border-collapse: collapse;
126
+ width: 100%;
127
+ font-size: 14px;
128
+ }
129
+
130
+ .lct-stat-sub {
131
+ color: var(--lct-muted);
132
+ font-size: 12px;
133
+ margin: 4px 0 0;
134
+ }
135
+
136
+ .lct-delta {
137
+ font-size: 12px;
138
+ margin: 4px 0 0;
139
+ }
140
+
141
+ .lct-delta-neutral { color: var(--lct-muted); }
142
+ .lct-delta-up { color: #b91c1c; }
143
+ .lct-delta-down { color: #047857; }
144
+
145
+ .lct-table-wrap {
146
+ overflow-x: auto;
147
+ }
148
+
149
+ .lct-table th,
150
+ .lct-table td {
151
+ border-bottom: 1px solid var(--lct-border);
152
+ padding: 10px 8px;
153
+ text-align: left;
154
+ vertical-align: middle;
155
+ }
156
+
157
+ .lct-table th {
158
+ color: var(--lct-muted);
159
+ font-weight: 600;
160
+ font-size: 12px;
161
+ text-transform: uppercase;
162
+ }
163
+
164
+ .lct-table td:last-child,
165
+ .lct-table th:last-child {
166
+ text-align: right;
167
+ }
168
+
169
+ .lct-calls-table td:last-child,
170
+ .lct-calls-table th:last-child {
171
+ text-align: left;
172
+ }
173
+
174
+ .lct-bar-track {
175
+ background: #e5e7eb;
176
+ border-radius: 999px;
177
+ height: 8px;
178
+ overflow: hidden;
179
+ min-width: 96px;
180
+ }
181
+
182
+ .lct-bar-fill {
183
+ background: var(--lct-accent);
184
+ height: 100%;
185
+ }
186
+
187
+ .lct-budget-fill {
188
+ background: var(--lct-danger);
189
+ }
190
+
191
+ .lct-empty {
192
+ padding: 32px;
193
+ text-align: center;
194
+ }
195
+
196
+ .lct-filters {
197
+ display: grid;
198
+ gap: 12px;
199
+ grid-template-columns: repeat(6, minmax(0, 1fr));
200
+ }
201
+
202
+ .lct-field {
203
+ display: flex;
204
+ flex-direction: column;
205
+ gap: 6px;
206
+ }
207
+
208
+ .lct-field label {
209
+ color: var(--lct-muted);
210
+ font-size: 12px;
211
+ font-weight: 600;
212
+ text-transform: uppercase;
213
+ }
214
+
215
+ .lct-field input,
216
+ .lct-field select {
217
+ border: 1px solid var(--lct-border);
218
+ border-radius: 6px;
219
+ color: var(--lct-text);
220
+ font: inherit;
221
+ padding: 8px 10px;
222
+ background: var(--lct-panel);
223
+ }
224
+
225
+ .lct-button-row {
226
+ align-items: end;
227
+ display: flex;
228
+ gap: 8px;
229
+ }
230
+
231
+ .lct-button {
232
+ background: var(--lct-accent);
233
+ border: 1px solid var(--lct-accent);
234
+ border-radius: 6px;
235
+ color: #ffffff;
236
+ display: inline-block;
237
+ font: inherit;
238
+ padding: 8px 12px;
239
+ text-decoration: none;
240
+ }
241
+
242
+ .lct-button-secondary {
243
+ background: var(--lct-panel);
244
+ color: var(--lct-accent);
245
+ }
246
+
247
+ .lct-pagination {
248
+ align-items: center;
249
+ display: flex;
250
+ justify-content: space-between;
251
+ gap: 12px;
252
+ margin-top: 16px;
253
+ }
254
+
255
+ .lct-tags {
256
+ display: inline-block;
257
+ max-width: 280px;
258
+ overflow: hidden;
259
+ text-overflow: ellipsis;
260
+ white-space: nowrap;
261
+ }
262
+
263
+ .lct-nowrap {
264
+ white-space: nowrap;
265
+ }
266
+
267
+ .lct-detail-grid {
268
+ display: grid;
269
+ gap: 16px;
270
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
271
+ }
272
+
273
+ .lct-dl {
274
+ display: grid;
275
+ gap: 12px;
276
+ grid-template-columns: minmax(120px, 0.4fr) minmax(0, 1fr);
277
+ margin: 0;
278
+ }
279
+
280
+ .lct-dl dt {
281
+ color: var(--lct-muted);
282
+ font-size: 12px;
283
+ font-weight: 600;
284
+ text-transform: uppercase;
285
+ }
286
+
287
+ .lct-dl dd {
288
+ margin: 0;
289
+ }
290
+
291
+ .lct-pre {
292
+ background: #0f172a;
293
+ border-radius: 8px;
294
+ color: #e5e7eb;
295
+ margin: 0;
296
+ overflow: auto;
297
+ padding: 16px;
298
+ }
299
+
300
+ @media (max-width: 800px) {
301
+ .lct-header {
302
+ align-items: flex-start;
303
+ flex-direction: column;
304
+ }
305
+
306
+ .lct-stats,
307
+ .lct-two-col {
308
+ grid-template-columns: 1fr;
309
+ }
310
+
311
+ .lct-filters {
312
+ grid-template-columns: 1fr;
313
+ }
314
+
315
+ .lct-pagination {
316
+ align-items: flex-start;
317
+ flex-direction: column;
318
+ }
319
+
320
+ .lct-detail-grid {
321
+ grid-template-columns: 1fr;
322
+ }
323
+ }
324
+ </style>
325
+ </head>
326
+ <body>
327
+ <main class="lct-shell">
328
+ <header class="lct-header">
329
+ <h1 class="lct-title">LLM Costs</h1>
330
+ <nav class="lct-nav" aria-label="Dashboard">
331
+ <%= link_to "Overview", root_path, class: ("lct-active" if request.path.delete_suffix("/") == root_path.delete_suffix("/")) %>
332
+ <%= link_to "Models", models_path, class: ("lct-active" if request.path.start_with?(models_path)) %>
333
+ <%= link_to "Calls", calls_path, class: ("lct-active" if request.path.start_with?(calls_path)) %>
334
+ <%= link_to "Tags", tags_path, class: ("lct-active" if request.path.start_with?(tags_path)) %>
335
+ <%= link_to "Data Quality", data_quality_path, class: ("lct-active" if request.path.start_with?(data_quality_path)) %>
336
+ </nav>
337
+ </header>
338
+
339
+ <%= yield %>
340
+ </main>
341
+ </body>
342
+ </html>