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,55 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
def change
|
|
5
|
+
create_table :llm_cost_tracker_provider_invoices, if_not_exists: true do |t|
|
|
6
|
+
t.string :source, null: false
|
|
7
|
+
t.date :period_start, null: false
|
|
8
|
+
t.date :period_end, null: false
|
|
9
|
+
t.string :external_id, null: false
|
|
10
|
+
t.decimal :billed_amount, precision: 20, scale: 8
|
|
11
|
+
t.string :currency, null: false, default: "USD"
|
|
12
|
+
if postgresql?
|
|
13
|
+
t.jsonb :metadata, null: false, default: {}
|
|
14
|
+
elsif mysql?
|
|
15
|
+
t.json :metadata, null: false
|
|
16
|
+
else
|
|
17
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
18
|
+
end
|
|
19
|
+
t.datetime :imported_at, null: false
|
|
20
|
+
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
|
|
25
|
+
t.string :source, null: false
|
|
26
|
+
t.string :cursor
|
|
27
|
+
t.date :window_start
|
|
28
|
+
t.date :window_end
|
|
29
|
+
t.string :state, null: false
|
|
30
|
+
t.text :last_error
|
|
31
|
+
t.integer :rows_imported, null: false, default: 0
|
|
32
|
+
t.datetime :started_at, null: false
|
|
33
|
+
t.datetime :finished_at
|
|
34
|
+
|
|
35
|
+
t.timestamps
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true,
|
|
39
|
+
if_not_exists: true
|
|
40
|
+
add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
|
|
41
|
+
if_not_exists: true
|
|
42
|
+
add_index :llm_cost_tracker_provider_invoice_imports, %i[source started_at],
|
|
43
|
+
if_not_exists: true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def postgresql?
|
|
49
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def mysql?
|
|
53
|
+
LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -4,7 +4,10 @@ LlmCostTracker.configure do |config|
|
|
|
4
4
|
# Set to false to temporarily disable tracking without removing middleware.
|
|
5
5
|
config.enabled = true
|
|
6
6
|
|
|
7
|
-
#
|
|
7
|
+
# LLM Cost Tracker logs warnings through Rails.logger when available.
|
|
8
|
+
config.log_level = :info
|
|
9
|
+
|
|
10
|
+
# Tags merged into every event. Use a callable for request/job-time context.
|
|
8
11
|
config.default_tags = -> { { environment: Rails.env } }
|
|
9
12
|
|
|
10
13
|
# Tag guardrails keep accidental high-cardinality or sensitive values out of the ledger.
|
|
@@ -18,40 +21,40 @@ LlmCostTracker.configure do |config|
|
|
|
18
21
|
# config.instrument :anthropic
|
|
19
22
|
# config.instrument :ruby_llm
|
|
20
23
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
config.budget_exceeded_behavior = :notify
|
|
24
|
-
|
|
25
|
-
# Unknown pricing records token usage with nil cost by default. Use :raise if
|
|
26
|
-
# every model must have known pricing before it can be used.
|
|
27
|
-
config.unknown_pricing_behavior = :warn
|
|
28
|
-
|
|
29
|
-
# LLM Cost Tracker logs warnings through Rails.logger when available.
|
|
30
|
-
config.log_level = :info
|
|
24
|
+
# Pricing — local file refreshed via bin/rails llm_cost_tracker:prices:refresh
|
|
25
|
+
# plus inline overrides. Prices are USD per 1M tokens.
|
|
31
26
|
<% if options[:prices] -%>
|
|
32
|
-
|
|
33
|
-
# Local JSON/YAML pricing file generated by --prices. Keep it in source control
|
|
34
|
-
# and refresh it with bin/rails llm_cost_tracker:prices:refresh.
|
|
35
27
|
config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
28
|
+
<% else -%>
|
|
29
|
+
# config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
36
30
|
<% end -%>
|
|
31
|
+
# config.pricing_overrides = {
|
|
32
|
+
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
33
|
+
# }
|
|
34
|
+
# :warn (default) records token usage with nil cost when a model has no rate.
|
|
35
|
+
# Use :raise to require known pricing for every model.
|
|
36
|
+
config.unknown_pricing_behavior = :warn
|
|
37
37
|
|
|
38
|
-
#
|
|
38
|
+
# Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
|
|
39
|
+
# plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
|
|
40
|
+
# raises after recording; :block_requests preflights supported requests) and
|
|
41
|
+
# an optional callback. Cap evaluation reads from llm_cost_tracker_calls live;
|
|
42
|
+
# flip cache_rollups to true at high volume so reads hit the rollups table
|
|
43
|
+
# instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
39
44
|
# config.monthly_budget = 100.00
|
|
40
45
|
# config.daily_budget = 10.00
|
|
41
46
|
# config.per_call_budget = 1.00
|
|
42
|
-
|
|
43
|
-
# Called when :notify is selected and a monthly, daily, or per-call budget is exceeded.
|
|
47
|
+
config.budget_exceeded_behavior = :notify
|
|
44
48
|
# config.on_budget_exceeded = ->(data) {
|
|
45
|
-
# Rails.logger.warn(
|
|
46
|
-
# "LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}"
|
|
47
|
-
# )
|
|
49
|
+
# Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
|
|
48
50
|
# }
|
|
51
|
+
# config.cache_rollups = true
|
|
49
52
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
53
|
+
# Ingestion path — false (default) writes events synchronously from the request
|
|
54
|
+
# thread. Flip to true for a write-ahead inbox + background worker that batches
|
|
55
|
+
# inserts and survives caller transaction rollbacks. Requires the optional
|
|
56
|
+
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:durable_ingestion`.
|
|
57
|
+
# config.durable_ingestion = true
|
|
55
58
|
|
|
56
59
|
# Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
|
|
57
60
|
# for bin/rails llm_cost_tracker:report.
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
TABLE = :llm_cost_tracker_call_rollups
|
|
3
|
+
OLD_INDEX = %i[period period_start currency].freeze
|
|
4
|
+
NEW_INDEX = %i[period period_start currency provider].freeze
|
|
5
|
+
|
|
6
|
+
def up
|
|
7
|
+
unless column_exists?(TABLE, :provider)
|
|
8
|
+
execute "DELETE FROM #{TABLE}"
|
|
9
|
+
add_column TABLE, :provider, :string, null: false, default: ""
|
|
10
|
+
end
|
|
11
|
+
remove_index TABLE, column: OLD_INDEX, unique: true if index_exists?(TABLE, OLD_INDEX, unique: true)
|
|
12
|
+
add_index TABLE, NEW_INDEX, unique: true unless index_exists?(TABLE, NEW_INDEX, unique: true)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def down
|
|
16
|
+
remove_index TABLE, column: NEW_INDEX, unique: true if index_exists?(TABLE, NEW_INDEX, unique: true)
|
|
17
|
+
add_index TABLE, OLD_INDEX, unique: true unless index_exists?(TABLE, OLD_INDEX, unique: true)
|
|
18
|
+
remove_column TABLE, :provider if column_exists?(TABLE, :provider)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
TABLE = :llm_cost_tracker_call_tags
|
|
5
|
+
INDEX_COLUMNS = %i[key value].freeze
|
|
6
|
+
|
|
7
|
+
def up
|
|
8
|
+
return if index_exists?(TABLE, INDEX_COLUMNS)
|
|
9
|
+
|
|
10
|
+
if postgresql?
|
|
11
|
+
add_index TABLE, INDEX_COLUMNS
|
|
12
|
+
elsif mysql?
|
|
13
|
+
add_index TABLE, INDEX_COLUMNS, length: { value: 191 }
|
|
14
|
+
else
|
|
15
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def down
|
|
20
|
+
remove_index TABLE, column: INDEX_COLUMNS if index_exists?(TABLE, INDEX_COLUMNS)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def postgresql?
|
|
26
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mysql?
|
|
30
|
+
LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class UpgradeLlmCostTrackerImageTokens < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
TABLE = :llm_cost_tracker_calls
|
|
3
|
+
COLUMNS = %i[image_input_tokens image_output_tokens].freeze
|
|
4
|
+
|
|
5
|
+
def up
|
|
6
|
+
COLUMNS.each do |column|
|
|
7
|
+
next if column_exists?(TABLE, column)
|
|
8
|
+
|
|
9
|
+
add_column TABLE, column, :integer, null: false, default: 0
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def down
|
|
14
|
+
COLUMNS.each do |column|
|
|
15
|
+
remove_column TABLE, column if column_exists?(TABLE, column)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class UpgradeCallRollupsProviderGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds the v0.9 provider column and unique index to llm_cost_tracker_call_rollups."
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"upgrade_call_rollups_provider.rb.erb",
|
|
18
|
+
"db/migrate/upgrade_llm_cost_tracker_call_rollups_provider.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def warn_about_rollups_truncation
|
|
23
|
+
say(<<~MSG, :yellow)
|
|
24
|
+
The migration clears existing llm_cost_tracker_call_rollups rows before adding the
|
|
25
|
+
provider column. Budget reads fall back to live aggregation from
|
|
26
|
+
llm_cost_tracker_calls until new events repopulate the rollups under their provider
|
|
27
|
+
keys. See docs/upgrading.md for details.
|
|
28
|
+
MSG
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def migration_version
|
|
34
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -5,17 +5,18 @@ require "rails/generators/active_record"
|
|
|
5
5
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
module Generators
|
|
8
|
-
class
|
|
8
|
+
class UpgradeCallTagsKeyValueIndexGenerator < Rails::Generators::Base
|
|
9
9
|
include ActiveRecord::Generators::Migration
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "
|
|
13
|
+
desc "Adds a (key, value) composite index on llm_cost_tracker_call_tags " \
|
|
14
|
+
"so high-cardinality tag filters use an index lookup instead of a key-only scan."
|
|
14
15
|
|
|
15
16
|
def create_migration_file
|
|
16
17
|
migration_template(
|
|
17
|
-
"
|
|
18
|
-
"db/migrate/
|
|
18
|
+
"upgrade_call_tags_key_value_index.rb.erb",
|
|
19
|
+
"db/migrate/upgrade_llm_cost_tracker_call_tags_key_value_index.rb"
|
|
19
20
|
)
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -5,17 +5,17 @@ require "rails/generators/active_record"
|
|
|
5
5
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
module Generators
|
|
8
|
-
class
|
|
8
|
+
class UpgradeImageTokensGenerator < Rails::Generators::Base
|
|
9
9
|
include ActiveRecord::Generators::Migration
|
|
10
10
|
|
|
11
11
|
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "
|
|
13
|
+
desc "Adds image_input_tokens and image_output_tokens columns to llm_cost_tracker_calls."
|
|
14
14
|
|
|
15
15
|
def create_migration_file
|
|
16
16
|
migration_template(
|
|
17
|
-
"
|
|
18
|
-
"db/migrate/
|
|
17
|
+
"upgrade_image_tokens.rb.erb",
|
|
18
|
+
"db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -27,7 +27,7 @@ module LlmCostTracker
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def pending?
|
|
30
|
-
Ingestion::
|
|
30
|
+
Ingestion::InboxEntry.where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE).exists?
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def claimable?
|
|
@@ -37,7 +37,7 @@ module LlmCostTracker
|
|
|
37
37
|
def mark_failed(rows, error)
|
|
38
38
|
message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
|
|
39
39
|
now = Time.now.utc
|
|
40
|
-
Ingestion::
|
|
40
|
+
Ingestion::InboxEntry
|
|
41
41
|
.where(id: rows.map(&:id), locked_by: identity)
|
|
42
42
|
.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
|
|
43
43
|
rescue StandardError
|
|
@@ -51,16 +51,15 @@ module LlmCostTracker
|
|
|
51
51
|
def claim
|
|
52
52
|
now = Time.now.utc
|
|
53
53
|
cutoff = now - LOCK_TIMEOUT_SECONDS
|
|
54
|
-
Ingestion::
|
|
54
|
+
Ingestion::InboxEntry.transaction do
|
|
55
55
|
rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
|
|
56
|
-
|
|
57
|
-
next [] if ids.empty?
|
|
56
|
+
next [] if rows.empty?
|
|
58
57
|
|
|
59
|
-
updates = Ingestion::
|
|
58
|
+
updates = Ingestion::InboxEntry.sanitize_sql_array(
|
|
60
59
|
["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
|
|
61
60
|
)
|
|
62
|
-
Ingestion::
|
|
63
|
-
|
|
61
|
+
Ingestion::InboxEntry.where(id: rows.map(&:id)).update_all(updates)
|
|
62
|
+
rows
|
|
64
63
|
end
|
|
65
64
|
end
|
|
66
65
|
|
|
@@ -77,15 +76,15 @@ module LlmCostTracker
|
|
|
77
76
|
end
|
|
78
77
|
|
|
79
78
|
def persist(rows, events)
|
|
80
|
-
LlmCostTracker::
|
|
79
|
+
LlmCostTracker::Call.transaction do
|
|
81
80
|
Ledger::Store.insert_many(events)
|
|
82
|
-
Ingestion::
|
|
81
|
+
Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
83
82
|
end
|
|
84
83
|
end
|
|
85
84
|
|
|
86
85
|
def claimable_scope(cutoff)
|
|
87
|
-
Ingestion::
|
|
88
|
-
.where("attempts < ?", Ingestion::
|
|
86
|
+
Ingestion::InboxEntry
|
|
87
|
+
.where("attempts < ?", Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE)
|
|
89
88
|
.where("locked_at IS NULL OR locked_at < ?", cutoff)
|
|
90
89
|
end
|
|
91
90
|
end
|
|
@@ -5,11 +5,12 @@ require "time"
|
|
|
5
5
|
|
|
6
6
|
require_relative "../event"
|
|
7
7
|
require_relative "../pricing"
|
|
8
|
+
require_relative "../billing/line_item"
|
|
8
9
|
|
|
9
10
|
module LlmCostTracker
|
|
10
11
|
module Ingestion
|
|
11
12
|
class Inbox
|
|
12
|
-
PAYLOAD_SCHEMA_VERSION =
|
|
13
|
+
PAYLOAD_SCHEMA_VERSION = 2
|
|
13
14
|
|
|
14
15
|
class << self
|
|
15
16
|
def save(event)
|
|
@@ -19,32 +20,47 @@ module LlmCostTracker
|
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def event_from_row(row)
|
|
22
|
-
payload = JSON.parse(row.payload)
|
|
23
|
-
schema_version = payload
|
|
24
|
-
unless
|
|
23
|
+
payload = JSON.parse(row.payload, symbolize_names: true)
|
|
24
|
+
schema_version = payload[:schema_version]
|
|
25
|
+
unless schema_version == PAYLOAD_SCHEMA_VERSION
|
|
25
26
|
raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
LlmCostTracker::Event.new(**event_attributes_from(payload))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
def event_attributes_from(payload)
|
|
35
|
+
cost = payload[:cost] && Pricing.stored_cost_attributes(payload[:cost])
|
|
36
|
+
token_usage = TokenUsage.build(**payload.fetch(:token_usage).slice(*TokenUsage.members))
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
event_id: payload.fetch(:event_id),
|
|
40
|
+
provider: payload.fetch(:provider),
|
|
41
|
+
model: payload.fetch(:model),
|
|
42
|
+
token_usage: token_usage,
|
|
43
|
+
pricing_mode: Pricing.normalize_mode(payload[:pricing_mode]),
|
|
37
44
|
cost: cost,
|
|
38
|
-
tags: payload.fetch(
|
|
39
|
-
latency_ms: payload[
|
|
40
|
-
stream: payload.fetch(
|
|
41
|
-
usage_source: payload[
|
|
42
|
-
provider_response_id: payload[
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
tags: payload.fetch(:tags),
|
|
46
|
+
latency_ms: payload[:latency_ms],
|
|
47
|
+
stream: payload.fetch(:stream),
|
|
48
|
+
usage_source: payload[:usage_source]&.to_sym,
|
|
49
|
+
provider_response_id: payload[:provider_response_id],
|
|
50
|
+
provider_project_id: payload[:provider_project_id],
|
|
51
|
+
provider_api_key_id: payload[:provider_api_key_id],
|
|
52
|
+
provider_workspace_id: payload[:provider_workspace_id],
|
|
53
|
+
batch: payload.fetch(:batch),
|
|
54
|
+
tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
|
|
55
|
+
cost_status: payload.fetch(:cost_status),
|
|
56
|
+
pricing_snapshot: payload[:pricing_snapshot],
|
|
57
|
+
line_items: line_items_from(payload)
|
|
58
|
+
}
|
|
45
59
|
end
|
|
46
60
|
|
|
47
|
-
|
|
61
|
+
def line_items_from(payload)
|
|
62
|
+
(payload[:line_items] || []).map { |attributes| Billing::LineItem.build(attributes) }
|
|
63
|
+
end
|
|
48
64
|
|
|
49
65
|
def row_for(event)
|
|
50
66
|
now = Time.now.utc
|
|
@@ -70,7 +86,7 @@ module LlmCostTracker
|
|
|
70
86
|
end
|
|
71
87
|
|
|
72
88
|
def insert_row(row)
|
|
73
|
-
connection = LlmCostTracker::
|
|
89
|
+
connection = LlmCostTracker::Call.connection
|
|
74
90
|
if connection.transaction_open?
|
|
75
91
|
insert_with_separate_connection(row)
|
|
76
92
|
else
|
|
@@ -82,7 +98,7 @@ module LlmCostTracker
|
|
|
82
98
|
end
|
|
83
99
|
|
|
84
100
|
def insert_with_separate_connection(row)
|
|
85
|
-
pool = LlmCostTracker::
|
|
101
|
+
pool = LlmCostTracker::Call.connection_pool
|
|
86
102
|
connection = pool.checkout
|
|
87
103
|
begin
|
|
88
104
|
connection.transaction(requires_new: true) { execute_insert(connection, row) }
|
|
@@ -95,8 +111,7 @@ module LlmCostTracker
|
|
|
95
111
|
columns = row.keys
|
|
96
112
|
quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
|
|
97
113
|
quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
|
|
98
|
-
table = connection.quote_table_name(
|
|
99
|
-
|
|
114
|
+
table = connection.quote_table_name(InboxEntry.table_name)
|
|
100
115
|
connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
|
|
101
116
|
end
|
|
102
117
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ledger/store"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ingestion
|
|
7
|
+
module Inline
|
|
8
|
+
class << self
|
|
9
|
+
def save(event)
|
|
10
|
+
persist(event)
|
|
11
|
+
event
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def persist(event)
|
|
17
|
+
Ledger::Store.insert_many([event])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -18,6 +18,8 @@ module LlmCostTracker
|
|
|
18
18
|
FLUSH_TIMEOUT_SECONDS = 10
|
|
19
19
|
class << self
|
|
20
20
|
def ensure_started
|
|
21
|
+
return unless Ingestion.durable?
|
|
22
|
+
|
|
21
23
|
thread = mutex.synchronize do
|
|
22
24
|
reset_after_fork!
|
|
23
25
|
unless @thread&.alive?
|
|
@@ -25,18 +27,20 @@ module LlmCostTracker
|
|
|
25
27
|
@generation = @generation.to_i + 1
|
|
26
28
|
generation = @generation
|
|
27
29
|
@thread = Thread.new { run(generation) }
|
|
28
|
-
@thread.name = "llm_cost_tracker_ingestor"
|
|
29
|
-
@thread.report_on_exception = false
|
|
30
|
+
@thread.name = "llm_cost_tracker_ingestor"
|
|
31
|
+
@thread.report_on_exception = false
|
|
30
32
|
end
|
|
31
33
|
@thread
|
|
32
34
|
end
|
|
33
35
|
wake_thread(thread)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
def flush!(timeout:
|
|
38
|
+
def flush!(timeout: nil, require_lease: false)
|
|
39
|
+
return true unless Ingestion.durable?
|
|
40
|
+
|
|
37
41
|
Ingestion.ensure_current_schema!
|
|
38
42
|
|
|
39
|
-
deadline = Time.now.utc + timeout
|
|
43
|
+
deadline = Time.now.utc + flush_timeout_seconds(timeout)
|
|
40
44
|
loop do
|
|
41
45
|
return true unless Ingestion::Batch.new(identity: identity).pending?
|
|
42
46
|
return false if Time.now.utc >= deadline
|
|
@@ -51,20 +55,25 @@ module LlmCostTracker
|
|
|
51
55
|
end
|
|
52
56
|
end
|
|
53
57
|
|
|
54
|
-
def shutdown!(timeout:
|
|
58
|
+
def shutdown!(timeout: nil, drain: true)
|
|
59
|
+
return true unless Ingestion.durable?
|
|
60
|
+
|
|
61
|
+
timeout ||= FLUSH_TIMEOUT_SECONDS
|
|
55
62
|
thread = mutex.synchronize do
|
|
56
63
|
@stop_requested = true
|
|
57
64
|
@generation = @generation.to_i + 1
|
|
58
65
|
@thread
|
|
59
66
|
end
|
|
60
67
|
wake_thread(thread)
|
|
61
|
-
thread&.join(
|
|
68
|
+
thread&.join(timeout)
|
|
62
69
|
drain ? flush!(timeout: timeout, require_lease: true) : true
|
|
63
70
|
rescue StandardError => e
|
|
64
71
|
handle_error(e)
|
|
65
72
|
false
|
|
66
73
|
ensure
|
|
67
|
-
mutex.synchronize
|
|
74
|
+
mutex.synchronize do
|
|
75
|
+
@thread = nil if @thread.equal?(thread) && !thread&.alive?
|
|
76
|
+
end
|
|
68
77
|
end
|
|
69
78
|
|
|
70
79
|
def reset!
|
|
@@ -80,7 +89,15 @@ module LlmCostTracker
|
|
|
80
89
|
wake_thread(thread)
|
|
81
90
|
end
|
|
82
91
|
|
|
92
|
+
def flush_timeout_seconds(timeout)
|
|
93
|
+
numeric = Float(timeout, exception: false)
|
|
94
|
+
return FLUSH_TIMEOUT_SECONDS unless numeric&.finite? && numeric.positive?
|
|
95
|
+
|
|
96
|
+
numeric
|
|
97
|
+
end
|
|
98
|
+
|
|
83
99
|
def ingest_once(require_lease: true)
|
|
100
|
+
Ingestion.ensure_current_schema!
|
|
84
101
|
batch = Ingestion::Batch.new(identity: identity)
|
|
85
102
|
return 0 unless batch.claimable?
|
|
86
103
|
return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
|