llm_cost_tracker 0.7.0 → 0.7.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 +11 -9
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/lib/llm_cost_tracker/budget.rb +8 -20
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +28 -35
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +63 -71
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
- data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
- data/lib/llm_cost_tracker/parsers/base.rb +10 -19
- data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +52 -11
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
- data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -8
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -8
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +38 -70
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -78
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +54 -58
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -67
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/writer.rb +0 -35
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -105
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbf918d9a4886e24ba99f93dc1125e016e50b45208437dd3254adda76f58033a
|
|
4
|
+
data.tar.gz: 8a22ccfa517549f55d2a302287660a8a5d7faf03568e80c253e6024ad4988743
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ef6bc278f6f98ce37a91e515e8b4a004aaff5f573fc8580e312e53b903d1e7700b2cd58b9bea72fc0ec904010cba43fb0209e7993c31bb69205b42564554884
|
|
7
|
+
data.tar.gz: 4c9a193d16bb5e8bfa58aaefb6fb9b7d98a632a8e19203927c3fd29b957da6088af71caf5c14f5f2b7452660f78001d7898745f8d7611a4fd6e94311b723bc71
|
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.7.1] - 2026-04-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- BREAKING: ActiveRecord ledger write failures now raise directly; removed `storage_error_behavior` and `StorageError`.
|
|
12
|
+
- BREAKING: Removed custom parser and SDK integration registration APIs; use built-in capture or explicit `track` / `track_stream`.
|
|
13
|
+
- BREAKING: Usage and pricing APIs now use `TokenUsage`; removed `UsageBreakdown`, `add_usage_breakdown`, direct `Pricing` token arguments, and `Pricing::Cost`.
|
|
14
|
+
- BREAKING: `Tracker.record` now accepts `UsageCapture`, and notification payloads nest `token_usage`.
|
|
15
|
+
- BREAKING: Moved price registry and refresh APIs under `LlmCostTracker::Pricing`.
|
|
16
|
+
- BREAKING: ActiveRecord installs must run the current ledger and period-total migrations; doctor, dashboard setup, and flush now fail on stale schema.
|
|
17
|
+
- BREAKING: `cache_write_input_tokens` now stores only standard cache writes; 1-hour cache writes use `cache_write_1h_input_tokens` and `cache_write_1h_input_cost`.
|
|
18
|
+
- Dashboard model and data-quality pages now use canonical `TokenUsage` totals.
|
|
19
|
+
- OpenAI, Anthropic, and RubyLLM capture now populate `pricing_mode` from provider tier data.
|
|
20
|
+
- Pricing now handles Anthropic 1-hour cache-write TTLs, Gemini context-cache reads, stackable batch cache rates, and long-context tiers.
|
|
21
|
+
- Missing positive-token pricing-mode rates now return unknown pricing instead of falling back to standard prices.
|
|
22
|
+
|
|
7
23
|
## [0.7.0] - 2026-04-29
|
|
8
24
|
|
|
9
25
|
### Changed
|
data/README.md
CHANGED
|
@@ -90,7 +90,7 @@ LlmCostTracker.with_tags(feature: "support_chat") do
|
|
|
90
90
|
end
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
Captures usage, model, latency, response ID, cache tokens, and reasoning tokens whenever the SDK exposes them. Provider SDKs are not added as gem dependencies — you install whichever you actually use.
|
|
93
|
+
Captures usage, model, latency, response ID, pricing mode, cache tokens, Anthropic cache-write TTLs, and reasoning tokens whenever the SDK exposes them. Provider SDKs are not added as gem dependencies — you install whichever you actually use.
|
|
94
94
|
|
|
95
95
|
Enabled integrations are checked at boot: the client gem must be loaded, meet the minimum supported version, and expose the expected classes and methods. If the contract check fails, boot raises instead of silently missing spend.
|
|
96
96
|
|
|
@@ -134,7 +134,7 @@ For streaming the same way, `track_stream` accepts a block, parses provider even
|
|
|
134
134
|
|
|
135
135
|
## Tags: who burned this money
|
|
136
136
|
|
|
137
|
-
Tags answer the only question that matters in attribution: which feature, which user, which job, which tenant. They're free-form strings,
|
|
137
|
+
Tags answer the only question that matters in attribution: which feature, which user, which job, which tenant. They're free-form strings, stored as JSONB on PostgreSQL or JSON on MySQL, and queryable from both Ruby and the dashboard.
|
|
138
138
|
|
|
139
139
|
```ruby
|
|
140
140
|
LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat", trace_id: request.uuid) do
|
|
@@ -172,7 +172,9 @@ Explain why a model is priced or unknown:
|
|
|
172
172
|
PROVIDER=openai MODEL=gpt-4o bin/rails llm_cost_tracker:prices:explain
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys.
|
|
175
|
+
Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys.
|
|
176
|
+
|
|
177
|
+
`pricing_mode` selects mode-prefixed rates such as `batch_input` or `priority_output`. Built-in capture fills it from provider tier fields when available; explicit `track` calls can pass it directly for batch jobs or gateway-specific modes. Full pricing reference: [`docs/pricing.md`](docs/pricing.md).
|
|
176
178
|
|
|
177
179
|
## Budgets
|
|
178
180
|
|
|
@@ -195,10 +197,10 @@ Full behavior, error class, and preflight details: [`docs/budgets.md`](docs/budg
|
|
|
195
197
|
When you want to slice spend from a console, scheduled job, or your own admin page:
|
|
196
198
|
|
|
197
199
|
```ruby
|
|
198
|
-
LlmCostTracker::
|
|
199
|
-
LlmCostTracker::
|
|
200
|
-
LlmCostTracker::
|
|
201
|
-
LlmCostTracker::
|
|
200
|
+
LlmCostTracker::Ledger::Call.this_month.cost_by_model
|
|
201
|
+
LlmCostTracker::Ledger::Call.this_month.cost_by_tag("feature")
|
|
202
|
+
LlmCostTracker::Ledger::Call.daily_costs(days: 7)
|
|
203
|
+
LlmCostTracker::Ledger::Call.by_tags(user_id: 42, feature: "chat").this_month.total_cost
|
|
202
204
|
```
|
|
203
205
|
|
|
204
206
|
A text report is also one rake task away:
|
|
@@ -232,7 +234,7 @@ Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs
|
|
|
232
234
|
| OpenRouter | Yes | OpenAI-compatible usage; provider-prefixed model IDs are normalized |
|
|
233
235
|
| DeepSeek | Yes | OpenAI-compatible usage; add `pricing_overrides` for DeepSeek-specific rates |
|
|
234
236
|
| Other OpenAI-compatible hosts | Configurable | Register the host via `config.openai_compatible_providers` |
|
|
235
|
-
| Anything else |
|
|
237
|
+
| Anything else | Manual | Use `LlmCostTracker.track` / `track_stream` |
|
|
236
238
|
|
|
237
239
|
RubyLLM chat, embedding, and transcription calls are captured through RubyLLM's provider layer when `config.instrument :ruby_llm` is enabled.
|
|
238
240
|
|
|
@@ -266,8 +268,8 @@ is still brief.
|
|
|
266
268
|
|
|
267
269
|
- `:block_requests` is best-effort under concurrency, not a transactional cap.
|
|
268
270
|
- Streaming usage capture relies on the provider emitting a final-usage event. Missing events are stored with `usage_source: "unknown"` so they appear on the data-quality page rather than vanishing.
|
|
271
|
+
- Non-token line items such as Gemini explicit-cache storage duration, provider tool calls, and modality-specific surcharges are not folded into token cost.
|
|
269
272
|
- `provider_response_id` is stored only when the provider exposes a stable ID. Gemini is best-effort and varies by endpoint.
|
|
270
|
-
- Cache write TTL variants on Anthropic (1h vs 5min writes) are not modeled separately yet.
|
|
271
273
|
|
|
272
274
|
## Development
|
|
273
275
|
|
|
@@ -302,6 +302,9 @@
|
|
|
302
302
|
.lct-budget-fill--warn { background: linear-gradient(90deg, #f59e0b, #d97706); }
|
|
303
303
|
.lct-budget-fill--over { background: linear-gradient(90deg, #ef4444, #b91c1c); }
|
|
304
304
|
.lct-stack-fill-input { background: var(--lct-accent); }
|
|
305
|
+
.lct-stack-fill-cache-read { background: #22c55e; }
|
|
306
|
+
.lct-stack-fill-cache-write { background: #f59e0b; }
|
|
307
|
+
.lct-stack-fill-cache-write-1h { background: #a855f7; }
|
|
305
308
|
.lct-stack-fill-output { background: #0ea5e9; }
|
|
306
309
|
|
|
307
310
|
.lct-budget { display: grid; gap: 10px; }
|
|
@@ -4,7 +4,7 @@ module LlmCostTracker
|
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
5
|
layout "llm_cost_tracker/application"
|
|
6
6
|
|
|
7
|
-
before_action :
|
|
7
|
+
before_action :ensure_current_schema
|
|
8
8
|
|
|
9
9
|
rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
|
|
10
10
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
@@ -13,9 +13,27 @@ module LlmCostTracker
|
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
def ensure_current_schema
|
|
17
|
+
unless LlmCostTracker::Ledger::Call.table_exists?
|
|
18
|
+
@setup_message = "The llm_api_calls table is not available yet."
|
|
19
|
+
return render template: "llm_cost_tracker/shared/setup_required"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
schema_errors = LlmCostTracker::Ledger::Schema::Calls.current_schema_errors
|
|
23
|
+
if schema_errors.any?
|
|
24
|
+
@setup_message = "The llm_api_calls table does not match the current LLM Cost Tracker schema."
|
|
25
|
+
@setup_details = schema_errors
|
|
26
|
+
render template: "llm_cost_tracker/shared/setup_required"
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
period_total_errors = LlmCostTracker::Ledger::Schema::PeriodTotals.current_schema_errors
|
|
31
|
+
return if period_total_errors.empty?
|
|
32
|
+
|
|
33
|
+
@setup_message = "The llm_cost_tracker_period_totals table does not match the current LLM Cost Tracker schema."
|
|
34
|
+
@setup_details = period_total_errors + [
|
|
35
|
+
"run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
|
|
36
|
+
]
|
|
19
37
|
render template: "llm_cost_tracker/shared/setup_required"
|
|
20
38
|
end
|
|
21
39
|
|
|
@@ -14,11 +14,10 @@ module LlmCostTracker
|
|
|
14
14
|
scope = Dashboard::Filter.call(params: params)
|
|
15
15
|
scope = scope.unknown_pricing if @sort == "unknown_pricing"
|
|
16
16
|
ordered_scope = scope.order(Arel.sql(calls_order(@sort)))
|
|
17
|
-
@latency_available = LlmApiCall.latency_column?
|
|
18
17
|
|
|
19
18
|
respond_to do |format|
|
|
20
19
|
format.html do
|
|
21
|
-
@page = Pagination.call(params)
|
|
20
|
+
@page = Dashboard::Pagination.call(params)
|
|
22
21
|
@calls_count = scope.count
|
|
23
22
|
@calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
|
|
24
23
|
end
|
|
@@ -31,8 +30,7 @@ module LlmCostTracker
|
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def show
|
|
34
|
-
@call =
|
|
35
|
-
@latency_available = LlmApiCall.latency_column?
|
|
33
|
+
@call = Ledger::Call.find(params[:id])
|
|
36
34
|
end
|
|
37
35
|
|
|
38
36
|
private
|
|
@@ -46,8 +44,6 @@ module LlmCostTracker
|
|
|
46
44
|
when "output"
|
|
47
45
|
"output_tokens DESC, #{DEFAULT_ORDER}"
|
|
48
46
|
when "slow"
|
|
49
|
-
return DEFAULT_ORDER unless LlmApiCall.latency_column?
|
|
50
|
-
|
|
51
47
|
"CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
|
|
52
48
|
else
|
|
53
49
|
DEFAULT_ORDER
|
|
@@ -66,11 +62,10 @@ module LlmCostTracker
|
|
|
66
62
|
end
|
|
67
63
|
|
|
68
64
|
def csv_fields
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
fields
|
|
65
|
+
%i[tracked_at provider model] +
|
|
66
|
+
TokenUsage::STORED_KEYS +
|
|
67
|
+
Pricing::COST_KEYS +
|
|
68
|
+
%i[latency_ms provider_response_id tags]
|
|
74
69
|
end
|
|
75
70
|
|
|
76
71
|
def csv_value(field, value)
|
|
@@ -7,7 +7,7 @@ module LlmCostTracker
|
|
|
7
7
|
@from_date = range.from
|
|
8
8
|
@to_date = range.to
|
|
9
9
|
prev_from, prev_to = previous_range
|
|
10
|
-
filter_params = LlmCostTracker::
|
|
10
|
+
filter_params = LlmCostTracker::Dashboard::Params.to_hash(params)
|
|
11
11
|
scope = Dashboard::Filter.call(
|
|
12
12
|
params: filter_params.merge("from" => @from_date.iso8601, "to" => @to_date.iso8601)
|
|
13
13
|
)
|
|
@@ -16,6 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
@stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
|
|
19
|
+
@monthly_budget_status = Dashboard::OverviewStats.monthly_budget_status
|
|
19
20
|
@time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
|
|
20
21
|
@comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
|
|
21
22
|
@spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class DataQualityController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
|
|
6
|
+
scope = Dashboard::Filter.call(params: params)
|
|
7
|
+
@stats = Dashboard::DataQuality.call(scope: scope)
|
|
8
|
+
@usage_rows = Dashboard::DataQuality.usage_rows(@stats)
|
|
9
|
+
@hidden_output_summary = Dashboard::DataQuality.hidden_output_summary(@stats)
|
|
10
|
+
@unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(scope)
|
|
7
11
|
end
|
|
8
12
|
end
|
|
9
13
|
end
|
|
@@ -7,14 +7,7 @@ module LlmCostTracker
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
@
|
|
11
|
-
breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
|
|
12
|
-
@rows = breakdown.rows
|
|
13
|
-
@total_calls = breakdown.total_calls
|
|
14
|
-
@tagged_calls = breakdown.tagged_calls
|
|
15
|
-
@distinct_values = breakdown.distinct_values
|
|
16
|
-
@tag_value_limit = breakdown.limit
|
|
17
|
-
@tag_values_limited = breakdown.limited?
|
|
10
|
+
@breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: params[:key])
|
|
18
11
|
end
|
|
19
12
|
end
|
|
20
13
|
end
|
|
@@ -12,6 +12,7 @@ module LlmCostTracker
|
|
|
12
12
|
include DashboardQueryHelper
|
|
13
13
|
include ChartHelper
|
|
14
14
|
include PaginationHelper
|
|
15
|
+
include TokenUsageHelper
|
|
15
16
|
|
|
16
17
|
def coverage_percent(numerator, denominator)
|
|
17
18
|
return 0.0 unless denominator.to_i.positive?
|
|
@@ -43,7 +44,7 @@ module LlmCostTracker
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def format_date(value)
|
|
46
|
-
value.
|
|
47
|
+
value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def pricing_status(call)
|
|
@@ -14,8 +14,7 @@ module LlmCostTracker
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def active_tag_filters
|
|
17
|
-
tag_params =
|
|
18
|
-
return [] unless tag_params.is_a?(Hash)
|
|
17
|
+
tag_params = LlmCostTracker::Dashboard::Params.to_hash(params[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
|
|
19
18
|
|
|
20
19
|
tag_params.filter_map do |key, value|
|
|
21
20
|
next if key.blank? || value.blank?
|
|
@@ -15,7 +15,7 @@ module LlmCostTracker
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def filter_options_for(column, filter_params:)
|
|
18
|
-
source = LlmCostTracker::
|
|
18
|
+
source = LlmCostTracker::Dashboard::Params.to_hash(filter_params)
|
|
19
19
|
scope_params = source.stringify_keys.merge(
|
|
20
20
|
column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
|
|
21
21
|
)
|
|
@@ -11,44 +11,27 @@ module LlmCostTracker
|
|
|
11
11
|
|
|
12
12
|
def calls_query_for_tag(key:, value:)
|
|
13
13
|
query = current_query(page: nil, per: nil, format: nil)
|
|
14
|
-
tags =
|
|
14
|
+
tags = LlmCostTracker::Dashboard::Params.to_hash(query[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
|
|
15
15
|
query[:tag] = tags.merge(key.to_s => value.to_s)
|
|
16
16
|
query
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
-
def normalized_query_tags(tags)
|
|
22
|
-
LlmCostTracker::ParameterHash.to_hash(tags).transform_keys(&:to_s).transform_values(&:to_s)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
21
|
def clean_dashboard_query(value)
|
|
26
|
-
if
|
|
27
|
-
return
|
|
28
|
-
|
|
22
|
+
if value.is_a?(Hash) || value.try(:to_unsafe_h).is_a?(Hash)
|
|
23
|
+
return LlmCostTracker::Dashboard::Params.to_hash(value).each_with_object({}) do |(key, nested), cleaned|
|
|
24
|
+
nested = clean_dashboard_query(nested)
|
|
25
|
+
next if nested.nil? || nested == {} || nested == []
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
value
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def clean_dashboard_hash(hash)
|
|
37
|
-
hash.each_with_object({}) do |(key, nested), cleaned|
|
|
38
|
-
nested = clean_dashboard_query(nested)
|
|
39
|
-
next if nested.nil? || nested == {} || nested == []
|
|
40
|
-
|
|
41
|
-
cleaned[key] = nested
|
|
27
|
+
cleaned[key] = nested
|
|
28
|
+
end
|
|
42
29
|
end
|
|
43
|
-
end
|
|
44
30
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
end
|
|
31
|
+
return value.filter_map { |item| clean_dashboard_query(item) }.presence if value.is_a?(Array)
|
|
32
|
+
return value.strip.presence if value.is_a?(String)
|
|
48
33
|
|
|
49
|
-
|
|
50
|
-
stripped = string.strip
|
|
51
|
-
stripped.empty? ? nil : stripped
|
|
34
|
+
value
|
|
52
35
|
end
|
|
53
36
|
end
|
|
54
37
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module TokenUsageHelper
|
|
5
|
+
COMPONENT_LABELS = {
|
|
6
|
+
input_tokens: "Input",
|
|
7
|
+
cache_read_input_tokens: "Cache read",
|
|
8
|
+
cache_write_input_tokens: "Cache write",
|
|
9
|
+
cache_write_1h_input_tokens: "1h cache write",
|
|
10
|
+
output_tokens: "Output",
|
|
11
|
+
hidden_output_tokens: "Hidden output"
|
|
12
|
+
}.freeze
|
|
13
|
+
QUALITY_LABELS = COMPONENT_LABELS.merge(
|
|
14
|
+
input_tokens: "Regular input",
|
|
15
|
+
cache_read_input_tokens: "Cache read input",
|
|
16
|
+
cache_write_input_tokens: "Cache write input",
|
|
17
|
+
cache_write_1h_input_tokens: "1h cache write input"
|
|
18
|
+
).freeze
|
|
19
|
+
STACK_CLASSES = {
|
|
20
|
+
input_tokens: "lct-stack-fill-input",
|
|
21
|
+
cache_read_input_tokens: "lct-stack-fill-cache-read",
|
|
22
|
+
cache_write_input_tokens: "lct-stack-fill-cache-write",
|
|
23
|
+
cache_write_1h_input_tokens: "lct-stack-fill-cache-write-1h",
|
|
24
|
+
output_tokens: "lct-stack-fill-output"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def token_usage_stack_components
|
|
28
|
+
token_usage_display_components(labels: COMPONENT_LABELS).select do |component|
|
|
29
|
+
component.fetch(:cost_key)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def token_usage_quality_label(token_key)
|
|
34
|
+
QUALITY_LABELS.fetch(token_key.to_sym)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def token_usage_display_components(labels:)
|
|
40
|
+
LlmCostTracker::Pricing::COMPONENTS.map do |component|
|
|
41
|
+
token_key = component.token_key
|
|
42
|
+
{
|
|
43
|
+
token_key: token_key,
|
|
44
|
+
cost_key: component.cost_key,
|
|
45
|
+
label: labels.fetch(token_key),
|
|
46
|
+
css_class: STACK_CLASSES[token_key]
|
|
47
|
+
}
|
|
48
|
+
end + [
|
|
49
|
+
{
|
|
50
|
+
token_key: :hidden_output_tokens,
|
|
51
|
+
cost_key: nil,
|
|
52
|
+
label: labels.fetch(:hidden_output_tokens),
|
|
53
|
+
css_class: nil
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
class Call < ActiveRecord::Base
|
|
8
|
+
extend Period::Grouping
|
|
9
|
+
extend Ledger::CallMetrics
|
|
10
|
+
include Ledger::Tags::Accessors
|
|
11
|
+
|
|
12
|
+
self.table_name = "llm_api_calls"
|
|
13
|
+
|
|
14
|
+
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
15
|
+
scope :without_cost, -> { where(total_cost: nil) }
|
|
16
|
+
scope :unknown_pricing, -> { without_cost }
|
|
17
|
+
scope :with_latency, -> { where.not(latency_ms: nil) }
|
|
18
|
+
scope :streaming, -> { where(stream: true) }
|
|
19
|
+
scope :non_streaming, -> { where(stream: [false, nil]) }
|
|
20
|
+
scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
|
|
21
|
+
scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
|
|
22
|
+
scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
|
|
23
|
+
scope :streaming_missing_usage, lambda {
|
|
24
|
+
where(stream: true).where(usage_source: ["unknown", nil])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
scope :with_json_tags, lambda {
|
|
28
|
+
where.not(tags: {})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
|
|
32
|
+
scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
|
|
33
|
+
scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
|
|
34
|
+
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
35
|
+
|
|
36
|
+
def self.by_tag(key, value)
|
|
37
|
+
by_tags(key => value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.by_tags(tags)
|
|
41
|
+
Ledger::Tags::Query.apply(self, tags)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "llm_cost_tracker/ledger/tags/sql"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module CallMetrics
|
|
8
|
+
def total_cost
|
|
9
|
+
sum(:total_cost).to_f
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def total_tokens
|
|
13
|
+
sum(:total_tokens).to_i
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cost_by_model(limit: nil)
|
|
17
|
+
cost_by_column(:model, limit: limit)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def cost_by_provider(limit: nil)
|
|
21
|
+
cost_by_column(:provider, limit: limit)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def group_by_tag(key)
|
|
25
|
+
group(Arel.sql(tag_value_expression(key)))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cost_by_tag(key, limit: nil)
|
|
29
|
+
expression = tag_value_expression(key)
|
|
30
|
+
label_expression = "COALESCE(NULLIF(#{expression}, ''), #{connection.quote('(untagged)')})"
|
|
31
|
+
relation = select("#{label_expression} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
32
|
+
.group(Arel.sql(label_expression))
|
|
33
|
+
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
34
|
+
relation = relation.limit(limit) if limit
|
|
35
|
+
relation
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def average_latency_ms
|
|
39
|
+
average(:latency_ms)&.to_f
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def latency_by_model
|
|
43
|
+
group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def latency_by_provider
|
|
47
|
+
group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tag_value_expression(key, table_name: quoted_table_name)
|
|
51
|
+
Ledger::Tags::Sql.value_expression(self, key, table_name: table_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def cost_by_column(column, limit:)
|
|
57
|
+
quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
|
|
58
|
+
relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
59
|
+
.group(column)
|
|
60
|
+
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
61
|
+
relation = relation.limit(limit) if limit
|
|
62
|
+
relation
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Period
|
|
8
|
+
module Grouping
|
|
9
|
+
PERIOD_FORMATS = {
|
|
10
|
+
day: {
|
|
11
|
+
postgres: "YYYY-MM-DD",
|
|
12
|
+
mysql: "%Y-%m-%d"
|
|
13
|
+
},
|
|
14
|
+
month: {
|
|
15
|
+
postgres: "YYYY-MM",
|
|
16
|
+
mysql: "%Y-%m"
|
|
17
|
+
}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
private_constant :PERIOD_FORMATS
|
|
21
|
+
|
|
22
|
+
def group_by_period(period, column: :tracked_at)
|
|
23
|
+
group(Arel.sql(period_group_expression(period, column: column)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def daily_costs(days: 30)
|
|
27
|
+
where(tracked_at: days.days.ago..)
|
|
28
|
+
.group_by_period(:day)
|
|
29
|
+
.sum(:total_cost)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def period_group_expression(period, column:)
|
|
35
|
+
period = validated_period(period)
|
|
36
|
+
column = period_column_expression(column)
|
|
37
|
+
formats = PERIOD_FORMATS.fetch(period)
|
|
38
|
+
|
|
39
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
40
|
+
postgres_period_expression(period, column, formats)
|
|
41
|
+
elsif Ledger::Schema::Adapter.mysql?(connection)
|
|
42
|
+
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
43
|
+
else
|
|
44
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def postgres_period_expression(period, column, formats)
|
|
49
|
+
"TO_CHAR(" \
|
|
50
|
+
"DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
|
|
51
|
+
"#{connection.quote(formats.fetch(:postgres))}" \
|
|
52
|
+
")"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validated_period(period)
|
|
56
|
+
normalized_period = period.try(:to_sym)
|
|
57
|
+
return normalized_period if PERIOD_FORMATS.key?(normalized_period)
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def period_column_expression(column)
|
|
63
|
+
column = column.to_s
|
|
64
|
+
return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "invalid period column: #{column.inspect}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Tags
|
|
8
|
+
module Accessors
|
|
9
|
+
def parsed_tags
|
|
10
|
+
return tags.transform_keys(&:to_s) if tags.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
JSON.parse(tags || "{}")
|
|
13
|
+
rescue JSON::ParserError
|
|
14
|
+
{}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|