llm_cost_tracker 0.2.0.alpha2 → 0.3.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +114 -70
  4. data/Rakefile +2 -0
  5. data/app/assets/llm_cost_tracker/application.css +760 -0
  6. data/app/controllers/llm_cost_tracker/application_controller.rb +1 -7
  7. data/app/controllers/llm_cost_tracker/assets_controller.rb +12 -0
  8. data/app/controllers/llm_cost_tracker/calls_controller.rb +29 -12
  9. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +5 -1
  10. data/app/helpers/llm_cost_tracker/application_helper.rb +46 -5
  11. data/app/helpers/llm_cost_tracker/chart_helper.rb +133 -0
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +47 -0
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +34 -0
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +58 -0
  15. data/app/helpers/llm_cost_tracker/pagination_helper.rb +18 -0
  16. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +16 -1
  17. data/app/services/llm_cost_tracker/dashboard/filter.rb +22 -3
  18. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +16 -1
  19. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +79 -0
  20. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +19 -46
  21. data/app/services/llm_cost_tracker/dashboard/top_models.rb +17 -8
  22. data/app/services/llm_cost_tracker/pagination.rb +6 -0
  23. data/app/views/layouts/llm_cost_tracker/application.html.erb +35 -333
  24. data/app/views/llm_cost_tracker/calls/index.html.erb +116 -74
  25. data/app/views/llm_cost_tracker/calls/show.html.erb +58 -1
  26. data/app/views/llm_cost_tracker/dashboard/index.html.erb +211 -111
  27. data/app/views/llm_cost_tracker/data_quality/index.html.erb +224 -78
  28. data/app/views/llm_cost_tracker/errors/database.html.erb +3 -3
  29. data/app/views/llm_cost_tracker/errors/invalid_filter.html.erb +3 -3
  30. data/app/views/llm_cost_tracker/errors/not_found.html.erb +3 -3
  31. data/app/views/llm_cost_tracker/models/index.html.erb +66 -58
  32. data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +16 -0
  33. data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +23 -0
  34. data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +18 -0
  35. data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +15 -0
  36. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +3 -2
  37. data/app/views/llm_cost_tracker/tags/index.html.erb +55 -12
  38. data/app/views/llm_cost_tracker/tags/show.html.erb +88 -39
  39. data/config/routes.rb +3 -0
  40. data/lib/llm_cost_tracker/assets.rb +19 -0
  41. data/lib/llm_cost_tracker/configuration.rb +78 -42
  42. data/lib/llm_cost_tracker/engine.rb +2 -0
  43. data/lib/llm_cost_tracker/event.rb +2 -0
  44. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_generator.rb +29 -0
  45. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +25 -0
  46. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +4 -0
  47. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/llm_cost_tracker_prices.yml.erb +8 -1
  48. data/lib/llm_cost_tracker/llm_api_call.rb +9 -1
  49. data/lib/llm_cost_tracker/middleware/faraday.rb +57 -9
  50. data/lib/llm_cost_tracker/parsed_usage.rb +7 -3
  51. data/lib/llm_cost_tracker/parsers/anthropic.rb +79 -1
  52. data/lib/llm_cost_tracker/parsers/base.rb +17 -5
  53. data/lib/llm_cost_tracker/parsers/gemini.rb +59 -6
  54. data/lib/llm_cost_tracker/parsers/openai.rb +8 -0
  55. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +8 -0
  56. data/lib/llm_cost_tracker/parsers/openai_usage.rb +55 -1
  57. data/lib/llm_cost_tracker/parsers/registry.rb +15 -3
  58. data/lib/llm_cost_tracker/parsers/sse.rb +81 -0
  59. data/lib/llm_cost_tracker/price_registry.rb +18 -7
  60. data/lib/llm_cost_tracker/price_sync/fetcher.rb +72 -0
  61. data/lib/llm_cost_tracker/price_sync/merger.rb +72 -0
  62. data/lib/llm_cost_tracker/price_sync/model_catalog.rb +77 -0
  63. data/lib/llm_cost_tracker/price_sync/raw_price.rb +35 -0
  64. data/lib/llm_cost_tracker/price_sync/source.rb +29 -0
  65. data/lib/llm_cost_tracker/price_sync/source_result.rb +7 -0
  66. data/lib/llm_cost_tracker/price_sync/sources/litellm.rb +91 -0
  67. data/lib/llm_cost_tracker/price_sync/sources/open_router.rb +94 -0
  68. data/lib/llm_cost_tracker/price_sync/validator.rb +66 -0
  69. data/lib/llm_cost_tracker/price_sync.rb +310 -0
  70. data/lib/llm_cost_tracker/pricing.rb +19 -6
  71. data/lib/llm_cost_tracker/retention.rb +34 -0
  72. data/lib/llm_cost_tracker/storage/active_record_store.rb +3 -1
  73. data/lib/llm_cost_tracker/stream_collector.rb +158 -0
  74. data/lib/llm_cost_tracker/tag_query.rb +7 -2
  75. data/lib/llm_cost_tracker/tags_column.rb +21 -1
  76. data/lib/llm_cost_tracker/tracker.rb +15 -12
  77. data/lib/llm_cost_tracker/value_helpers.rb +40 -0
  78. data/lib/llm_cost_tracker/version.rb +1 -1
  79. data/lib/llm_cost_tracker.rb +51 -29
  80. data/lib/tasks/llm_cost_tracker.rake +124 -0
  81. data/llm_cost_tracker.gemspec +9 -8
  82. metadata +40 -12
  83. data/PLAN_0.2.md +0 -488
data/PLAN_0.2.md DELETED
@@ -1,488 +0,0 @@
1
- # llm_cost_tracker v0.2.0 Plan
2
-
3
- ## Goal
4
-
5
- Ship an opt-in Rails dashboard engine:
6
-
7
- ```ruby
8
- require "llm_cost_tracker/engine"
9
-
10
- mount LlmCostTracker::Engine => "/llm-costs"
11
- ```
12
-
13
- The dashboard is read-only and shows where a Rails app spends money on LLM APIs:
14
-
15
- - spend over time
16
- - top models
17
- - recent calls
18
- - call details
19
- - tag breakdowns such as `feature`
20
- - optional monthly budget status
21
-
22
- The first milestone is a useful Overview page that can be used as the README screenshot.
23
-
24
- ## Non-Goals
25
-
26
- These are intentionally out of scope for v0.2.0:
27
-
28
- - built-in authorization
29
- - CSV or JSON export
30
- - alerts, webhooks, or Slack notifications
31
- - JavaScript charts or live updates
32
- - editing settings from the dashboard
33
- - persistent budget models
34
- - `/budgets`
35
- - `/tags` index or automatic tag-key discovery
36
- - timezone-aware SQL bucketing
37
- - hourly period grouping
38
- - SaaS or remote sync
39
-
40
- ## Compatibility Decisions
41
-
42
- ### Core Gem
43
-
44
- The core gem remains lightweight and usable outside Rails:
45
-
46
- - keep `activesupport >= 7.0, < 9.0`
47
- - do not add `railties` as a runtime dependency
48
- - keep middleware-only usage working for plain Faraday, Sinatra, Hanami, and service objects
49
-
50
- ### Dashboard Engine
51
-
52
- The Engine is Rails-only and opt-in:
53
-
54
- - users must `require "llm_cost_tracker/engine"`
55
- - Engine requires Rails 7.1+
56
- - the Rails version guard must run at top level in `engine.rb`, before defining `class Engine < ::Rails::Engine`
57
- - `railties` is a development/test dependency only
58
-
59
- Example guard:
60
-
61
- ```ruby
62
- unless Gem::Version.new(Rails.version) >= Gem::Version.new("7.1.0")
63
- raise LlmCostTracker::Error, "LlmCostTracker::Engine requires Rails 7.1+"
64
- end
65
- ```
66
-
67
- ## Phase 1: Public Query Primitive
68
-
69
- Add SQL-side period grouping to `LlmCostTracker::LlmApiCall`.
70
-
71
- ### API
72
-
73
- ```ruby
74
- LlmCostTracker::LlmApiCall.group_by_period(:day).sum(:total_cost)
75
- LlmCostTracker::LlmApiCall.group_by_period(:month).count
76
- LlmCostTracker::LlmApiCall.group_by_period(:day, column: :created_at)
77
- ```
78
-
79
- Default column: `:tracked_at`.
80
-
81
- ### Supported Periods
82
-
83
- Only:
84
-
85
- - `:day`
86
- - `:month`
87
-
88
- Do not add `:hour` or `:week` in v0.2.0.
89
-
90
- ### Return Keys
91
-
92
- Return string keys across all adapters:
93
-
94
- ```ruby
95
- :day # "2026-04-18"
96
- :month # "2026-04"
97
- ```
98
-
99
- ### SQL Strategy
100
-
101
- - PostgreSQL: `TO_CHAR(DATE_TRUNC(...), ...)`
102
- - MySQL: `DATE_FORMAT(...)`
103
- - SQLite: `strftime(...)`
104
-
105
- ### Validation
106
-
107
- - period must be whitelisted
108
- - column must exist in `column_names`
109
- - invalid period or column raises `ArgumentError`
110
- - no `time_zone:` parameter in v0.2.0
111
-
112
- ### Tests
113
-
114
- - real SQLite grouping specs
115
- - generated SQL expression specs for PostgreSQL/MySQL if those adapters are not in CI
116
- - injection tests for period and column
117
- - composition test:
118
-
119
- ```ruby
120
- LlmCostTracker::LlmApiCall
121
- .this_month
122
- .where(provider: "openai")
123
- .group_by_period(:day)
124
- .sum(:total_cost)
125
- ```
126
-
127
- ## Phase 2: Engine Skeleton
128
-
129
- Add the minimal Rails Engine structure:
130
-
131
- ```text
132
- lib/llm_cost_tracker/engine.rb
133
- app/controllers/llm_cost_tracker/application_controller.rb
134
- app/controllers/llm_cost_tracker/dashboard_controller.rb
135
- app/views/layouts/llm_cost_tracker/application.html.erb
136
- config/routes.rb
137
- spec/dummy/
138
- ```
139
-
140
- ### Requirements
141
-
142
- - isolated namespace: `isolate_namespace LlmCostTracker`
143
- - no automatic loading for non-Rails users
144
- - no asset pipeline dependency
145
- - no JavaScript
146
- - inline CSS in the Engine layout
147
- - all CSS classes prefixed with `.lct-`
148
- - minimal `spec/dummy` Rails app for Engine request specs
149
-
150
- ### Acceptance
151
-
152
- - dummy app mounts the Engine at `/llm-costs`
153
- - `GET /llm-costs` returns 200
154
- - empty database shows an intentional empty state
155
- - missing `llm_api_calls` table shows a friendly setup error
156
-
157
- ## Phase 3: Dashboard Data Layer
158
-
159
- Use plain Ruby objects under:
160
-
161
- ```text
162
- app/services/llm_cost_tracker/dashboard/
163
- ```
164
-
165
- ### Filter
166
-
167
- Parses dashboard params and returns an ActiveRecord relation.
168
-
169
- Supported params:
170
-
171
- - `from`
172
- - `to`
173
- - `provider`
174
- - `model`
175
- - `tag[key]=value`
176
-
177
- Rules:
178
-
179
- - parse `from` and `to` with `Date.iso8601`
180
- - ignore invalid dates
181
- - use AR placeholders for provider/model
182
- - parse all `params[:tag]` keys as a hash
183
- - pass multi-key tag filters to `by_tags`
184
- - validate tag keys with the same whitelist as `group_by_tag`
185
-
186
- Example:
187
-
188
- ```text
189
- ?tag[feature]=chat&tag[user_id]=42
190
- ```
191
-
192
- becomes:
193
-
194
- ```ruby
195
- scope.by_tags("feature" => "chat", "user_id" => "42")
196
- ```
197
-
198
- ### Page
199
-
200
- Use a small immutable object, not a large service:
201
-
202
- ```ruby
203
- Dashboard::Page = Data.define(:page, :per)
204
- ```
205
-
206
- Rules:
207
-
208
- - `page` minimum: 1
209
- - `per` default: 50
210
- - `per` maximum: 200
211
- - expose `limit`, `offset`, `prev_page?`, and `next_page?`
212
-
213
- ### OverviewStats
214
-
215
- Compute:
216
-
217
- - total spend
218
- - total calls
219
- - average cost per call
220
- - average latency only if `latency_ms` exists
221
- - monthly budget status only if `LlmCostTracker.configuration.monthly_budget` is set
222
-
223
- Do not compute `known_pricing_rate` in v0.2.0.
224
-
225
- ### TimeSeries
226
-
227
- Uses `group_by_period(:day).sum(:total_cost)`.
228
-
229
- Rules:
230
-
231
- - default range: last 30 days
232
- - fill missing days with zero
233
- - output array:
234
-
235
- ```ruby
236
- [{ label: "2026-04-01", cost: 0.0 }]
237
- ```
238
-
239
- ### TopModels
240
-
241
- Compute:
242
-
243
- - provider
244
- - model
245
- - calls
246
- - total cost
247
- - average cost per call
248
- - input tokens
249
- - output tokens
250
- - average latency if available
251
-
252
- ### TopTags
253
-
254
- Only for configured keys.
255
-
256
- Default keys:
257
-
258
- ```ruby
259
- ["feature"]
260
- ```
261
-
262
- No automatic tag-key discovery in v0.2.0.
263
-
264
- ## Phase 4: Dashboard Pages
265
-
266
- ### Overview: `GET /`
267
-
268
- The main screenshot-worthy page.
269
-
270
- Show:
271
-
272
- - total spend
273
- - total calls
274
- - average cost per call
275
- - average latency if available
276
- - monthly budget status if configured
277
- - daily spend table with CSS bars
278
- - top 5 models
279
- - cost by `feature` tag if data exists
280
-
281
- Budget wording:
282
-
283
- > Soft monthly limit. Blocking is not atomic under concurrency.
284
-
285
- ### Calls Index: `GET /calls`
286
-
287
- Filters:
288
-
289
- - `from`
290
- - `to`
291
- - `provider`
292
- - `model`
293
- - `tag[key]=value`
294
- - `page`
295
- - `per`
296
-
297
- Columns:
298
-
299
- - created_at
300
- - provider
301
- - model
302
- - input tokens
303
- - output tokens
304
- - cache tokens if present
305
- - total cost
306
- - latency if available
307
- - tags summary
308
- - details link
309
-
310
- Rules:
311
-
312
- - newest first
313
- - max `per=200`
314
- - one count query and one paginated query
315
- - no N+1 behavior
316
-
317
- ### Call Details: `GET /calls/:id`
318
-
319
- Show:
320
-
321
- - all stored columns
322
- - token breakdown
323
- - cost breakdown
324
- - latency if available
325
- - tags as pretty JSON
326
- - metadata if present
327
-
328
- Missing records render a friendly 404 inside the Engine layout.
329
-
330
- ### Models: `GET /models`
331
-
332
- Show rows grouped by provider/model:
333
-
334
- - provider
335
- - model
336
- - calls
337
- - total cost
338
- - average cost per call
339
- - input tokens
340
- - output tokens
341
- - average latency if available
342
-
343
- Default sort: total cost descending.
344
-
345
- ### Tag Breakdown: `GET /tags/:key`
346
-
347
- Validate `key` using the same tag-key whitelist as `group_by_tag`.
348
-
349
- Show:
350
-
351
- - tag value
352
- - calls
353
- - total cost
354
- - average cost per call
355
-
356
- Invalid keys render a friendly 400.
357
-
358
- Do not add `/tags` index in v0.2.0.
359
-
360
- ## Graceful Degradation
361
-
362
- The dashboard must not explode when optional pieces are missing.
363
-
364
- Handle:
365
-
366
- - empty database
367
- - missing `llm_api_calls` table
368
- - missing `latency_ms`
369
- - text `tags` fallback instead of JSONB
370
- - malformed legacy tag JSON
371
- - unknown-pricing rows with `total_cost = nil`
372
- - no configured monthly budget
373
-
374
- ## Security
375
-
376
- - dashboard is read-only
377
- - routes are GET-only
378
- - keep `protect_from_forgery with: :exception`
379
- - no built-in auth
380
- - README must show Basic Auth and Devise examples
381
- - validate every param through `Dashboard::Filter`
382
- - do not log full tags or request details from the Engine
383
-
384
- README warning:
385
-
386
- > Do not expose this dashboard publicly. Tags may contain internal user, tenant, or feature identifiers.
387
-
388
- ## Styling
389
-
390
- - ERB templates
391
- - no JS
392
- - no external fonts
393
- - no external CSS
394
- - no Chart.js
395
- - no Tailwind dependency
396
- - inline `<style>` in layout
397
- - `.lct-` class prefix
398
- - CSS bar charts through inline custom properties
399
-
400
- Example:
401
-
402
- ```html
403
- <div class="lct-bar" style="--lct-width: 73%"></div>
404
- ```
405
-
406
- ## Testing Plan
407
-
408
- ### Core Specs
409
-
410
- - `group_by_period(:day)`
411
- - `group_by_period(:month)`
412
- - invalid period
413
- - invalid column
414
- - composition with existing scopes
415
-
416
- ### Engine Request Specs
417
-
418
- - `/` empty DB
419
- - `/` seeded DB
420
- - `/calls`
421
- - `/calls` filters narrow results
422
- - `/calls/:id`
423
- - `/calls/:missing`
424
- - `/models`
425
- - `/tags/feature`
426
- - invalid tag key returns 400
427
- - missing table renders setup error
428
-
429
- ### Service Specs
430
-
431
- - `Dashboard::Filter`
432
- - `Dashboard::Page`
433
- - `Dashboard::TimeSeries`
434
- - `Dashboard::OverviewStats`
435
- - `Dashboard::TopModels`
436
- - `Dashboard::TopTags`
437
-
438
- ### CI
439
-
440
- - Ruby 3.3, 3.4
441
- - Rails Engine specs on Rails 7.1, 7.2, 8.0
442
- - SQLite default
443
- - PostgreSQL job for period grouping and tag grouping
444
- - MySQL best-effort only; not required for v0.2.0 CI
445
-
446
- ## Documentation
447
-
448
- README additions:
449
-
450
- - Dashboard quick start
451
- - mount snippet
452
- - Basic Auth snippet
453
- - Devise snippet
454
- - warning not to expose dashboard publicly
455
- - note that Engine requires Rails 7.1+
456
- - note that core middleware remains usable without Rails
457
-
458
- ## Release Strategy
459
-
460
- 1. Work on `codex/engine-dashboard`.
461
- 2. Implement `group_by_period`.
462
- 3. Add Engine skeleton.
463
- 4. Build Overview first.
464
- 5. Build Calls index.
465
- 6. Build Call details.
466
- 7. Build Models.
467
- 8. Build `/tags/:key`.
468
- 9. Dogfood in a real Rails app.
469
- 10. Release `0.2.0.alpha1`.
470
- 11. Fix issues from real usage.
471
- 12. Release `0.2.0.rc1`.
472
- 13. Release final `0.2.0`.
473
-
474
- ## v0.2.0 Acceptance Criteria
475
-
476
- The release is ready when:
477
-
478
- - `group_by_period(:day/:month)` is tested and documented
479
- - Engine is opt-in and requires Rails 7.1+
480
- - non-Rails core usage does not gain Rails runtime dependencies
481
- - `/llm-costs` renders without JS or asset pipeline dependencies
482
- - empty DB and missing table states are friendly
483
- - seeded dashboard shows useful spend data
484
- - filters cannot SQL-inject
485
- - Overview is good enough for README screenshot
486
- - README includes auth snippets
487
- - full test suite and RuboCop pass
488
- - alpha has been tested in one real Rails app