llm_cost_tracker 0.7.3 → 0.9.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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +173 -0
- data/README.md +60 -220
- data/app/assets/llm_cost_tracker/application.css +282 -45
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
- data/app/models/llm_cost_tracker/call.rb +166 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
- data/app/models/llm_cost_tracker/call_tag.rb +12 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +95 -0
- data/lib/llm_cost_tracker/billing/components.yml +188 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +26 -36
- data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +86 -17
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
- data/lib/llm_cost_tracker/doctor.rb +111 -44
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +11 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
- data/lib/llm_cost_tracker/ingestion.rb +66 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
- data/lib/llm_cost_tracker/integrations/base.rb +56 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
- data/lib/llm_cost_tracker/integrations.rb +21 -3
- data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +103 -20
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +5 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
- data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
- data/lib/llm_cost_tracker/parsers/base.rb +13 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +198 -22
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +220 -28
- data/lib/llm_cost_tracker/railtie.rb +6 -8
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +19 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +22 -9
- data/lib/llm_cost_tracker/tags/context.rb +2 -5
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +64 -42
- data/lib/llm_cost_tracker/tracker.rb +97 -27
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +45 -35
- data/lib/tasks/llm_cost_tracker.rake +45 -17
- metadata +71 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<section class="lct-panel">
|
|
2
|
+
<div class="lct-toolbar-head">
|
|
3
|
+
<h2 class="lct-section-title">Provider Invoice Reconciliation <span class="lct-badge lct-badge-warn">Experimental</span></h2>
|
|
4
|
+
<% if @last_imported_at %>
|
|
5
|
+
<p class="lct-state-copy">Last import: <%= @last_imported_at.utc.iso8601 %></p>
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
<p class="lct-state-copy">
|
|
9
|
+
Experimental in v0.9.0 — public API may change in v0.9.x based on feedback.
|
|
10
|
+
<%= link_to "Open an issue", "https://github.com/sergey-homenko/llm_cost_tracker/issues", target: "_blank", rel: "noopener" %>
|
|
11
|
+
if you use it.
|
|
12
|
+
</p>
|
|
13
|
+
</section>
|
|
14
|
+
|
|
15
|
+
<% if flash[:notice] %>
|
|
16
|
+
<section class="lct-panel"><p class="lct-state-copy"><%= flash[:notice] %></p></section>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% if flash[:alert] %>
|
|
19
|
+
<section class="lct-panel"><p class="lct-state-copy"><%= flash[:alert] %></p></section>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<% if @configured_importers.any? %>
|
|
23
|
+
<section class="lct-panel">
|
|
24
|
+
<h3 class="lct-section-title">Trigger import</h3>
|
|
25
|
+
<% @configured_importers.each_key do |source| %>
|
|
26
|
+
<%= button_to "Re-import #{source}",
|
|
27
|
+
reconciliation_import_path(source: source),
|
|
28
|
+
method: :post,
|
|
29
|
+
class: "lct-button lct-button-secondary" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</section>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<% if !@reconciliation_enabled %>
|
|
35
|
+
<section class="lct-panel lct-empty">
|
|
36
|
+
<h2 class="lct-state-title">Reconciliation disabled</h2>
|
|
37
|
+
<p class="lct-state-copy">
|
|
38
|
+
Provider invoice reconciliation is opt-in because it requires admin/org-level
|
|
39
|
+
provider API keys (OpenAI <code>sk-admin-…</code>, Anthropic admin keys, GCP
|
|
40
|
+
<code>billing.viewer</code>) — separate from the runtime inference key the
|
|
41
|
+
tracker uses. Enable explicitly in the initializer:
|
|
42
|
+
</p>
|
|
43
|
+
<pre class="lct-state-pre"><code>LlmCostTracker.configure do |config|
|
|
44
|
+
config.reconciliation_enabled = true
|
|
45
|
+
end</code></pre>
|
|
46
|
+
</section>
|
|
47
|
+
<% elsif !@reconciliation_installed %>
|
|
48
|
+
<section class="lct-panel lct-empty">
|
|
49
|
+
<h2 class="lct-state-title">Reconciliation not installed</h2>
|
|
50
|
+
<p class="lct-state-copy">
|
|
51
|
+
Run the optional migration to create the reconciliation tables:
|
|
52
|
+
</p>
|
|
53
|
+
<pre class="lct-state-pre"><code>bin/rails generate llm_cost_tracker:reconciliation
|
|
54
|
+
bin/rails db:migrate</code></pre>
|
|
55
|
+
</section>
|
|
56
|
+
<% elsif @diffs.empty? %>
|
|
57
|
+
<section class="lct-panel lct-empty">
|
|
58
|
+
<h2 class="lct-state-title">No invoices imported yet</h2>
|
|
59
|
+
<p class="lct-state-copy">
|
|
60
|
+
Reconciliation compares provider-side invoices against local cost. Once you import
|
|
61
|
+
invoice rows via <code>LlmCostTracker::Reconciliation.import</code>, they appear here.
|
|
62
|
+
</p>
|
|
63
|
+
</section>
|
|
64
|
+
<% else %>
|
|
65
|
+
<section class="lct-panel">
|
|
66
|
+
<h3 class="lct-section-title">Latest period per source / provider / currency</h3>
|
|
67
|
+
<table class="lct-table">
|
|
68
|
+
<thead>
|
|
69
|
+
<tr>
|
|
70
|
+
<th>Source</th>
|
|
71
|
+
<th>Provider</th>
|
|
72
|
+
<th>Currency</th>
|
|
73
|
+
<th>Period</th>
|
|
74
|
+
<th class="lct-num">Provider total</th>
|
|
75
|
+
<th class="lct-num">Local total</th>
|
|
76
|
+
<th class="lct-num">Delta</th>
|
|
77
|
+
<th class="lct-num">%</th>
|
|
78
|
+
<th>Status</th>
|
|
79
|
+
</tr>
|
|
80
|
+
</thead>
|
|
81
|
+
<tbody>
|
|
82
|
+
<% @diffs.each do |diff| %>
|
|
83
|
+
<tr>
|
|
84
|
+
<td><%= diff.source %></td>
|
|
85
|
+
<td><%= diff.provider %></td>
|
|
86
|
+
<td><%= diff.currency %></td>
|
|
87
|
+
<td><%= diff.period_start %> → <%= diff.period_end %></td>
|
|
88
|
+
<td class="lct-num"><%= money(diff.provider_total) %></td>
|
|
89
|
+
<td class="lct-num"><%= money(diff.local_total) %></td>
|
|
90
|
+
<td class="lct-num"><%= money(diff.delta_amount) %></td>
|
|
91
|
+
<td class="lct-num"><%= diff.delta_percent.nil? ? "—" : "#{diff.delta_percent}%" %></td>
|
|
92
|
+
<td>
|
|
93
|
+
<% if diff.aligned?(threshold_percent: @threshold) %>
|
|
94
|
+
<span class="lct-badge lct-badge-ok">Aligned</span>
|
|
95
|
+
<% else %>
|
|
96
|
+
<span class="lct-badge lct-badge-warn">Drift</span>
|
|
97
|
+
<% end %>
|
|
98
|
+
</td>
|
|
99
|
+
</tr>
|
|
100
|
+
<% end %>
|
|
101
|
+
</tbody>
|
|
102
|
+
</table>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<% @diffs.each do |diff| %>
|
|
106
|
+
<% next if diff.unmatched_provider_rows.empty? && diff.unmatched_local_calls.empty? && diff.non_cost_rows.empty? %>
|
|
107
|
+
|
|
108
|
+
<section class="lct-panel">
|
|
109
|
+
<h3 class="lct-section-title"><%= diff.source %> / <%= diff.provider %> / <%= diff.currency %> — drill down</h3>
|
|
110
|
+
|
|
111
|
+
<% if diff.unmatched_provider_rows.any? %>
|
|
112
|
+
<h4 class="lct-state-title">
|
|
113
|
+
Provider rows without a matching local call
|
|
114
|
+
<% if diff.unmatched_provider_rows_truncated? %>
|
|
115
|
+
<small>(showing <%= diff.unmatched_provider_rows.size %> of <%= diff.unmatched_provider_rows_total %>, ranked by billed amount)</small>
|
|
116
|
+
<% end %>
|
|
117
|
+
</h4>
|
|
118
|
+
<table class="lct-table">
|
|
119
|
+
<thead>
|
|
120
|
+
<tr><th>External ID</th><th>Match basis</th><th>Attribution</th><th class="lct-num">Billed</th></tr>
|
|
121
|
+
</thead>
|
|
122
|
+
<tbody>
|
|
123
|
+
<% diff.unmatched_provider_rows.each do |row| %>
|
|
124
|
+
<tr>
|
|
125
|
+
<td><%= row[:external_id] %></td>
|
|
126
|
+
<td><%= row[:match_basis] %></td>
|
|
127
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
128
|
+
<td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
|
|
129
|
+
</tr>
|
|
130
|
+
<% end %>
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
<% end %>
|
|
134
|
+
|
|
135
|
+
<% if diff.unmatched_local_calls.any? %>
|
|
136
|
+
<h4 class="lct-state-title">
|
|
137
|
+
Local calls no provider invoice can explain
|
|
138
|
+
<% if diff.unmatched_local_calls_truncated? %>
|
|
139
|
+
<small>(showing <%= diff.unmatched_local_calls.size %> of <%= diff.unmatched_local_calls_total %>, ranked by total cost)</small>
|
|
140
|
+
<% end %>
|
|
141
|
+
</h4>
|
|
142
|
+
<table class="lct-table">
|
|
143
|
+
<thead>
|
|
144
|
+
<tr><th>Attribution</th><th class="lct-num">Calls</th><th class="lct-num">Total cost</th></tr>
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody>
|
|
147
|
+
<% diff.unmatched_local_calls.each do |row| %>
|
|
148
|
+
<tr>
|
|
149
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
150
|
+
<td class="lct-num"><%= number(row[:count]) %></td>
|
|
151
|
+
<td class="lct-num"><%= money(row[:total_cost]) %></td>
|
|
152
|
+
</tr>
|
|
153
|
+
<% end %>
|
|
154
|
+
</tbody>
|
|
155
|
+
</table>
|
|
156
|
+
<% end %>
|
|
157
|
+
|
|
158
|
+
<% if diff.non_cost_rows.any? %>
|
|
159
|
+
<h4 class="lct-state-title">
|
|
160
|
+
Non-cost evidence (free quota, credits, adjustments)
|
|
161
|
+
<% if diff.non_cost_rows_truncated? %>
|
|
162
|
+
<small>(showing <%= diff.non_cost_rows.size %> of <%= diff.non_cost_rows_total %>, ranked by amount)</small>
|
|
163
|
+
<% end %>
|
|
164
|
+
</h4>
|
|
165
|
+
<table class="lct-table">
|
|
166
|
+
<thead>
|
|
167
|
+
<tr><th>Row type</th><th>Meter</th><th>Attribution</th><th class="lct-num">Amount</th></tr>
|
|
168
|
+
</thead>
|
|
169
|
+
<tbody>
|
|
170
|
+
<% diff.non_cost_rows.each do |row| %>
|
|
171
|
+
<tr>
|
|
172
|
+
<td><%= row[:row_type] %></td>
|
|
173
|
+
<td><%= row[:meter] %></td>
|
|
174
|
+
<td><%= attribution_summary(row[:attribution]) %></td>
|
|
175
|
+
<td class="lct-num"><%= optional_money(row[:billed_amount]) %></td>
|
|
176
|
+
</tr>
|
|
177
|
+
<% end %>
|
|
178
|
+
</tbody>
|
|
179
|
+
</table>
|
|
180
|
+
<% end %>
|
|
181
|
+
</section>
|
|
182
|
+
<% end %>
|
|
183
|
+
<% end %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<% variant_class = local_assigns[:variant] == "budget" ? " lct-budget-fill" : "" %>
|
|
2
2
|
|
|
3
3
|
<div class="lct-bar-track" aria-hidden="true">
|
|
4
|
-
<div
|
|
4
|
+
<div data-lct-style="<%= inline_style("width: #{bar_width(value, max)}") %>" class="lct-bar-fill<%= variant_class %>"></div>
|
|
5
5
|
</div>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<%
|
|
2
|
+
fields = local_assigns.fetch(:fields, %i[from to provider model stream])
|
|
3
|
+
default_range = LlmCostTracker::Dashboard::DateRange.call(params: params)
|
|
4
|
+
defaults = {
|
|
5
|
+
from: default_range.from.iso8601,
|
|
6
|
+
to: default_range.to.iso8601
|
|
7
|
+
}.merge(local_assigns.fetch(:defaults, {}))
|
|
8
|
+
reset_path = local_assigns.fetch(:reset_path, path)
|
|
9
|
+
filter_scope = local_assigns.fetch(:filter_scope, params)
|
|
10
|
+
%>
|
|
11
|
+
|
|
12
|
+
<form class="lct-filters" action="<%= path %>" method="get">
|
|
13
|
+
<% local_assigns.fetch(:hidden_fields, {}).each do |key, val| %>
|
|
14
|
+
<%= hidden_field_tag(key, val) %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<div class="lct-filter-row">
|
|
17
|
+
<% if fields.include?(:from) %>
|
|
18
|
+
<div class="lct-field">
|
|
19
|
+
<label for="lct-filter-from">From</label>
|
|
20
|
+
<input id="lct-filter-from" type="date" name="from" value="<%= params[:from] || defaults[:from] %>">
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
|
|
24
|
+
<% if fields.include?(:to) %>
|
|
25
|
+
<div class="lct-field">
|
|
26
|
+
<label for="lct-filter-to">To</label>
|
|
27
|
+
<input id="lct-filter-to" type="date" name="to" value="<%= params[:to] || defaults[:to] %>">
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<% if fields.include?(:provider) %>
|
|
32
|
+
<div class="lct-field">
|
|
33
|
+
<label for="lct-filter-provider">Provider</label>
|
|
34
|
+
<%= select_tag :provider,
|
|
35
|
+
options_for_select(provider_filter_options(filter_params: filter_scope), params[:provider]),
|
|
36
|
+
include_blank: "All providers",
|
|
37
|
+
id: "lct-filter-provider" %>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<% if fields.include?(:model) %>
|
|
42
|
+
<div class="lct-field">
|
|
43
|
+
<label for="lct-filter-model">Model</label>
|
|
44
|
+
<%= select_tag :model,
|
|
45
|
+
options_for_select(model_filter_options(filter_params: filter_scope), params[:model]),
|
|
46
|
+
include_blank: "All models",
|
|
47
|
+
id: "lct-filter-model" %>
|
|
48
|
+
</div>
|
|
49
|
+
<% end %>
|
|
50
|
+
|
|
51
|
+
<% if fields.include?(:stream) %>
|
|
52
|
+
<div class="lct-field">
|
|
53
|
+
<label for="lct-filter-stream">Stream</label>
|
|
54
|
+
<%= select_tag :stream,
|
|
55
|
+
options_for_select(LlmCostTracker::DashboardFilterHelper::STREAM_FILTER_OPTIONS, params[:stream]),
|
|
56
|
+
include_blank: "All calls",
|
|
57
|
+
id: "lct-filter-stream" %>
|
|
58
|
+
</div>
|
|
59
|
+
<% end %>
|
|
60
|
+
|
|
61
|
+
<div class="lct-filter-actions">
|
|
62
|
+
<button class="lct-button" type="submit">Apply</button>
|
|
63
|
+
<%= link_to("Reset", reset_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</form>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<% else %>
|
|
6
6
|
<div class="lct-stack-track" aria-hidden="true">
|
|
7
7
|
<% visible_segments.each do |segment| %>
|
|
8
|
-
<span
|
|
8
|
+
<span data-lct-style="<%= inline_style("width: #{segment[:percent].round(2)}%") %>" class="lct-stack-fill <%= segment[:css_class] %>"></span>
|
|
9
9
|
<% end %>
|
|
10
10
|
</div>
|
|
11
11
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%
|
|
2
|
+
current = local_assigns.fetch(:current).to_s
|
|
3
|
+
options = local_assigns.fetch(:options)
|
|
4
|
+
%>
|
|
5
|
+
|
|
6
|
+
<nav class="lct-sort" aria-label="Sort by">
|
|
7
|
+
<% options.each do |label, value| %>
|
|
8
|
+
<%= link_to label,
|
|
9
|
+
path_for_sort.call(value),
|
|
10
|
+
class: ["lct-sort-option", ("is-active" if current == value.to_s)].compact.join(" "),
|
|
11
|
+
aria: ({ current: "true" } if current == value.to_s) %>
|
|
12
|
+
<% end %>
|
|
13
|
+
</nav>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<section class="lct-panel lct-empty">
|
|
2
2
|
<h2 class="lct-state-title">Setup required</h2>
|
|
3
3
|
<p class="lct-state-copy">
|
|
4
|
-
<%= @setup_message || "The
|
|
4
|
+
<%= @setup_message || "The llm_cost_tracker_calls table is not available yet." %>
|
|
5
5
|
<% if @setup_details.present? %>
|
|
6
6
|
Run <span class="lct-code">bin/rails llm_cost_tracker:doctor</span>, apply the listed migrations, and migrate your database.
|
|
7
7
|
<% else %>
|
|
@@ -3,40 +3,9 @@
|
|
|
3
3
|
<h2 class="lct-section-title">Tag keys</h2>
|
|
4
4
|
</div>
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
<label for="lct-tags-from">From</label>
|
|
10
|
-
<input id="lct-tags-from" type="date" name="from" value="<%= params[:from] %>">
|
|
11
|
-
</div>
|
|
12
|
-
|
|
13
|
-
<div class="lct-field">
|
|
14
|
-
<label for="lct-tags-to">To</label>
|
|
15
|
-
<input id="lct-tags-to" type="date" name="to" value="<%= params[:to] %>">
|
|
16
|
-
</div>
|
|
17
|
-
|
|
18
|
-
<div class="lct-field">
|
|
19
|
-
<label for="lct-tags-provider">Provider</label>
|
|
20
|
-
<%= select_tag :provider,
|
|
21
|
-
options_for_select(provider_filter_options, params[:provider]),
|
|
22
|
-
include_blank: "All providers",
|
|
23
|
-
id: "lct-tags-provider" %>
|
|
24
|
-
</div>
|
|
25
|
-
|
|
26
|
-
<div class="lct-field">
|
|
27
|
-
<label for="lct-tags-model">Model</label>
|
|
28
|
-
<%= select_tag :model,
|
|
29
|
-
options_for_select(model_filter_options, params[:model]),
|
|
30
|
-
include_blank: "All models",
|
|
31
|
-
id: "lct-tags-model" %>
|
|
32
|
-
</div>
|
|
33
|
-
|
|
34
|
-
<div class="lct-filter-actions">
|
|
35
|
-
<button class="lct-button" type="submit">Apply</button>
|
|
36
|
-
<%= link_to("Reset", tags_path, class: "lct-button lct-button-secondary") if any_filter_applied? %>
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
</form>
|
|
6
|
+
<%= render "llm_cost_tracker/shared/filters",
|
|
7
|
+
path: tags_path,
|
|
8
|
+
fields: %i[from to provider model] %>
|
|
40
9
|
|
|
41
10
|
<%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tags_path %>
|
|
42
11
|
</section>
|
|
@@ -1,47 +1,73 @@
|
|
|
1
|
-
<%
|
|
2
|
-
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
<% if @value.present? %>
|
|
2
|
+
<section class="lct-panel lct-toolbar">
|
|
3
|
+
<div class="lct-toolbar-head">
|
|
4
|
+
<div>
|
|
5
|
+
<p class="lct-muted"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
|
|
6
|
+
<h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code> = <code class="lct-code"><%= @value %></code></h2>
|
|
7
|
+
</div>
|
|
8
8
|
</div>
|
|
9
|
-
</div>
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
<%= render "llm_cost_tracker/shared/filters",
|
|
11
|
+
path: tag_path(params[:key]),
|
|
12
|
+
fields: %i[from to provider model],
|
|
13
|
+
hidden_fields: { tag_value: @value },
|
|
14
|
+
reset_path: tag_path(params[:key], tag_value: @value) %>
|
|
15
|
+
</section>
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
<% if @value_calls.zero? %>
|
|
18
|
+
<section class="lct-panel lct-empty">
|
|
19
|
+
<h2 class="lct-state-title">No calls tagged with <%= params[:key] %>=<%= @value %></h2>
|
|
20
|
+
<p class="lct-state-copy">No matching calls in the current slice.</p>
|
|
21
|
+
<div class="lct-state-actions">
|
|
22
|
+
<%= link_to "Back to values", tag_path(params[:key]), class: "lct-button lct-button-secondary" %>
|
|
21
23
|
</div>
|
|
24
|
+
</section>
|
|
25
|
+
<% else %>
|
|
26
|
+
<section class="lct-stat-grid lct-stat-grid-spaced">
|
|
27
|
+
<article class="lct-stat">
|
|
28
|
+
<p class="lct-stat-label">Total cost</p>
|
|
29
|
+
<p class="lct-stat-value"><%= money(@value_total_cost) %></p>
|
|
30
|
+
<p class="lct-stat-copy">Across <%= number(@value_calls) %> calls</p>
|
|
31
|
+
</article>
|
|
22
32
|
|
|
23
|
-
<
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
id: "lct-tag-show-provider" %>
|
|
29
|
-
</div>
|
|
33
|
+
<article class="lct-stat">
|
|
34
|
+
<p class="lct-stat-label">Calls</p>
|
|
35
|
+
<p class="lct-stat-value"><%= number(@value_calls) %></p>
|
|
36
|
+
<p class="lct-stat-copy">Tagged with <code class="lct-code"><%= @value %></code></p>
|
|
37
|
+
</article>
|
|
30
38
|
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</div>
|
|
39
|
+
<article class="lct-stat">
|
|
40
|
+
<p class="lct-stat-label">Avg cost / call</p>
|
|
41
|
+
<p class="lct-stat-value"><%= money(@value_calls.positive? ? @value_total_cost / @value_calls : 0) %></p>
|
|
42
|
+
<p class="lct-stat-copy">Mean over the slice</p>
|
|
43
|
+
</article>
|
|
44
|
+
</section>
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
<section class="lct-panel">
|
|
47
|
+
<div class="lct-section-head">
|
|
48
|
+
<div>
|
|
49
|
+
<h2 class="lct-section-title">Spend over time</h2>
|
|
50
|
+
<p class="lct-section-copy">Daily total cost for calls tagged <code class="lct-code"><%= params[:key] %>=<%= @value %></code>.</p>
|
|
51
|
+
</div>
|
|
52
|
+
<%= link_to "Calls", calls_path(calls_query_for_tag(key: @key, value: @value)), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
42
53
|
</div>
|
|
54
|
+
|
|
55
|
+
<%= render "llm_cost_tracker/shared/spend_chart", series: @value_points %>
|
|
56
|
+
</section>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% else %>
|
|
59
|
+
<section class="lct-panel lct-toolbar">
|
|
60
|
+
<div class="lct-toolbar-head">
|
|
61
|
+
<div>
|
|
62
|
+
<p class="lct-muted"><%= link_to "← All tag keys", tags_path(current_query) %></p>
|
|
63
|
+
<h2 class="lct-section-title">Tag: <code class="lct-code"><%= params[:key] %></code></h2>
|
|
43
64
|
</div>
|
|
44
|
-
</
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<%= render "llm_cost_tracker/shared/filters",
|
|
68
|
+
path: tag_path(params[:key]),
|
|
69
|
+
fields: %i[from to provider model],
|
|
70
|
+
reset_path: tag_path(params[:key]) %>
|
|
45
71
|
|
|
46
72
|
<%= render "llm_cost_tracker/shared/active_filters", chips: active_tag_filters, clear_path: tag_path(params[:key]) %>
|
|
47
73
|
|
|
@@ -103,13 +129,14 @@
|
|
|
103
129
|
<tr>
|
|
104
130
|
<td><code class="lct-code"><%= row.value %></code></td>
|
|
105
131
|
<td class="lct-num"><%= number(row.calls) %></td>
|
|
106
|
-
<td class="lct-num"><%= percent(
|
|
132
|
+
<td class="lct-num"><%= percent(row.share_percent) %></td>
|
|
107
133
|
<td class="lct-num"><%= money(row.total_cost) %></td>
|
|
108
134
|
<td class="lct-num"><%= money(row.average_cost_per_call) %></td>
|
|
109
135
|
<td>
|
|
110
136
|
<% if row.value == "(untagged)" %>
|
|
111
137
|
<span class="lct-muted">n/a</span>
|
|
112
138
|
<% else %>
|
|
139
|
+
<%= link_to "Trend", tag_path(params[:key], current_query.merge(tag_value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
113
140
|
<%= link_to "Calls", calls_path(calls_query_for_tag(key: params[:key], value: row.value)), class: "lct-button lct-button-secondary lct-button-compact" %>
|
|
114
141
|
<% end %>
|
|
115
142
|
</td>
|
|
@@ -120,3 +147,4 @@
|
|
|
120
147
|
</div>
|
|
121
148
|
</section>
|
|
122
149
|
<% end %>
|
|
150
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -4,9 +4,10 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
4
4
|
root "dashboard#index"
|
|
5
5
|
resources :calls, only: %i[index show], constraints: { id: /\d+/ }, defaults: { format: :html }
|
|
6
6
|
resources :models, only: :index
|
|
7
|
-
|
|
8
|
-
get "tags/:key", to: "tags#show", as: :tag, format: false
|
|
7
|
+
resources :tags, only: %i[index show], param: :key, format: false
|
|
9
8
|
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
9
|
+
get "reconciliation", to: "reconciliation#index", as: :reconciliation
|
|
10
|
+
post "reconciliation/import", to: "reconciliation#trigger_import", as: :reconciliation_import
|
|
10
11
|
|
|
11
12
|
get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
|
|
12
13
|
to: "assets#stylesheet", as: :stylesheet
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "psych"
|
|
4
|
+
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Billing
|
|
9
|
+
RATE_BASES = %i[
|
|
10
|
+
per_million_tokens
|
|
11
|
+
per_million_characters
|
|
12
|
+
per_request
|
|
13
|
+
per_1k_requests
|
|
14
|
+
per_session
|
|
15
|
+
per_hour
|
|
16
|
+
per_gb_day
|
|
17
|
+
per_image
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
RATE_BASIS_QUANTITIES = {
|
|
21
|
+
per_million_tokens: 1_000_000,
|
|
22
|
+
per_million_characters: 1_000_000,
|
|
23
|
+
per_request: 1,
|
|
24
|
+
per_1k_requests: 1_000,
|
|
25
|
+
per_session: 1,
|
|
26
|
+
per_hour: 1,
|
|
27
|
+
per_gb_day: 1,
|
|
28
|
+
per_image: 1
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
DEFAULT_RATE_BASIS_BY_UNIT = {
|
|
32
|
+
token: :per_million_tokens,
|
|
33
|
+
character: :per_million_characters,
|
|
34
|
+
request: :per_request,
|
|
35
|
+
session: :per_session,
|
|
36
|
+
hour: :per_hour,
|
|
37
|
+
image: :per_image
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
module Components
|
|
41
|
+
Component = Data.define(
|
|
42
|
+
:key,
|
|
43
|
+
:kind,
|
|
44
|
+
:direction,
|
|
45
|
+
:modality,
|
|
46
|
+
:cache_state,
|
|
47
|
+
:unit,
|
|
48
|
+
:category,
|
|
49
|
+
:token_key,
|
|
50
|
+
:cost_key,
|
|
51
|
+
:rate_basis
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
REQUIRED_FIELDS = %i[key kind direction modality cache_state unit category].freeze
|
|
55
|
+
DEFINITIONS_PATH = File.expand_path("components.yml", __dir__)
|
|
56
|
+
|
|
57
|
+
def self.load_registry
|
|
58
|
+
Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
|
|
59
|
+
.map { |attributes| build(attributes) }
|
|
60
|
+
.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.build(attributes)
|
|
64
|
+
missing = REQUIRED_FIELDS - attributes.keys
|
|
65
|
+
raise Error, "components.yml entry missing #{missing.join(', ')}: #{attributes.inspect}" if missing.any?
|
|
66
|
+
|
|
67
|
+
unit = attributes.fetch(:unit).to_sym
|
|
68
|
+
rate_basis = attributes[:rate_basis]&.to_sym || Billing::DEFAULT_RATE_BASIS_BY_UNIT[unit]
|
|
69
|
+
if rate_basis.nil?
|
|
70
|
+
raise Error, "components.yml entry needs rate_basis for unit #{unit.inspect}: #{attributes.inspect}"
|
|
71
|
+
end
|
|
72
|
+
unless Billing::RATE_BASES.include?(rate_basis)
|
|
73
|
+
raise Error, "components.yml entry has unknown rate_basis #{rate_basis.inspect}: #{attributes.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Component.new(
|
|
77
|
+
key: attributes.fetch(:key).to_sym,
|
|
78
|
+
kind: attributes.fetch(:kind).to_sym,
|
|
79
|
+
direction: attributes.fetch(:direction).to_sym,
|
|
80
|
+
modality: attributes.fetch(:modality).to_sym,
|
|
81
|
+
cache_state: attributes.fetch(:cache_state).to_sym,
|
|
82
|
+
unit: unit,
|
|
83
|
+
category: attributes.fetch(:category).to_sym,
|
|
84
|
+
token_key: attributes[:token_key]&.to_sym,
|
|
85
|
+
cost_key: attributes[:cost_key]&.to_sym,
|
|
86
|
+
rate_basis: rate_basis
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
REGISTRY = load_registry
|
|
91
|
+
BY_KEY = REGISTRY.to_h { |component| [component.key, component] }.freeze
|
|
92
|
+
TOKEN_PRICED = REGISTRY.select { |component| component.token_key && component.cost_key }.freeze
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|