llm_cost_tracker 0.7.2 → 0.8.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 +72 -1
- data/README.md +58 -221
- data/app/assets/llm_cost_tracker/application.css +218 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
- 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/token_usage_helper.rb +20 -7
- data/app/models/llm_cost_tracker/call.rb +169 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
- data/app/models/llm_cost_tracker/call_tag.rb +16 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
- 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/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
- 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/shared/_filters.html.erb +63 -0
- 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 +5 -37
- data/lib/llm_cost_tracker/billing/components.rb +53 -0
- data/lib/llm_cost_tracker/billing/components.yml +117 -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 +23 -35
- data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
- data/lib/llm_cost_tracker/configuration.rb +36 -19
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
- 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 +31 -0
- data/lib/llm_cost_tracker/doctor.rb +43 -45
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +10 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
- data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
- data/lib/llm_cost_tracker/ingestion.rb +28 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
- data/lib/llm_cost_tracker/integrations/base.rb +36 -29
- data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
- data/lib/llm_cost_tracker/integrations.rb +2 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +110 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
- data/lib/llm_cost_tracker/ledger.rb +4 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
- data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
- data/lib/llm_cost_tracker/parsers/base.rb +8 -3
- data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
- 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 +105 -20
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
- data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -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.rb +57 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +190 -26
- data/lib/llm_cost_tracker/railtie.rb +0 -8
- data/lib/llm_cost_tracker/report/data.rb +16 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +8 -8
- data/lib/llm_cost_tracker/tags/context.rb +2 -4
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +56 -42
- data/lib/llm_cost_tracker/tracker.rb +67 -24
- 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 +36 -35
- data/lib/tasks/llm_cost_tracker.rake +22 -17
- metadata +36 -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_provider_response_id_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_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
|
@@ -6,16 +6,12 @@ module LlmCostTracker
|
|
|
6
6
|
class InvalidFilterError < Error; end
|
|
7
7
|
|
|
8
8
|
class BudgetExceededError < Error
|
|
9
|
-
attr_reader :
|
|
10
|
-
|
|
11
|
-
def initialize(budget:,
|
|
12
|
-
|
|
13
|
-
@monthly_total = monthly_total
|
|
14
|
-
@daily_total = daily_total
|
|
15
|
-
@call_cost = call_cost
|
|
16
|
-
@total = total || monthly_total || daily_total || call_cost
|
|
9
|
+
attr_reader :total, :budget, :budget_type, :last_event
|
|
10
|
+
|
|
11
|
+
def initialize(budget:, budget_type:, total:, last_event: nil)
|
|
12
|
+
@total = total
|
|
17
13
|
@budget = budget
|
|
18
|
-
@budget_type = budget_type
|
|
14
|
+
@budget_type = budget_type
|
|
19
15
|
@last_event = last_event
|
|
20
16
|
|
|
21
17
|
super(
|
|
@@ -23,16 +19,6 @@ module LlmCostTracker
|
|
|
23
19
|
"$#{format('%.6f', @total)} / $#{format('%.6f', budget)}"
|
|
24
20
|
)
|
|
25
21
|
end
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
def inferred_budget_type
|
|
30
|
-
return :monthly if monthly_total
|
|
31
|
-
return :daily if daily_total
|
|
32
|
-
return :per_call if call_cost
|
|
33
|
-
|
|
34
|
-
:unknown
|
|
35
|
-
end
|
|
36
22
|
end
|
|
37
23
|
|
|
38
24
|
class UnknownPricingError < Error
|
|
@@ -13,7 +13,14 @@ module LlmCostTracker
|
|
|
13
13
|
:stream,
|
|
14
14
|
:usage_source,
|
|
15
15
|
:provider_response_id,
|
|
16
|
-
:
|
|
16
|
+
:provider_project_id,
|
|
17
|
+
:provider_api_key_id,
|
|
18
|
+
:provider_workspace_id,
|
|
19
|
+
:batch,
|
|
20
|
+
:tracked_at,
|
|
21
|
+
:cost_status,
|
|
22
|
+
:pricing_snapshot,
|
|
23
|
+
:line_items
|
|
17
24
|
) do
|
|
18
25
|
def total_cost
|
|
19
26
|
cost&.fetch(:total_cost, nil)
|
|
@@ -23,7 +30,8 @@ module LlmCostTracker
|
|
|
23
30
|
super.merge(
|
|
24
31
|
token_usage: token_usage.to_h,
|
|
25
32
|
cost: cost&.to_h,
|
|
26
|
-
tags: tags ? tags.to_h : {}
|
|
33
|
+
tags: tags ? tags.to_h : {},
|
|
34
|
+
line_items: (line_items || []).map(&:to_h)
|
|
27
35
|
)
|
|
28
36
|
end
|
|
29
37
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
4
|
require "rails/generators/active_record"
|
|
5
|
+
require "llm_cost_tracker/billing/components"
|
|
6
|
+
require "llm_cost_tracker/billing/cost_status"
|
|
5
7
|
require "llm_cost_tracker/pricing"
|
|
6
8
|
require "llm_cost_tracker/token_usage"
|
|
7
9
|
|
|
@@ -18,8 +20,8 @@ module LlmCostTracker
|
|
|
18
20
|
|
|
19
21
|
def create_migration_file
|
|
20
22
|
migration_template(
|
|
21
|
-
"
|
|
22
|
-
"db/migrate/
|
|
23
|
+
"create_llm_cost_tracker_calls.rb.erb",
|
|
24
|
+
"db/migrate/create_llm_cost_tracker_calls.rb"
|
|
23
25
|
)
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "yaml"
|
|
4
5
|
|
|
5
6
|
require_relative "../../pricing/registry"
|
|
6
|
-
require_relative "../../pricing/sync/registry_loader"
|
|
7
7
|
require_relative "../../pricing/sync/registry_writer"
|
|
8
8
|
|
|
9
9
|
module LlmCostTracker
|
|
@@ -12,13 +12,9 @@ module LlmCostTracker
|
|
|
12
12
|
desc "Creates a local LLM Cost Tracker price snapshot"
|
|
13
13
|
|
|
14
14
|
def create_prices_file
|
|
15
|
-
registry = LlmCostTracker::Pricing::Sync::RegistryLoader.new.call(
|
|
16
|
-
path: LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH,
|
|
17
|
-
seed_path: LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH
|
|
18
|
-
)
|
|
19
15
|
LlmCostTracker::Pricing::Sync::RegistryWriter.new.call(
|
|
20
16
|
path: File.join(destination_root, "config/llm_cost_tracker_prices.yml"),
|
|
21
|
-
registry:
|
|
17
|
+
registry: YAML.safe_load_file(LlmCostTracker::Pricing::Registry::DEFAULT_PRICES_PATH, aliases: false) || {}
|
|
22
18
|
)
|
|
23
19
|
end
|
|
24
20
|
end
|
data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
require "llm_cost_tracker/billing/components"
|
|
2
|
+
require "llm_cost_tracker/billing/cost_status"
|
|
3
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
4
|
+
|
|
5
|
+
class CreateLlmCostTrackerCalls < ActiveRecord::Migration<%= migration_version %>
|
|
6
|
+
def change
|
|
7
|
+
create_table :llm_cost_tracker_calls do |t|
|
|
8
|
+
t.string :event_id, null: false
|
|
9
|
+
t.string :provider, null: false
|
|
10
|
+
t.string :model, null: false
|
|
11
|
+
<% LlmCostTracker::TokenUsage.members.each do |column| -%>
|
|
12
|
+
t.integer :<%= column %>, null: false, default: 0
|
|
13
|
+
<% end -%>
|
|
14
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
15
|
+
t.integer :latency_ms
|
|
16
|
+
t.boolean :stream, null: false, default: false
|
|
17
|
+
t.string :usage_source
|
|
18
|
+
t.string :provider_response_id
|
|
19
|
+
t.string :provider_project_id
|
|
20
|
+
t.string :provider_api_key_id
|
|
21
|
+
t.string :provider_workspace_id
|
|
22
|
+
t.boolean :batch, null: false, default: false
|
|
23
|
+
t.string :pricing_mode
|
|
24
|
+
t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
|
|
25
|
+
if postgresql?
|
|
26
|
+
t.jsonb :pricing_snapshot
|
|
27
|
+
elsif mysql?
|
|
28
|
+
t.json :pricing_snapshot
|
|
29
|
+
else
|
|
30
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
31
|
+
end
|
|
32
|
+
t.datetime :tracked_at, null: false
|
|
33
|
+
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
create_table :llm_cost_tracker_call_rollups do |t|
|
|
38
|
+
t.string :period, null: false
|
|
39
|
+
t.date :period_start, null: false
|
|
40
|
+
t.string :currency, null: false, default: "USD"
|
|
41
|
+
t.decimal :total_cost, precision: 20, scale: 8, null: false, default: 0
|
|
42
|
+
|
|
43
|
+
t.timestamps
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
create_table :llm_cost_tracker_ingestion_inbox_entries do |t|
|
|
47
|
+
t.string :event_id, null: false
|
|
48
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
49
|
+
t.datetime :tracked_at, null: false
|
|
50
|
+
t.text :payload, null: false
|
|
51
|
+
t.datetime :locked_at
|
|
52
|
+
t.string :locked_by
|
|
53
|
+
t.integer :attempts, null: false, default: 0
|
|
54
|
+
t.text :last_error
|
|
55
|
+
|
|
56
|
+
t.timestamps
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
create_table :llm_cost_tracker_ingestion_leases do |t|
|
|
60
|
+
t.string :name, null: false
|
|
61
|
+
t.string :locked_by
|
|
62
|
+
t.datetime :locked_until
|
|
63
|
+
|
|
64
|
+
t.timestamps
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
create_table :llm_cost_tracker_call_line_items do |t|
|
|
68
|
+
t.references :llm_cost_tracker_call,
|
|
69
|
+
null: false,
|
|
70
|
+
index: false,
|
|
71
|
+
foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
|
|
72
|
+
t.integer :position, null: false, default: 0, limit: 2
|
|
73
|
+
t.string :kind, null: false
|
|
74
|
+
t.string :direction, null: false
|
|
75
|
+
t.string :modality, null: false
|
|
76
|
+
t.string :cache_state, null: false, default: "none"
|
|
77
|
+
t.decimal :quantity, precision: 30, scale: 10, null: false
|
|
78
|
+
t.string :unit, null: false
|
|
79
|
+
t.decimal :rate_amount, precision: 20, scale: 8
|
|
80
|
+
t.decimal :rate_quantity, precision: 30, scale: 10, null: false, default: 1
|
|
81
|
+
t.decimal :cost, precision: 20, scale: 8
|
|
82
|
+
t.string :currency, null: false, default: "USD"
|
|
83
|
+
t.string :cost_status, null: false, default: LlmCostTracker::Billing::CostStatus::UNKNOWN
|
|
84
|
+
t.string :pricing_basis
|
|
85
|
+
t.string :price_key
|
|
86
|
+
t.string :price_source
|
|
87
|
+
t.string :price_source_version
|
|
88
|
+
t.string :provider_field
|
|
89
|
+
t.string :provider_item_id
|
|
90
|
+
if postgresql?
|
|
91
|
+
t.jsonb :details, null: false, default: {}
|
|
92
|
+
elsif mysql?
|
|
93
|
+
t.json :details, null: false
|
|
94
|
+
else
|
|
95
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
t.datetime :created_at, null: false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
create_table :llm_cost_tracker_call_tags do |t|
|
|
102
|
+
t.references :llm_cost_tracker_call,
|
|
103
|
+
null: false,
|
|
104
|
+
index: false,
|
|
105
|
+
foreign_key: { to_table: :llm_cost_tracker_calls, on_delete: :cascade }
|
|
106
|
+
t.string :key, null: false
|
|
107
|
+
t.text :value, null: false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
create_table :llm_cost_tracker_provider_invoices do |t|
|
|
111
|
+
t.string :source, null: false
|
|
112
|
+
t.date :period_start, null: false
|
|
113
|
+
t.date :period_end, null: false
|
|
114
|
+
t.string :external_id, null: false
|
|
115
|
+
t.decimal :billed_amount, precision: 20, scale: 8
|
|
116
|
+
t.string :currency, null: false, default: "USD"
|
|
117
|
+
if postgresql?
|
|
118
|
+
t.jsonb :metadata, null: false, default: {}
|
|
119
|
+
elsif mysql?
|
|
120
|
+
t.json :metadata, null: false
|
|
121
|
+
else
|
|
122
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
123
|
+
end
|
|
124
|
+
t.datetime :imported_at, null: false
|
|
125
|
+
|
|
126
|
+
t.timestamps
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
add_index :llm_cost_tracker_calls, :event_id, unique: true
|
|
130
|
+
add_index :llm_cost_tracker_calls, :tracked_at
|
|
131
|
+
add_index :llm_cost_tracker_calls, [:provider, :tracked_at]
|
|
132
|
+
add_index :llm_cost_tracker_calls, [:model, :tracked_at]
|
|
133
|
+
add_index :llm_cost_tracker_calls, :cost_status
|
|
134
|
+
add_index :llm_cost_tracker_calls, :provider_response_id
|
|
135
|
+
add_index :llm_cost_tracker_call_line_items, [:llm_cost_tracker_call_id, :position]
|
|
136
|
+
add_index :llm_cost_tracker_call_line_items, :kind
|
|
137
|
+
add_index :llm_cost_tracker_call_tags, :llm_cost_tracker_call_id
|
|
138
|
+
add_index :llm_cost_tracker_call_tags, :key
|
|
139
|
+
add_index :llm_cost_tracker_call_rollups, [:period, :period_start, :currency], unique: true
|
|
140
|
+
add_index :llm_cost_tracker_ingestion_inbox_entries, :event_id, unique: true
|
|
141
|
+
add_index :llm_cost_tracker_ingestion_inbox_entries, [:tracked_at, :attempts]
|
|
142
|
+
add_index :llm_cost_tracker_ingestion_inbox_entries, [:locked_at, :id]
|
|
143
|
+
add_index :llm_cost_tracker_ingestion_leases, :name, unique: true
|
|
144
|
+
add_index :llm_cost_tracker_provider_invoices, :external_id, unique: true
|
|
145
|
+
add_index :llm_cost_tracker_provider_invoices, [:source, :period_start]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def postgresql?
|
|
151
|
+
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def mysql?
|
|
155
|
+
LlmCostTracker::Ledger::Schema::Adapter.mysql?(connection)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -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,7 +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(
|
|
114
|
+
table = connection.quote_table_name(InboxEntry.table_name)
|
|
99
115
|
|
|
100
116
|
connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
|
|
101
117
|
end
|
|
@@ -25,18 +25,18 @@ module LlmCostTracker
|
|
|
25
25
|
@generation = @generation.to_i + 1
|
|
26
26
|
generation = @generation
|
|
27
27
|
@thread = Thread.new { run(generation) }
|
|
28
|
-
@thread.name = "llm_cost_tracker_ingestor"
|
|
29
|
-
@thread.report_on_exception = false
|
|
28
|
+
@thread.name = "llm_cost_tracker_ingestor"
|
|
29
|
+
@thread.report_on_exception = false
|
|
30
30
|
end
|
|
31
31
|
@thread
|
|
32
32
|
end
|
|
33
33
|
wake_thread(thread)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def flush!(timeout:
|
|
36
|
+
def flush!(timeout: nil, require_lease: false)
|
|
37
37
|
Ingestion.ensure_current_schema!
|
|
38
38
|
|
|
39
|
-
deadline = Time.now.utc + timeout
|
|
39
|
+
deadline = Time.now.utc + flush_timeout_seconds(timeout)
|
|
40
40
|
loop do
|
|
41
41
|
return true unless Ingestion::Batch.new(identity: identity).pending?
|
|
42
42
|
return false if Time.now.utc >= deadline
|
|
@@ -51,7 +51,8 @@ module LlmCostTracker
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
def shutdown!(timeout:
|
|
54
|
+
def shutdown!(timeout: nil, drain: true)
|
|
55
|
+
timeout ||= FLUSH_TIMEOUT_SECONDS
|
|
55
56
|
thread = mutex.synchronize do
|
|
56
57
|
@stop_requested = true
|
|
57
58
|
@generation = @generation.to_i + 1
|
|
@@ -80,7 +81,15 @@ module LlmCostTracker
|
|
|
80
81
|
wake_thread(thread)
|
|
81
82
|
end
|
|
82
83
|
|
|
84
|
+
def flush_timeout_seconds(timeout)
|
|
85
|
+
numeric = Float(timeout, exception: false)
|
|
86
|
+
return FLUSH_TIMEOUT_SECONDS unless numeric&.finite? && numeric.positive?
|
|
87
|
+
|
|
88
|
+
numeric
|
|
89
|
+
end
|
|
90
|
+
|
|
83
91
|
def ingest_once(require_lease: true)
|
|
92
|
+
Ingestion.ensure_current_schema!
|
|
84
93
|
batch = Ingestion::Batch.new(identity: identity)
|
|
85
94
|
return 0 unless batch.claimable?
|
|
86
95
|
return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
|
|
@@ -15,31 +15,38 @@ module LlmCostTracker
|
|
|
15
15
|
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
16
16
|
|
|
17
17
|
class << self
|
|
18
|
+
def table_name_prefix
|
|
19
|
+
"llm_cost_tracker_ingestion_"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
WRITE_SCHEMA_GUARDS = [
|
|
23
|
+
["llm_cost_tracker_calls", Ledger::Schema::Calls],
|
|
24
|
+
["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
|
|
25
|
+
["llm_cost_tracker_call_tags", Ledger::Schema::CallTags],
|
|
26
|
+
["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups]
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
18
29
|
def ensure_current_schema!
|
|
19
|
-
unless
|
|
20
|
-
raise Error, "
|
|
30
|
+
unless LlmCostTracker::Call.table_exists?
|
|
31
|
+
raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
21
32
|
end
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
WRITE_SCHEMA_GUARDS.each do |table_name, schema_module|
|
|
35
|
+
errors = schema_module.current_schema_errors
|
|
36
|
+
next if errors.empty?
|
|
26
37
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
message = "llm_cost_tracker_period_totals table is not on the current schema: " \
|
|
31
|
-
"#{period_total_errors.join('; ')}; " \
|
|
32
|
-
"run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
|
|
33
|
-
raise Error, message
|
|
38
|
+
raise Error,
|
|
39
|
+
"#{table_name} table is not on the current schema: #{errors.join('; ')}; see docs/upgrading.md"
|
|
40
|
+
end
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
def verify
|
|
37
|
-
unless LlmCostTracker::
|
|
44
|
+
unless LlmCostTracker::Call.table_exists?
|
|
38
45
|
return [
|
|
39
46
|
LlmCostTracker::Doctor::Check.new(
|
|
40
47
|
:error,
|
|
41
48
|
"active_record",
|
|
42
|
-
"
|
|
49
|
+
"llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
43
50
|
)
|
|
44
51
|
]
|
|
45
52
|
end
|
|
@@ -60,13 +67,12 @@ module LlmCostTracker
|
|
|
60
67
|
event = LlmCostTracker.track(
|
|
61
68
|
provider: provider,
|
|
62
69
|
model: model,
|
|
63
|
-
|
|
64
|
-
output_tokens: 1,
|
|
70
|
+
tokens: { input: 1, output: 1 },
|
|
65
71
|
provider_response_id: response_id,
|
|
66
|
-
feature: VERIFY_TAG
|
|
72
|
+
tags: { feature: VERIFY_TAG }
|
|
67
73
|
)
|
|
68
|
-
LlmCostTracker.flush!
|
|
69
|
-
persisted = LlmCostTracker::
|
|
74
|
+
LlmCostTracker::Ingestion::Worker.flush!
|
|
75
|
+
persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
|
|
70
76
|
|
|
71
77
|
return capture_success if persisted && notifications.any?
|
|
72
78
|
|
|
@@ -83,7 +89,7 @@ module LlmCostTracker
|
|
|
83
89
|
LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
|
|
84
90
|
ensure
|
|
85
91
|
cleanup_verification_call(response_id) if response_id
|
|
86
|
-
LlmCostTracker::Ingestion::
|
|
92
|
+
LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all if event
|
|
87
93
|
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
88
94
|
end
|
|
89
95
|
|
|
@@ -109,8 +115,8 @@ module LlmCostTracker
|
|
|
109
115
|
end
|
|
110
116
|
|
|
111
117
|
def cleanup_verification_call(response_id)
|
|
112
|
-
relation = LlmCostTracker::
|
|
113
|
-
rows = relation.pluck(:id, :tracked_at, :total_cost)
|
|
118
|
+
relation = LlmCostTracker::Call.where(provider_response_id: response_id)
|
|
119
|
+
rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot)
|
|
114
120
|
return if rows.empty?
|
|
115
121
|
|
|
116
122
|
relation.delete_all
|