llm_cost_tracker 0.8.0 → 0.10.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/CHANGELOG.md +136 -0
- data/README.md +14 -6
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- 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 +11 -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 +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
- 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 +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
- data/lib/llm_cost_tracker/budget.rb +31 -7
- data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +72 -17
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -14
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +48 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
- 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_async_ingestion.rb.erb +29 -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 +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -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/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
- data/lib/llm_cost_tracker/ingestion.rb +48 -11
- data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
- data/lib/llm_cost_tracker/integrations/base.rb +35 -15
- data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
- data/lib/llm_cost_tracker/integrations.rb +33 -14
- data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
- data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
- data/lib/llm_cost_tracker/ledger/store.rb +34 -31
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
- data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -43
- data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +572 -493
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -5
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
- data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +62 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +117 -44
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +8 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -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 +4 -1
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +31 -6
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
- data/lib/llm_cost_tracker/token_usage.rb +14 -2
- data/lib/llm_cost_tracker/tracker.rb +41 -55
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +19 -14
- data/lib/tasks/llm_cost_tracker.rake +41 -4
- metadata +49 -3
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -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,47 @@ LlmCostTracker.configure do |config|
|
|
|
18
21
|
# config.instrument :anthropic
|
|
19
22
|
# config.instrument :ruby_llm
|
|
20
23
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
|
|
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. Rates are per 1M tokens; the snapshot's currency
|
|
26
|
+
# is read from your prices_file's `metadata.currency` (USD in the bundled
|
|
27
|
+
# snapshot — set a different code per file if you maintain non-USD prices).
|
|
31
28
|
<% 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
29
|
config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
30
|
+
<% else -%>
|
|
31
|
+
# config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
36
32
|
<% end -%>
|
|
33
|
+
# config.pricing_overrides = {
|
|
34
|
+
# "my-custom-model" => { input: 1.00, output: 2.00 }
|
|
35
|
+
# }
|
|
36
|
+
# :warn (default) records token usage with nil cost when a model has no rate.
|
|
37
|
+
# Use :raise to require known pricing for every model.
|
|
38
|
+
config.unknown_pricing_behavior = :warn
|
|
37
39
|
|
|
38
|
-
#
|
|
40
|
+
# Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
|
|
41
|
+
# plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
|
|
42
|
+
# raises after recording; :block_requests preflights supported requests, also
|
|
43
|
+
# estimating the current call's input cost via chars/4 so it can block before
|
|
44
|
+
# send) and an optional callback. Cap evaluation reads from llm_cost_tracker_calls live;
|
|
45
|
+
# flip cache_rollups to true at high volume so reads hit the rollups table
|
|
46
|
+
# instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
39
47
|
# config.monthly_budget = 100.00
|
|
40
48
|
# config.daily_budget = 10.00
|
|
41
49
|
# config.per_call_budget = 1.00
|
|
42
|
-
|
|
43
|
-
# Called when :notify is selected and a monthly, daily, or per-call budget is exceeded.
|
|
50
|
+
config.budget_exceeded_behavior = :notify
|
|
44
51
|
# config.on_budget_exceeded = ->(data) {
|
|
45
|
-
# Rails.logger.warn(
|
|
46
|
-
# "LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}"
|
|
47
|
-
# )
|
|
52
|
+
# Rails.logger.warn("LLM #{data[:budget_type]} budget exceeded: $#{data[:total]} / $#{data[:budget]}")
|
|
48
53
|
# }
|
|
54
|
+
# config.cache_rollups = true
|
|
49
55
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
56
|
+
# Ingestion path — :inline (default) writes events synchronously from the request
|
|
57
|
+
# thread. Set to :async for a write-ahead inbox + background worker that batches
|
|
58
|
+
# inserts and survives caller transaction rollbacks. Requires the optional
|
|
59
|
+
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:async_ingestion`.
|
|
60
|
+
# The worker uses a dedicated ActiveRecord pool (defaults to 2 connections) so it
|
|
61
|
+
# doesn't compete with request threads — bump ingestion_pool_size if your Puma
|
|
62
|
+
# worker count outgrows that.
|
|
63
|
+
# config.ingestion = :async
|
|
64
|
+
# config.ingestion_pool_size = 5
|
|
55
65
|
|
|
56
66
|
# Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
|
|
57
67
|
# 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,35 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
TABLE = :llm_cost_tracker_call_rollups
|
|
7
|
+
OLD_INDEX = %i[period period_start currency].freeze
|
|
8
|
+
NEW_INDEX = %i[period period_start currency provider].freeze
|
|
9
|
+
|
|
10
|
+
def up
|
|
11
|
+
add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
|
|
12
|
+
add_unique_index NEW_INDEX
|
|
13
|
+
remove_index TABLE, column: OLD_INDEX, unique: true, if_exists: true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def down
|
|
17
|
+
add_unique_index OLD_INDEX
|
|
18
|
+
remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
|
|
19
|
+
remove_column TABLE, :provider if column_exists?(TABLE, :provider)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def add_unique_index(columns)
|
|
25
|
+
if postgresql?
|
|
26
|
+
add_index TABLE, columns, unique: true, algorithm: :concurrently, if_not_exists: true
|
|
27
|
+
else
|
|
28
|
+
add_index TABLE, columns, unique: true, if_not_exists: true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def postgresql?
|
|
33
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
TABLE = :llm_cost_tracker_call_tags
|
|
7
|
+
INDEX_COLUMNS = %i[key value].freeze
|
|
8
|
+
|
|
9
|
+
def up
|
|
10
|
+
if postgresql?
|
|
11
|
+
add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
|
|
12
|
+
elsif mysql?
|
|
13
|
+
add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
|
|
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_exists: true
|
|
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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerProviderInvoiceImportsProvider < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
TABLE = :llm_cost_tracker_provider_invoice_imports
|
|
7
|
+
OLD_INDEX = %i[source started_at].freeze
|
|
8
|
+
NEW_INDEX = %i[source provider started_at].freeze
|
|
9
|
+
|
|
10
|
+
def up
|
|
11
|
+
add_column TABLE, :provider, :string, null: false, default: "" unless column_exists?(TABLE, :provider)
|
|
12
|
+
if postgresql?
|
|
13
|
+
remove_index TABLE, column: OLD_INDEX, algorithm: :concurrently, if_exists: true
|
|
14
|
+
add_index TABLE, NEW_INDEX, algorithm: :concurrently, if_not_exists: true
|
|
15
|
+
else
|
|
16
|
+
remove_index TABLE, column: OLD_INDEX, if_exists: true
|
|
17
|
+
add_index TABLE, NEW_INDEX, if_not_exists: true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def down
|
|
22
|
+
remove_index TABLE, column: NEW_INDEX, if_exists: true
|
|
23
|
+
add_index TABLE, OLD_INDEX, if_not_exists: true
|
|
24
|
+
remove_column TABLE, :provider if column_exists?(TABLE, :provider)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def postgresql?
|
|
30
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
3
|
+
class UpgradeLlmCostTrackerProviderInvoicesMetadataIndex < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
6
|
+
TABLE = :llm_cost_tracker_provider_invoices
|
|
7
|
+
|
|
8
|
+
def up
|
|
9
|
+
return unless postgresql?
|
|
10
|
+
|
|
11
|
+
add_index TABLE, :metadata, using: :gin, algorithm: :concurrently, if_not_exists: true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def down
|
|
15
|
+
return unless postgresql?
|
|
16
|
+
|
|
17
|
+
remove_index TABLE, column: :metadata, if_exists: true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def postgresql?
|
|
23
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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 UpgradeCallTagsKeyValueIndexGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
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."
|
|
15
|
+
|
|
16
|
+
def create_migration_file
|
|
17
|
+
migration_template(
|
|
18
|
+
"upgrade_call_tags_key_value_index.rb.erb",
|
|
19
|
+
"db/migrate/upgrade_llm_cost_tracker_call_tags_key_value_index.rb"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def migration_version
|
|
26
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
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 UpgradeImageTokensGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds image_input_tokens and image_output_tokens columns to llm_cost_tracker_calls."
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"upgrade_image_tokens.rb.erb",
|
|
18
|
+
"db/migrate/upgrade_llm_cost_tracker_image_tokens.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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 UpgradeProviderInvoiceImportsProviderGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds a provider column to llm_cost_tracker_provider_invoice_imports and a " \
|
|
14
|
+
"(source, provider, started_at) index so resume_cursor_for and " \
|
|
15
|
+
"last_completed_window_for can isolate per-provider state on shared sources (e.g. csv)."
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template(
|
|
19
|
+
"upgrade_provider_invoice_imports_provider.rb.erb",
|
|
20
|
+
"db/migrate/upgrade_llm_cost_tracker_provider_invoice_imports_provider.rb"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def migration_version
|
|
27
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
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 UpgradeProviderInvoicesMetadataIndexGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Adds a GIN index on llm_cost_tracker_provider_invoices.metadata for PostgreSQL " \
|
|
14
|
+
"so Reconciliation::Diff queries that filter on metadata->>'provider' / 'row_type' / " \
|
|
15
|
+
"'match_basis' hit an index instead of a sequential scan. No-op on MySQL."
|
|
16
|
+
|
|
17
|
+
def create_migration_file
|
|
18
|
+
migration_template(
|
|
19
|
+
"upgrade_provider_invoices_metadata_index.rb.erb",
|
|
20
|
+
"db/migrate/upgrade_llm_cost_tracker_provider_invoices_metadata_index.rb"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def migration_version
|
|
27
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -40,7 +40,10 @@ module LlmCostTracker
|
|
|
40
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
|
-
rescue StandardError
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
LlmCostTracker::Logging.warn(
|
|
45
|
+
"Inbox mark_failed failed for #{rows.size} rows: #{e.class}: #{e.message} (original error: #{error.class})"
|
|
46
|
+
)
|
|
44
47
|
nil
|
|
45
48
|
end
|
|
46
49
|
|
|
@@ -77,7 +80,7 @@ module LlmCostTracker
|
|
|
77
80
|
|
|
78
81
|
def persist(rows, events)
|
|
79
82
|
LlmCostTracker::Call.transaction do
|
|
80
|
-
Ledger::Store.
|
|
83
|
+
Ledger::Store.insert(events)
|
|
81
84
|
Ingestion::InboxEntry.where(id: rows.map(&:id), locked_by: identity).delete_all
|
|
82
85
|
end
|
|
83
86
|
end
|
|
@@ -15,8 +15,6 @@ module LlmCostTracker
|
|
|
15
15
|
class << self
|
|
16
16
|
def save(event)
|
|
17
17
|
insert_row(row_for(event))
|
|
18
|
-
Ingestion::Worker.ensure_started
|
|
19
|
-
event
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def event_from_row(row)
|
|
@@ -54,14 +52,10 @@ module LlmCostTracker
|
|
|
54
52
|
tracked_at: Time.iso8601(payload.fetch(:tracked_at)),
|
|
55
53
|
cost_status: payload.fetch(:cost_status),
|
|
56
54
|
pricing_snapshot: payload[:pricing_snapshot],
|
|
57
|
-
line_items:
|
|
55
|
+
line_items: (payload[:line_items] || []).map { |attrs| Billing::LineItem.build(attrs) }
|
|
58
56
|
}
|
|
59
57
|
end
|
|
60
58
|
|
|
61
|
-
def line_items_from(payload)
|
|
62
|
-
(payload[:line_items] || []).map { |attributes| Billing::LineItem.build(attributes) }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
59
|
def row_for(event)
|
|
66
60
|
now = Time.now.utc
|
|
67
61
|
{
|
|
@@ -86,25 +80,10 @@ module LlmCostTracker
|
|
|
86
80
|
end
|
|
87
81
|
|
|
88
82
|
def insert_row(row)
|
|
89
|
-
connection
|
|
90
|
-
if connection.transaction_open?
|
|
91
|
-
insert_with_separate_connection(row)
|
|
92
|
-
else
|
|
93
|
-
execute_insert(connection, row)
|
|
94
|
-
end
|
|
83
|
+
Pool.with_connection { |connection| execute_insert(connection, row) }
|
|
95
84
|
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
96
85
|
raise LlmCostTracker::Error,
|
|
97
|
-
"ledger inbox could not checkout a
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def insert_with_separate_connection(row)
|
|
101
|
-
pool = LlmCostTracker::Call.connection_pool
|
|
102
|
-
connection = pool.checkout
|
|
103
|
-
begin
|
|
104
|
-
connection.transaction(requires_new: true) { execute_insert(connection, row) }
|
|
105
|
-
ensure
|
|
106
|
-
pool.checkin(connection)
|
|
107
|
-
end
|
|
86
|
+
"ledger inbox could not checkout a database connection: #{e.message}"
|
|
108
87
|
end
|
|
109
88
|
|
|
110
89
|
def execute_insert(connection, row)
|
|
@@ -112,7 +91,6 @@ module LlmCostTracker
|
|
|
112
91
|
quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
|
|
113
92
|
quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
|
|
114
93
|
table = connection.quote_table_name(InboxEntry.table_name)
|
|
115
|
-
|
|
116
94
|
connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
|
|
117
95
|
end
|
|
118
96
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Ingestion
|
|
5
|
+
module Pool
|
|
6
|
+
DEFAULT_POOL_SIZE = 2
|
|
7
|
+
MUTEX = Mutex.new
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def with_connection(&)
|
|
11
|
+
pool.with_connection(&)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def pool
|
|
15
|
+
@pool || MUTEX.synchronize { @pool ||= connect! }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def reset!
|
|
19
|
+
MUTEX.synchronize do
|
|
20
|
+
@pool&.disconnect!
|
|
21
|
+
@pool = nil
|
|
22
|
+
@handler = nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def connect!
|
|
29
|
+
@handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
|
|
30
|
+
@handler.establish_connection(connection_config)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def connection_config
|
|
34
|
+
LlmCostTracker::Call.connection_db_config.configuration_hash.merge(pool: pool_size)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pool_size
|
|
38
|
+
configured = LlmCostTracker.configuration.ingestion_pool_size.to_i
|
|
39
|
+
configured.positive? ? configured : DEFAULT_POOL_SIZE
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -16,25 +16,31 @@ module LlmCostTracker
|
|
|
16
16
|
MAX_IDLE_INTERVAL_SECONDS = 5.0
|
|
17
17
|
LEASE_SECONDS = 10
|
|
18
18
|
FLUSH_TIMEOUT_SECONDS = 10
|
|
19
|
+
MUTEX = Mutex.new
|
|
20
|
+
|
|
19
21
|
class << self
|
|
20
22
|
def ensure_started
|
|
21
|
-
|
|
23
|
+
return unless Ingestion.async?
|
|
24
|
+
|
|
25
|
+
thread = MUTEX.synchronize do
|
|
22
26
|
reset_after_fork!
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end
|
|
27
|
+
break @thread if @stop_requested || @thread&.alive?
|
|
28
|
+
|
|
29
|
+
@generation = @generation.to_i + 1
|
|
30
|
+
generation = @generation
|
|
31
|
+
@thread = Thread.new { run(generation) }
|
|
32
|
+
@thread.name = "llm_cost_tracker_ingestor"
|
|
33
|
+
@thread.report_on_exception = false
|
|
31
34
|
@thread
|
|
32
35
|
end
|
|
33
36
|
wake_thread(thread)
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
def flush!(timeout: nil, require_lease: false)
|
|
40
|
+
return true unless Ingestion.async?
|
|
41
|
+
|
|
37
42
|
Ingestion.ensure_current_schema!
|
|
43
|
+
MUTEX.synchronize { reset_after_fork! }
|
|
38
44
|
|
|
39
45
|
deadline = Time.now.utc + flush_timeout_seconds(timeout)
|
|
40
46
|
loop do
|
|
@@ -52,25 +58,29 @@ module LlmCostTracker
|
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
def shutdown!(timeout: nil, drain: true)
|
|
61
|
+
return true unless Ingestion.async?
|
|
62
|
+
|
|
55
63
|
timeout ||= FLUSH_TIMEOUT_SECONDS
|
|
56
|
-
thread =
|
|
64
|
+
thread = MUTEX.synchronize do
|
|
57
65
|
@stop_requested = true
|
|
58
66
|
@generation = @generation.to_i + 1
|
|
59
67
|
@thread
|
|
60
68
|
end
|
|
61
69
|
wake_thread(thread)
|
|
62
|
-
thread&.join(
|
|
70
|
+
thread&.join(timeout)
|
|
63
71
|
drain ? flush!(timeout: timeout, require_lease: true) : true
|
|
64
72
|
rescue StandardError => e
|
|
65
73
|
handle_error(e)
|
|
66
74
|
false
|
|
67
75
|
ensure
|
|
68
|
-
|
|
76
|
+
MUTEX.synchronize do
|
|
77
|
+
@thread = nil if @thread.equal?(thread) && !thread&.alive?
|
|
78
|
+
end
|
|
69
79
|
end
|
|
70
80
|
|
|
71
81
|
def reset!
|
|
72
|
-
thread =
|
|
73
|
-
@stop_requested =
|
|
82
|
+
thread = MUTEX.synchronize do
|
|
83
|
+
@stop_requested = false
|
|
74
84
|
@generation = @generation.to_i + 1
|
|
75
85
|
thread = @thread
|
|
76
86
|
@thread = nil
|
|
@@ -90,6 +100,7 @@ module LlmCostTracker
|
|
|
90
100
|
|
|
91
101
|
def ingest_once(require_lease: true)
|
|
92
102
|
Ingestion.ensure_current_schema!
|
|
103
|
+
MUTEX.synchronize { reset_after_fork! }
|
|
93
104
|
batch = Ingestion::Batch.new(identity: identity)
|
|
94
105
|
return 0 unless batch.claimable?
|
|
95
106
|
return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
|
|
@@ -102,16 +113,12 @@ module LlmCostTracker
|
|
|
102
113
|
|
|
103
114
|
private
|
|
104
115
|
|
|
105
|
-
def mutex
|
|
106
|
-
@mutex ||= Mutex.new
|
|
107
|
-
end
|
|
108
|
-
|
|
109
116
|
def run(generation)
|
|
110
117
|
idle_interval = IDLE_INTERVAL_SECONDS
|
|
111
118
|
loop do
|
|
112
|
-
break if
|
|
119
|
+
break if MUTEX.synchronize { @stop_requested || generation != @generation }
|
|
113
120
|
|
|
114
|
-
processed =
|
|
121
|
+
processed = Rails.application.executor.wrap { ingest_once }
|
|
115
122
|
release_connection!
|
|
116
123
|
if processed.zero?
|
|
117
124
|
sleep(idle_interval)
|
|
@@ -126,7 +133,7 @@ module LlmCostTracker
|
|
|
126
133
|
end
|
|
127
134
|
ensure
|
|
128
135
|
release_connection!
|
|
129
|
-
|
|
136
|
+
MUTEX.synchronize { @thread = nil if @thread.equal?(Thread.current) }
|
|
130
137
|
end
|
|
131
138
|
|
|
132
139
|
def reset_after_fork!
|
|
@@ -143,19 +150,6 @@ module LlmCostTracker
|
|
|
143
150
|
nil
|
|
144
151
|
end
|
|
145
152
|
|
|
146
|
-
def executor_wrap(&)
|
|
147
|
-
executor = rails_executor
|
|
148
|
-
return yield unless executor
|
|
149
|
-
|
|
150
|
-
executor.wrap(&)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def rails_executor
|
|
154
|
-
Rails.application.try(:executor)
|
|
155
|
-
rescue StandardError
|
|
156
|
-
nil
|
|
157
|
-
end
|
|
158
|
-
|
|
159
153
|
def identity
|
|
160
154
|
@identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
161
155
|
end
|