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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module IngestionLeases
|
|
9
|
+
REQUIRED_COLUMNS = %w[
|
|
10
|
+
name
|
|
11
|
+
locked_by
|
|
12
|
+
locked_until
|
|
13
|
+
created_at
|
|
14
|
+
updated_at
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
UNIQUE_COLUMNS = %i[name].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def current_schema_errors
|
|
21
|
+
connection = LlmCostTracker::Ingestion::Lease.connection
|
|
22
|
+
Adapter.ensure_supported!(connection)
|
|
23
|
+
table_name = LlmCostTracker::Ingestion::Lease.table_name
|
|
24
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
25
|
+
|
|
26
|
+
columns = LlmCostTracker::Ingestion::Lease.columns_hash
|
|
27
|
+
cache = @schema_capabilities
|
|
28
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
29
|
+
|
|
30
|
+
errors = compute_errors(connection, table_name, columns)
|
|
31
|
+
@schema_capabilities = { columns: columns, errors: errors }
|
|
32
|
+
errors
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def compute_errors(connection, table_name, columns)
|
|
38
|
+
errors = []
|
|
39
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
40
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
41
|
+
errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
|
|
42
|
+
errors
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def name_unique_index?(connection, table_name)
|
|
46
|
+
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module ProviderInvoiceImports
|
|
9
|
+
REQUIRED_COLUMNS = %w[
|
|
10
|
+
source provider cursor window_start window_end state last_error
|
|
11
|
+
rows_imported started_at finished_at
|
|
12
|
+
].freeze
|
|
13
|
+
SOURCE_PROVIDER_STARTED_AT_INDEX = %i[source provider started_at].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def current_schema_errors
|
|
17
|
+
connection = LlmCostTracker::Call.connection
|
|
18
|
+
Adapter.ensure_supported!(connection)
|
|
19
|
+
table_name = LlmCostTracker::ProviderInvoiceImport.table_name
|
|
20
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
21
|
+
|
|
22
|
+
columns = LlmCostTracker::ProviderInvoiceImport.columns_hash
|
|
23
|
+
cache = @schema_capabilities
|
|
24
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
25
|
+
|
|
26
|
+
errors = compute_errors(connection, table_name, columns)
|
|
27
|
+
@schema_capabilities = { columns: columns, errors: errors }
|
|
28
|
+
errors
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def compute_errors(connection, table_name, columns)
|
|
34
|
+
errors = []
|
|
35
|
+
errors.concat(column_errors(columns))
|
|
36
|
+
errors.concat(index_errors(connection, table_name))
|
|
37
|
+
errors
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def column_errors(columns)
|
|
41
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
42
|
+
return [] if missing.empty?
|
|
43
|
+
|
|
44
|
+
["missing columns: #{missing.join(', ')}"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def index_errors(connection, table_name)
|
|
48
|
+
return [] if connection.index_exists?(table_name, SOURCE_PROVIDER_STARTED_AT_INDEX)
|
|
49
|
+
|
|
50
|
+
["missing index: source, provider, started_at"]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -10,7 +10,7 @@ module LlmCostTracker
|
|
|
10
10
|
source period_start period_end external_id billed_amount currency metadata imported_at
|
|
11
11
|
].freeze
|
|
12
12
|
UNIQUE_INDEX_COLUMNS = %i[external_id].freeze
|
|
13
|
-
SOURCE_PERIOD_INDEX_COLUMNS = %i[source period_start].freeze
|
|
13
|
+
SOURCE_PERIOD_INDEX_COLUMNS = %i[source currency period_start].freeze
|
|
14
14
|
|
|
15
15
|
class << self
|
|
16
16
|
def current_schema_errors
|
|
@@ -19,37 +19,52 @@ module LlmCostTracker
|
|
|
19
19
|
table_name = LlmCostTracker::ProviderInvoice.table_name
|
|
20
20
|
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
errors.
|
|
25
|
-
|
|
22
|
+
columns = LlmCostTracker::ProviderInvoice.columns_hash
|
|
23
|
+
cache = @schema_capabilities
|
|
24
|
+
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
25
|
+
|
|
26
|
+
errors = compute_errors(connection, table_name, columns)
|
|
27
|
+
@schema_capabilities = { columns: columns, errors: errors }
|
|
26
28
|
errors
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
private
|
|
30
32
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
+
def compute_errors(connection, table_name, columns)
|
|
34
|
+
errors = []
|
|
35
|
+
errors.concat(column_errors(columns))
|
|
36
|
+
errors.concat(Adapter.json_column_errors(columns["metadata"], connection, "metadata"))
|
|
37
|
+
errors.concat(index_errors(connection, table_name))
|
|
38
|
+
errors
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def column_errors(columns)
|
|
42
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
33
43
|
return [] if missing.empty?
|
|
34
44
|
|
|
35
45
|
["missing columns: #{missing.join(', ')}"]
|
|
36
46
|
end
|
|
37
47
|
|
|
38
|
-
def metadata_type_errors(connection)
|
|
39
|
-
metadata = LlmCostTracker::ProviderInvoice.columns_hash["metadata"]
|
|
40
|
-
Adapter.json_column_errors(metadata, connection, "metadata")
|
|
41
|
-
end
|
|
42
|
-
|
|
43
48
|
def index_errors(connection, table_name)
|
|
44
49
|
errors = []
|
|
45
50
|
unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
|
|
46
51
|
errors << "missing unique index: external_id"
|
|
47
52
|
end
|
|
48
53
|
unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
|
|
49
|
-
errors << "missing index: source, period_start"
|
|
54
|
+
errors << "missing index: source, currency, period_start"
|
|
55
|
+
end
|
|
56
|
+
if Adapter.postgresql?(connection) && !gin_metadata_index?(connection, table_name)
|
|
57
|
+
errors << "missing GIN index on metadata " \
|
|
58
|
+
"(run bin/rails generate llm_cost_tracker:upgrade_provider_invoices_metadata_index)"
|
|
50
59
|
end
|
|
51
60
|
errors
|
|
52
61
|
end
|
|
62
|
+
|
|
63
|
+
def gin_metadata_index?(connection, table_name)
|
|
64
|
+
connection.indexes(table_name).any? do |index|
|
|
65
|
+
index.columns == ["metadata"] && index.using.to_s == "gin"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
53
68
|
end
|
|
54
69
|
end
|
|
55
70
|
end
|
|
@@ -5,32 +5,40 @@ require "json"
|
|
|
5
5
|
require_relative "../pricing"
|
|
6
6
|
require_relative "../billing/line_item"
|
|
7
7
|
require_relative "rollups"
|
|
8
|
+
require_relative "tags/encoding"
|
|
8
9
|
|
|
9
10
|
module LlmCostTracker
|
|
10
11
|
module Ledger
|
|
11
12
|
class Store
|
|
12
13
|
class << self
|
|
13
|
-
def
|
|
14
|
+
def insert(events, skip_existence_check: false)
|
|
14
15
|
events = Array(events)
|
|
15
|
-
return
|
|
16
|
-
|
|
17
|
-
insertable = insertable_events(events)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
insert_call_tags(insertable, call_ids)
|
|
26
|
-
Ledger::Rollups.increment_many!(insertable)
|
|
27
|
-
end
|
|
16
|
+
return if events.empty?
|
|
17
|
+
|
|
18
|
+
insertable = skip_existence_check ? events : insertable_events(events)
|
|
19
|
+
return unless insertable.any?
|
|
20
|
+
|
|
21
|
+
LlmCostTracker::Call.transaction do
|
|
22
|
+
rows = insertable.map { |event| attributes_for(event) }
|
|
23
|
+
call_ids = insert_calls_returning_ids(rows, insertable)
|
|
24
|
+
insert_line_items(insertable, call_ids)
|
|
25
|
+
insert_call_tags(insertable, call_ids)
|
|
28
26
|
end
|
|
29
|
-
|
|
27
|
+
increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
private
|
|
33
31
|
|
|
32
|
+
def insert_calls_returning_ids(rows, insertable)
|
|
33
|
+
if LlmCostTracker::Call.connection.supports_insert_returning?
|
|
34
|
+
result = LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: %i[id event_id])
|
|
35
|
+
result.rows.to_h { |id, event_id| [event_id, id] }
|
|
36
|
+
else
|
|
37
|
+
LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
|
|
38
|
+
call_ids_for(insertable)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
34
42
|
def attributes_for(event)
|
|
35
43
|
attributes = {
|
|
36
44
|
event_id: event.event_id,
|
|
@@ -109,7 +117,7 @@ module LlmCostTracker
|
|
|
109
117
|
{
|
|
110
118
|
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
111
119
|
key: key.to_s,
|
|
112
|
-
value:
|
|
120
|
+
value: Tags::Encoding.encode(value)
|
|
113
121
|
}
|
|
114
122
|
end
|
|
115
123
|
end
|
|
@@ -118,15 +126,18 @@ module LlmCostTracker
|
|
|
118
126
|
LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
|
|
119
127
|
end
|
|
120
128
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
when Hash, Array then JSON.generate(stored_tag_value(value))
|
|
124
|
-
else value.to_s
|
|
125
|
-
end
|
|
129
|
+
def stored_details(details)
|
|
130
|
+
(details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
|
|
126
131
|
end
|
|
127
132
|
|
|
128
|
-
def
|
|
129
|
-
|
|
133
|
+
def increment_rollups_safely(events)
|
|
134
|
+
Ledger::Rollups.increment_many!(events)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
raise if LlmCostTracker::Call.connection.open_transactions.positive?
|
|
137
|
+
|
|
138
|
+
LlmCostTracker::Logging.warn(
|
|
139
|
+
"Rollup increment failed for #{events.size} events after ledger commit: #{e.class}: #{e.message}"
|
|
140
|
+
)
|
|
130
141
|
end
|
|
131
142
|
|
|
132
143
|
def insertable_events(events)
|
|
@@ -138,14 +149,6 @@ module LlmCostTracker
|
|
|
138
149
|
!existing_ids.include?(event_id) && seen_ids.add?(event_id)
|
|
139
150
|
end
|
|
140
151
|
end
|
|
141
|
-
|
|
142
|
-
def stored_tag_value(value)
|
|
143
|
-
if value.is_a?(Hash)
|
|
144
|
-
return value.transform_keys(&:to_s).transform_values { |nested| stored_tag_value(nested) }
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
value.to_s
|
|
148
|
-
end
|
|
149
152
|
end
|
|
150
153
|
end
|
|
151
154
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Tags
|
|
8
|
+
module Encoding
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def encode(value)
|
|
12
|
+
case value
|
|
13
|
+
when Hash then JSON.generate(normalize_hash(value))
|
|
14
|
+
when Array then JSON.generate(normalize_array(value))
|
|
15
|
+
else value.to_s
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalize_hash(hash)
|
|
20
|
+
hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def normalize_array(array)
|
|
24
|
+
array.map { |v| normalize_value(v) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalize_value(value)
|
|
28
|
+
case value
|
|
29
|
+
when Hash then normalize_hash(value)
|
|
30
|
+
when Array then normalize_array(value)
|
|
31
|
+
else value.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "
|
|
3
|
+
require_relative "encoding"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Ledger
|
|
@@ -8,7 +8,7 @@ module LlmCostTracker
|
|
|
8
8
|
module Query
|
|
9
9
|
class << self
|
|
10
10
|
def apply(tags)
|
|
11
|
-
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(
|
|
11
|
+
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
|
|
12
12
|
return LlmCostTracker::Call.all if normalized_tags.empty?
|
|
13
13
|
|
|
14
14
|
normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
|
|
@@ -5,7 +5,8 @@ require_relative "ledger/schema/calls"
|
|
|
5
5
|
require_relative "ledger/schema/call_rollups"
|
|
6
6
|
require_relative "ledger/schema/call_line_items"
|
|
7
7
|
require_relative "ledger/schema/call_tags"
|
|
8
|
-
require_relative "ledger/schema/
|
|
8
|
+
require_relative "ledger/schema/ingestion_inbox_entries"
|
|
9
|
+
require_relative "ledger/schema/ingestion_leases"
|
|
9
10
|
require_relative "ledger/tags/query"
|
|
10
11
|
require_relative "ledger/tags/sql"
|
|
11
12
|
require_relative "ledger/period"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Masking
|
|
5
|
+
SENSITIVE_KEYS = %i[
|
|
6
|
+
provider_api_key_id provider_workspace_id provider_organization_id provider_project_id
|
|
7
|
+
].to_set.freeze
|
|
8
|
+
MASK_TAIL_LENGTH = 4
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def mask_value(key, value)
|
|
13
|
+
string = value.to_s
|
|
14
|
+
return string unless SENSITIVE_KEYS.include?(key.to_sym)
|
|
15
|
+
return string if string.length <= MASK_TAIL_LENGTH
|
|
16
|
+
|
|
17
|
+
"***#{string[-MASK_TAIL_LENGTH, MASK_TAIL_LENGTH]}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def format_attribution(attribution, separator: ", ")
|
|
21
|
+
return "" if attribution.nil? || attribution.empty?
|
|
22
|
+
|
|
23
|
+
attribution.map { |key, value| "#{key}=#{mask_value(key, value)}" }.join(separator)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mask_hash(hash)
|
|
27
|
+
return hash unless hash.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
hash.each_with_object({}) do |(key, value), masked|
|
|
30
|
+
masked[key] = case value
|
|
31
|
+
when Hash then mask_hash(value)
|
|
32
|
+
when Array then value.map { |entry| entry.is_a?(Hash) ? mask_hash(entry) : entry }
|
|
33
|
+
else
|
|
34
|
+
mask_value(key, value)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "faraday"
|
|
4
4
|
require "json"
|
|
5
|
+
require "stringio"
|
|
5
6
|
require "uri"
|
|
6
7
|
|
|
7
8
|
require_relative "../logging"
|
|
@@ -17,34 +18,116 @@ module LlmCostTracker
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def call(request_env)
|
|
20
|
-
return @app.call(request_env) unless
|
|
21
|
+
return @app.call(request_env) unless enabled?
|
|
21
22
|
|
|
22
23
|
request_url = request_env.url.to_s
|
|
23
24
|
request_body = read_body(request_env.body)
|
|
24
25
|
parser = Parsers.find_for(request_url)
|
|
25
|
-
|
|
26
|
+
request_parsed = parser ? safe_json_parse(request_body) : nil
|
|
27
|
+
streaming = parser&.streaming_request?(request_url, request_parsed)
|
|
28
|
+
if streaming
|
|
29
|
+
request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
|
|
30
|
+
end
|
|
26
31
|
stream_buffer = install_stream_tap(request_env) if streaming
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
if parser
|
|
34
|
+
Tracker.enforce_budget!(
|
|
35
|
+
provider: parser.provider_for(request_url),
|
|
36
|
+
model: parser.model_for(request_url, request_parsed),
|
|
37
|
+
request: request_parsed
|
|
38
|
+
)
|
|
39
|
+
end
|
|
29
40
|
context_tags, metadata = tag_snapshot(request_env) if parser
|
|
30
41
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
31
42
|
|
|
43
|
+
invoke_app_with_capture(
|
|
44
|
+
request_env: request_env, parser: parser, request_url: request_url,
|
|
45
|
+
request_body: request_body, streaming: streaming, stream_buffer: stream_buffer,
|
|
46
|
+
context_tags: context_tags, metadata: metadata, started_at: started_at
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def enabled?
|
|
53
|
+
return @enabled if defined?(@enabled)
|
|
54
|
+
|
|
55
|
+
@enabled = LlmCostTracker.configuration.enabled
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def safe_json_parse(body)
|
|
59
|
+
return {} if body.nil? || body.empty?
|
|
60
|
+
|
|
61
|
+
JSON.parse(body)
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def auto_enable_stream_usage?
|
|
67
|
+
return @auto_enable_stream_usage if defined?(@auto_enable_stream_usage)
|
|
68
|
+
|
|
69
|
+
@auto_enable_stream_usage = LlmCostTracker.configuration.auto_enable_stream_usage
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
|
|
73
|
+
stream_buffer:, context_tags:, metadata:, started_at:)
|
|
74
|
+
response_received = false
|
|
32
75
|
@app.call(request_env).on_complete do |response_env|
|
|
76
|
+
response_received = true
|
|
33
77
|
process(
|
|
34
|
-
parser: parser,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
78
|
+
parser: parser, request_url: request_url, request_body: request_body,
|
|
79
|
+
response_env: response_env, latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
80
|
+
streaming: streaming, stream_buffer: stream_buffer,
|
|
81
|
+
context_tags: context_tags, metadata: metadata
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
if streaming && parser && !response_received
|
|
86
|
+
process_interrupted_stream(
|
|
87
|
+
parser: parser, request_url: request_url, request_body: request_body,
|
|
38
88
|
latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
39
|
-
|
|
40
|
-
stream_buffer: stream_buffer,
|
|
41
|
-
context_tags: context_tags,
|
|
42
|
-
metadata: metadata
|
|
89
|
+
context_tags: context_tags, metadata: metadata, error: e
|
|
43
90
|
)
|
|
44
91
|
end
|
|
92
|
+
raise
|
|
45
93
|
end
|
|
46
94
|
|
|
47
|
-
|
|
95
|
+
def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
|
|
96
|
+
return nil unless auto_enable_stream_usage?
|
|
97
|
+
return nil unless parser&.auto_enable_stream_usage?(request_url)
|
|
98
|
+
|
|
99
|
+
stream_options = request_parsed["stream_options"]
|
|
100
|
+
return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
|
|
101
|
+
|
|
102
|
+
request_parsed["stream_options"] = (stream_options || {}).merge("include_usage" => true)
|
|
103
|
+
new_body = request_parsed.to_json
|
|
104
|
+
request_env.body = new_body
|
|
105
|
+
new_body
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
|
|
109
|
+
context_tags:, metadata:, error:)
|
|
110
|
+
request = parser.safe_json_parse(request_body)
|
|
111
|
+
event = Event.build(
|
|
112
|
+
provider: parser.provider_for(request_url),
|
|
113
|
+
model: request["model"] || Event::UNKNOWN_MODEL,
|
|
114
|
+
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
115
|
+
stream: true,
|
|
116
|
+
usage_source: :unknown
|
|
117
|
+
)
|
|
118
|
+
merged_metadata = (metadata || {}).merge(
|
|
119
|
+
stream_interrupted: true,
|
|
120
|
+
stream_interrupted_error: "#{error.class}: #{error.message}"
|
|
121
|
+
)
|
|
122
|
+
Tracker.record(
|
|
123
|
+
event: event,
|
|
124
|
+
latency_ms: latency_ms,
|
|
125
|
+
metadata: merged_metadata,
|
|
126
|
+
context_tags: context_tags
|
|
127
|
+
)
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
|
|
130
|
+
end
|
|
48
131
|
|
|
49
132
|
def process(parser:, request_url:, request_body:, response_env:,
|
|
50
133
|
latency_ms:, streaming:, stream_buffer:, context_tags:, metadata:)
|
|
@@ -70,7 +153,7 @@ module LlmCostTracker
|
|
|
70
153
|
return unless parsed
|
|
71
154
|
|
|
72
155
|
Tracker.record(
|
|
73
|
-
|
|
156
|
+
event: parsed,
|
|
74
157
|
latency_ms: latency_ms,
|
|
75
158
|
metadata: metadata,
|
|
76
159
|
context_tags: context_tags
|
|
@@ -102,21 +185,14 @@ module LlmCostTracker
|
|
|
102
185
|
end
|
|
103
186
|
|
|
104
187
|
def parse_stream(parser:, request_url:, request_body:, response_env:, stream_buffer:)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return parser.parse_stream(
|
|
108
|
-
request_url: request_url,
|
|
109
|
-
request_body: request_body,
|
|
110
|
-
response_status: response_env.status,
|
|
111
|
-
response_headers: response_env.response_headers
|
|
112
|
-
)
|
|
113
|
-
end
|
|
188
|
+
overflowed = stream_buffer&.dig(:overflowed) == true
|
|
189
|
+
Logging.warn(capture_warning(request_url, stream_buffer)) if overflowed
|
|
114
190
|
|
|
115
191
|
body = stream_buffer&.dig(:buffer)&.string
|
|
116
192
|
body = read_body(response_env.body) if body.blank?
|
|
117
193
|
|
|
118
194
|
if body.blank?
|
|
119
|
-
Logging.warn(capture_warning(request_url, stream_buffer))
|
|
195
|
+
Logging.warn(capture_warning(request_url, stream_buffer)) unless overflowed
|
|
120
196
|
return parser.parse_stream(
|
|
121
197
|
request_url: request_url,
|
|
122
198
|
request_body: request_body,
|
|
@@ -125,7 +201,7 @@ module LlmCostTracker
|
|
|
125
201
|
)
|
|
126
202
|
end
|
|
127
203
|
|
|
128
|
-
events = Parsers::SSE.parse(body)
|
|
204
|
+
events = overflowed ? [] : Parsers::SSE.parse(body)
|
|
129
205
|
parser.parse_stream(
|
|
130
206
|
request_url: request_url,
|
|
131
207
|
request_body: request_body,
|
|
@@ -135,6 +211,17 @@ module LlmCostTracker
|
|
|
135
211
|
)
|
|
136
212
|
end
|
|
137
213
|
|
|
214
|
+
def forward_on_data_chunk(callable, chunk, size, env)
|
|
215
|
+
arity = callable.arity
|
|
216
|
+
return callable.call(chunk, size, env) if arity.negative?
|
|
217
|
+
|
|
218
|
+
case arity
|
|
219
|
+
when 0, 1 then callable.call(chunk)
|
|
220
|
+
when 2 then callable.call(chunk, size)
|
|
221
|
+
else callable.call(chunk, size, env)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
138
225
|
def install_stream_tap(request_env)
|
|
139
226
|
request = request_env.request
|
|
140
227
|
return nil unless request
|
|
@@ -145,16 +232,16 @@ module LlmCostTracker
|
|
|
145
232
|
state = { buffer: StringIO.new, bytes: 0, overflowed: false }
|
|
146
233
|
request.on_data = proc do |chunk, size, env|
|
|
147
234
|
chunk = chunk.to_s
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
235
|
+
remaining = Capture::Stream::LIMIT_BYTES - state[:bytes]
|
|
236
|
+
if chunk.bytesize <= remaining
|
|
237
|
+
state[:buffer] << chunk
|
|
238
|
+
state[:bytes] += chunk.bytesize
|
|
239
|
+
else
|
|
240
|
+
state[:buffer] << chunk.byteslice(0, remaining) if remaining.positive?
|
|
241
|
+
state[:bytes] += [remaining, 0].max
|
|
242
|
+
state[:overflowed] = true
|
|
156
243
|
end
|
|
157
|
-
original
|
|
244
|
+
forward_on_data_chunk(original, chunk, size, env)
|
|
158
245
|
end
|
|
159
246
|
state
|
|
160
247
|
rescue StandardError => e
|