llm_cost_tracker 0.9.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 +29 -1
- data/README.md +2 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +28 -6
- data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +31 -28
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- 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.rb +6 -17
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- 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 +0 -9
- 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 -24
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
- data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- 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 +37 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
- 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 +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +72 -27
- 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 +3 -1
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +18 -7
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
class
|
|
1
|
+
class CreateLlmCostTrackerAsyncIngestion < ActiveRecord::Migration<%= migration_version %>
|
|
2
2
|
def change
|
|
3
3
|
create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
|
|
4
4
|
t.string :event_id, null: false
|
|
5
5
|
t.decimal :total_cost, precision: 20, scale: 8
|
|
6
6
|
t.datetime :tracked_at, null: false
|
|
7
|
-
t.text :payload, null: false
|
|
7
|
+
t.text :payload, null: false, limit: 16.megabytes
|
|
8
8
|
t.datetime :locked_at
|
|
9
9
|
t.string :locked_by
|
|
10
10
|
t.integer :attempts, null: false, default: 0
|
|
11
|
-
t.text :last_error
|
|
11
|
+
t.text :last_error, limit: 16.megabytes
|
|
12
12
|
|
|
13
13
|
t.timestamps
|
|
14
14
|
end
|
|
@@ -23,6 +23,7 @@ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_
|
|
|
23
23
|
|
|
24
24
|
create_table :llm_cost_tracker_provider_invoice_imports, if_not_exists: true do |t|
|
|
25
25
|
t.string :source, null: false
|
|
26
|
+
t.string :provider, null: false, default: ""
|
|
26
27
|
t.string :cursor
|
|
27
28
|
t.date :window_start
|
|
28
29
|
t.date :window_end
|
|
@@ -39,7 +40,11 @@ class CreateLlmCostTrackerReconciliation < ActiveRecord::Migration<%= migration_
|
|
|
39
40
|
if_not_exists: true
|
|
40
41
|
add_index :llm_cost_tracker_provider_invoices, %i[source currency period_start],
|
|
41
42
|
if_not_exists: true
|
|
42
|
-
|
|
43
|
+
if postgresql?
|
|
44
|
+
add_index :llm_cost_tracker_provider_invoices, :metadata, using: :gin,
|
|
45
|
+
if_not_exists: true
|
|
46
|
+
end
|
|
47
|
+
add_index :llm_cost_tracker_provider_invoice_imports, %i[source provider started_at],
|
|
43
48
|
if_not_exists: true
|
|
44
49
|
end
|
|
45
50
|
|
|
@@ -22,7 +22,9 @@ LlmCostTracker.configure do |config|
|
|
|
22
22
|
# config.instrument :ruby_llm
|
|
23
23
|
|
|
24
24
|
# Pricing — local file refreshed via bin/rails llm_cost_tracker:prices:refresh
|
|
25
|
-
# plus inline overrides.
|
|
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).
|
|
26
28
|
<% if options[:prices] -%>
|
|
27
29
|
config.prices_file = Rails.root.join("config/llm_cost_tracker_prices.yml")
|
|
28
30
|
<% else -%>
|
|
@@ -37,8 +39,9 @@ LlmCostTracker.configure do |config|
|
|
|
37
39
|
|
|
38
40
|
# Budget guardrails — cumulative monthly/daily and per-call ceilings in USD,
|
|
39
41
|
# plus behavior on crossing (:notify default fires on_budget_exceeded; :raise
|
|
40
|
-
# raises after recording; :block_requests preflights supported requests
|
|
41
|
-
#
|
|
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;
|
|
42
45
|
# flip cache_rollups to true at high volume so reads hit the rollups table
|
|
43
46
|
# instead — generate the table with `bin/rails generate llm_cost_tracker:call_rollups`.
|
|
44
47
|
# config.monthly_budget = 100.00
|
|
@@ -50,11 +53,15 @@ LlmCostTracker.configure do |config|
|
|
|
50
53
|
# }
|
|
51
54
|
# config.cache_rollups = true
|
|
52
55
|
|
|
53
|
-
# Ingestion path —
|
|
54
|
-
# thread.
|
|
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
|
|
55
58
|
# inserts and survives caller transaction rollbacks. Requires the optional
|
|
56
|
-
# inbox/leases tables created by `bin/rails generate llm_cost_tracker:
|
|
57
|
-
#
|
|
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
|
|
58
65
|
|
|
59
66
|
# Register OpenAI-compatible gateway hosts and choose extra tag breakdowns
|
|
60
67
|
# for bin/rails llm_cost_tracker:report.
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
|
+
|
|
1
3
|
class UpgradeLlmCostTrackerCallRollupsProvider < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
2
6
|
TABLE = :llm_cost_tracker_call_rollups
|
|
3
7
|
OLD_INDEX = %i[period period_start currency].freeze
|
|
4
8
|
NEW_INDEX = %i[period period_start currency provider].freeze
|
|
5
9
|
|
|
6
10
|
def up
|
|
7
|
-
unless column_exists?(TABLE, :provider)
|
|
8
|
-
|
|
9
|
-
|
|
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)
|
|
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
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def down
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
add_unique_index OLD_INDEX
|
|
18
|
+
remove_index TABLE, column: NEW_INDEX, unique: true, if_exists: true
|
|
18
19
|
remove_column TABLE, :provider if column_exists?(TABLE, :provider)
|
|
19
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
|
|
20
35
|
end
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
require "llm_cost_tracker/ledger/schema/adapter"
|
|
2
2
|
|
|
3
3
|
class UpgradeLlmCostTrackerCallTagsKeyValueIndex < ActiveRecord::Migration<%= migration_version %>
|
|
4
|
+
disable_ddl_transaction!
|
|
5
|
+
|
|
4
6
|
TABLE = :llm_cost_tracker_call_tags
|
|
5
7
|
INDEX_COLUMNS = %i[key value].freeze
|
|
6
8
|
|
|
7
9
|
def up
|
|
8
|
-
return if index_exists?(TABLE, INDEX_COLUMNS)
|
|
9
|
-
|
|
10
10
|
if postgresql?
|
|
11
|
-
add_index TABLE, INDEX_COLUMNS
|
|
11
|
+
add_index TABLE, INDEX_COLUMNS, algorithm: :concurrently, if_not_exists: true
|
|
12
12
|
elsif mysql?
|
|
13
|
-
add_index TABLE, INDEX_COLUMNS, length: { value: 191 }
|
|
13
|
+
add_index TABLE, INDEX_COLUMNS, length: { value: 191 }, if_not_exists: true
|
|
14
14
|
else
|
|
15
15
|
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def down
|
|
20
|
-
remove_index TABLE, column: INDEX_COLUMNS
|
|
20
|
+
remove_index TABLE, column: INDEX_COLUMNS, if_exists: true
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
@@ -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
CHANGED
|
@@ -19,15 +19,6 @@ module LlmCostTracker
|
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
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
22
|
private
|
|
32
23
|
|
|
33
24
|
def migration_version
|
|
@@ -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)
|
|
@@ -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,29 +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
|
-
return unless Ingestion.
|
|
23
|
+
return unless Ingestion.async?
|
|
22
24
|
|
|
23
|
-
thread =
|
|
25
|
+
thread = MUTEX.synchronize do
|
|
24
26
|
reset_after_fork!
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
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
|
|
33
34
|
@thread
|
|
34
35
|
end
|
|
35
36
|
wake_thread(thread)
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def flush!(timeout: nil, require_lease: false)
|
|
39
|
-
return true unless Ingestion.
|
|
40
|
+
return true unless Ingestion.async?
|
|
40
41
|
|
|
41
42
|
Ingestion.ensure_current_schema!
|
|
43
|
+
MUTEX.synchronize { reset_after_fork! }
|
|
42
44
|
|
|
43
45
|
deadline = Time.now.utc + flush_timeout_seconds(timeout)
|
|
44
46
|
loop do
|
|
@@ -56,10 +58,10 @@ module LlmCostTracker
|
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def shutdown!(timeout: nil, drain: true)
|
|
59
|
-
return true unless Ingestion.
|
|
61
|
+
return true unless Ingestion.async?
|
|
60
62
|
|
|
61
63
|
timeout ||= FLUSH_TIMEOUT_SECONDS
|
|
62
|
-
thread =
|
|
64
|
+
thread = MUTEX.synchronize do
|
|
63
65
|
@stop_requested = true
|
|
64
66
|
@generation = @generation.to_i + 1
|
|
65
67
|
@thread
|
|
@@ -71,14 +73,14 @@ module LlmCostTracker
|
|
|
71
73
|
handle_error(e)
|
|
72
74
|
false
|
|
73
75
|
ensure
|
|
74
|
-
|
|
76
|
+
MUTEX.synchronize do
|
|
75
77
|
@thread = nil if @thread.equal?(thread) && !thread&.alive?
|
|
76
78
|
end
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
def reset!
|
|
80
|
-
thread =
|
|
81
|
-
@stop_requested =
|
|
82
|
+
thread = MUTEX.synchronize do
|
|
83
|
+
@stop_requested = false
|
|
82
84
|
@generation = @generation.to_i + 1
|
|
83
85
|
thread = @thread
|
|
84
86
|
@thread = nil
|
|
@@ -98,6 +100,7 @@ module LlmCostTracker
|
|
|
98
100
|
|
|
99
101
|
def ingest_once(require_lease: true)
|
|
100
102
|
Ingestion.ensure_current_schema!
|
|
103
|
+
MUTEX.synchronize { reset_after_fork! }
|
|
101
104
|
batch = Ingestion::Batch.new(identity: identity)
|
|
102
105
|
return 0 unless batch.claimable?
|
|
103
106
|
return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
|
|
@@ -110,16 +113,12 @@ module LlmCostTracker
|
|
|
110
113
|
|
|
111
114
|
private
|
|
112
115
|
|
|
113
|
-
def mutex
|
|
114
|
-
@mutex ||= Mutex.new
|
|
115
|
-
end
|
|
116
|
-
|
|
117
116
|
def run(generation)
|
|
118
117
|
idle_interval = IDLE_INTERVAL_SECONDS
|
|
119
118
|
loop do
|
|
120
|
-
break if
|
|
119
|
+
break if MUTEX.synchronize { @stop_requested || generation != @generation }
|
|
121
120
|
|
|
122
|
-
processed =
|
|
121
|
+
processed = Rails.application.executor.wrap { ingest_once }
|
|
123
122
|
release_connection!
|
|
124
123
|
if processed.zero?
|
|
125
124
|
sleep(idle_interval)
|
|
@@ -134,7 +133,7 @@ module LlmCostTracker
|
|
|
134
133
|
end
|
|
135
134
|
ensure
|
|
136
135
|
release_connection!
|
|
137
|
-
|
|
136
|
+
MUTEX.synchronize { @thread = nil if @thread.equal?(Thread.current) }
|
|
138
137
|
end
|
|
139
138
|
|
|
140
139
|
def reset_after_fork!
|
|
@@ -151,19 +150,6 @@ module LlmCostTracker
|
|
|
151
150
|
nil
|
|
152
151
|
end
|
|
153
152
|
|
|
154
|
-
def executor_wrap(&)
|
|
155
|
-
executor = rails_executor
|
|
156
|
-
return yield unless executor
|
|
157
|
-
|
|
158
|
-
executor.wrap(&)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def rails_executor
|
|
162
|
-
Rails.application.try(:executor)
|
|
163
|
-
rescue StandardError
|
|
164
|
-
nil
|
|
165
|
-
end
|
|
166
|
-
|
|
167
153
|
def identity
|
|
168
154
|
@identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
169
155
|
end
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
|
-
require_relative "doctor/check"
|
|
6
5
|
require_relative "errors"
|
|
7
6
|
require_relative "ledger"
|
|
8
|
-
require_relative "ingestion/inline"
|
|
9
7
|
require_relative "ingestion/lease_claim"
|
|
8
|
+
require_relative "ingestion/pool"
|
|
10
9
|
require_relative "ingestion/inbox"
|
|
11
10
|
require_relative "ingestion/batch"
|
|
12
11
|
require_relative "ingestion/worker"
|
|
@@ -28,7 +27,7 @@ module LlmCostTracker
|
|
|
28
27
|
|
|
29
28
|
ROLLUPS_SCHEMA_GUARD = ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups].freeze
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
ASYNC_SCHEMA_GUARDS = [
|
|
32
31
|
["llm_cost_tracker_ingestion_inbox_entries", Ledger::Schema::IngestionInboxEntries],
|
|
33
32
|
["llm_cost_tracker_ingestion_leases", Ledger::Schema::IngestionLeases]
|
|
34
33
|
].freeze
|
|
@@ -47,8 +46,8 @@ module LlmCostTracker
|
|
|
47
46
|
end
|
|
48
47
|
end
|
|
49
48
|
|
|
50
|
-
def
|
|
51
|
-
LlmCostTracker.configuration.
|
|
49
|
+
def async?
|
|
50
|
+
LlmCostTracker.configuration.ingestion == :async
|
|
52
51
|
end
|
|
53
52
|
|
|
54
53
|
def cache_rollups?
|
|
@@ -58,7 +57,7 @@ module LlmCostTracker
|
|
|
58
57
|
def guards_for_current_config
|
|
59
58
|
guards = CORE_SCHEMA_GUARDS.dup
|
|
60
59
|
guards << ROLLUPS_SCHEMA_GUARD if cache_rollups?
|
|
61
|
-
guards +=
|
|
60
|
+
guards += ASYNC_SCHEMA_GUARDS if async?
|
|
62
61
|
guards
|
|
63
62
|
end
|
|
64
63
|
|
|
@@ -93,7 +92,7 @@ module LlmCostTracker
|
|
|
93
92
|
provider_response_id: response_id,
|
|
94
93
|
tags: { feature: VERIFY_TAG }
|
|
95
94
|
)
|
|
96
|
-
LlmCostTracker::Ingestion::Worker.flush! if
|
|
95
|
+
LlmCostTracker::Ingestion::Worker.flush! if async?
|
|
97
96
|
persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
|
|
98
97
|
|
|
99
98
|
return capture_success if persisted && notifications.any?
|
|
@@ -122,7 +121,7 @@ module LlmCostTracker
|
|
|
122
121
|
end
|
|
123
122
|
|
|
124
123
|
def capture_success
|
|
125
|
-
path =
|
|
124
|
+
path = async? ? "async inbox" : "inline writer"
|
|
126
125
|
LlmCostTracker::Doctor::Check.new(
|
|
127
126
|
:ok,
|
|
128
127
|
"active_record capture",
|
|
@@ -149,7 +148,7 @@ module LlmCostTracker
|
|
|
149
148
|
end
|
|
150
149
|
|
|
151
150
|
def cleanup_verification_inbox(event:, response_id:)
|
|
152
|
-
return unless
|
|
151
|
+
return unless async? && LlmCostTracker::Ingestion::InboxEntry.table_exists?
|
|
153
152
|
|
|
154
153
|
if event
|
|
155
154
|
LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all
|