llm_cost_tracker 0.11.0 → 0.12.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 +55 -0
- data/README.md +7 -4
- data/app/assets/llm_cost_tracker/application.css +8 -7
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +30 -44
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
- data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
- data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
- data/config/routes.rb +2 -3
- data/lib/llm_cost_tracker/budget.rb +24 -26
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/capture/sse.rb +1 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -44
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +5 -69
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +92 -106
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +70 -276
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +8 -18
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
- data/lib/llm_cost_tracker/parsers.rb +139 -26
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +40 -52
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -278
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +63 -39
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -3
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +1 -1
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +81 -55
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -174
- data/lib/llm_cost_tracker/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -36
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -176
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -230
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +0 -15
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -249
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
return unless table_exists?(TABLE)
|
|
11
|
-
|
|
12
|
-
add_index TABLE, :metadata, using: :gin, algorithm: :concurrently, if_not_exists: true
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def down
|
|
16
|
-
return unless postgresql?
|
|
17
|
-
return unless table_exists?(TABLE)
|
|
18
|
-
|
|
19
|
-
remove_index TABLE, column: :metadata, if_exists: true
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def postgresql?
|
|
25
|
-
LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|
|
@@ -1,31 +0,0 @@
|
|
|
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
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../schema/adapter"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module Ledger
|
|
7
|
-
class Rollups
|
|
8
|
-
class UpsertSql
|
|
9
|
-
def self.call
|
|
10
|
-
new.call
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def call
|
|
14
|
-
return Arel.sql(mysql_sql) if Ledger::Schema::Adapter.mysql?(connection)
|
|
15
|
-
return Arel.sql(postgres_sql) if Ledger::Schema::Adapter.postgresql?(connection)
|
|
16
|
-
|
|
17
|
-
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def postgres_sql
|
|
23
|
-
total_cost = connection.quote_column_name("total_cost")
|
|
24
|
-
updated_at = connection.quote_column_name("updated_at")
|
|
25
|
-
|
|
26
|
-
"#{total_cost} = #{LlmCostTracker::CallRollup.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
27
|
-
"#{updated_at} = excluded.#{updated_at}"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def mysql_sql
|
|
31
|
-
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def connection
|
|
35
|
-
LlmCostTracker::CallRollup.connection
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "adapter"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module Ledger
|
|
7
|
-
module Schema
|
|
8
|
-
module IngestionInboxEntries
|
|
9
|
-
REQUIRED_COLUMNS = %w[
|
|
10
|
-
event_id
|
|
11
|
-
total_cost
|
|
12
|
-
tracked_at
|
|
13
|
-
payload
|
|
14
|
-
locked_at
|
|
15
|
-
locked_by
|
|
16
|
-
attempts
|
|
17
|
-
last_error
|
|
18
|
-
created_at
|
|
19
|
-
updated_at
|
|
20
|
-
].freeze
|
|
21
|
-
|
|
22
|
-
UNIQUE_COLUMNS = %i[event_id].freeze
|
|
23
|
-
|
|
24
|
-
class << self
|
|
25
|
-
def current_schema_errors
|
|
26
|
-
connection = LlmCostTracker::Ingestion::InboxEntry.connection
|
|
27
|
-
Adapter.ensure_supported!(connection)
|
|
28
|
-
table_name = LlmCostTracker::Ingestion::InboxEntry.table_name
|
|
29
|
-
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
30
|
-
|
|
31
|
-
columns = LlmCostTracker::Ingestion::InboxEntry.columns_hash
|
|
32
|
-
cache = @schema_capabilities
|
|
33
|
-
return cache.fetch(:errors) if cache && cache.fetch(:columns).equal?(columns)
|
|
34
|
-
|
|
35
|
-
errors = compute_errors(connection, table_name, columns)
|
|
36
|
-
@schema_capabilities = { columns: columns, errors: errors }
|
|
37
|
-
errors
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
def compute_errors(connection, table_name, columns)
|
|
43
|
-
errors = []
|
|
44
|
-
missing = REQUIRED_COLUMNS - columns.keys
|
|
45
|
-
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
46
|
-
errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
|
|
47
|
-
errors
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def event_id_unique_index?(connection, table_name)
|
|
51
|
-
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
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
|
|
@@ -1,56 +0,0 @@
|
|
|
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
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "adapter"
|
|
4
|
-
|
|
5
|
-
module LlmCostTracker
|
|
6
|
-
module Ledger
|
|
7
|
-
module Schema
|
|
8
|
-
module ProviderInvoices
|
|
9
|
-
REQUIRED_COLUMNS = %w[
|
|
10
|
-
source period_start period_end external_id billed_amount currency metadata imported_at
|
|
11
|
-
].freeze
|
|
12
|
-
UNIQUE_INDEX_COLUMNS = %i[external_id].freeze
|
|
13
|
-
SOURCE_PERIOD_INDEX_COLUMNS = %i[source currency period_start].freeze
|
|
14
|
-
|
|
15
|
-
class << self
|
|
16
|
-
def current_schema_errors
|
|
17
|
-
connection = LlmCostTracker::Call.connection
|
|
18
|
-
Adapter.ensure_supported!(connection)
|
|
19
|
-
table_name = LlmCostTracker::ProviderInvoice.table_name
|
|
20
|
-
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
21
|
-
|
|
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 }
|
|
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(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
|
|
43
|
-
return [] if missing.empty?
|
|
44
|
-
|
|
45
|
-
["missing columns: #{missing.join(', ')}"]
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def index_errors(connection, table_name)
|
|
49
|
-
errors = []
|
|
50
|
-
unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
|
|
51
|
-
errors << "missing unique index: external_id"
|
|
52
|
-
end
|
|
53
|
-
unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
|
|
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)"
|
|
59
|
-
end
|
|
60
|
-
errors
|
|
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
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "base"
|
|
4
|
-
require_relative "../providers/anthropic/server_tools"
|
|
5
|
-
require_relative "../providers/anthropic/tier_classification"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module Parsers
|
|
9
|
-
class Anthropic < Base
|
|
10
|
-
HOSTS = %w[api.anthropic.com].freeze
|
|
11
|
-
|
|
12
|
-
class << self
|
|
13
|
-
def match?(url)
|
|
14
|
-
match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def provider_names
|
|
18
|
-
%w[anthropic]
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def parse(request_body:, response_status:, response_body:, **)
|
|
23
|
-
return nil unless response_status == 200
|
|
24
|
-
|
|
25
|
-
response = safe_json_parse(response_body)
|
|
26
|
-
usage = response["usage"]
|
|
27
|
-
return nil unless usage
|
|
28
|
-
|
|
29
|
-
request = safe_json_parse(request_body)
|
|
30
|
-
cache_read = usage["cache_read_input_tokens"].to_i
|
|
31
|
-
|
|
32
|
-
Event.build(
|
|
33
|
-
provider: "anthropic",
|
|
34
|
-
provider_response_id: response["id"],
|
|
35
|
-
pricing_mode: pricing_mode(request: request, usage: usage),
|
|
36
|
-
model: response["model"] || request["model"],
|
|
37
|
-
token_usage: token_usage(usage: usage, cache_read: cache_read),
|
|
38
|
-
usage_source: :response,
|
|
39
|
-
service_line_items: service_line_items(usage)
|
|
40
|
-
)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def parse_stream(response_status:, request_body: nil, events: [], **)
|
|
44
|
-
return nil unless response_status == 200
|
|
45
|
-
|
|
46
|
-
request = safe_json_parse(request_body)
|
|
47
|
-
model = find_event_value(events) { |data| data.dig("message", "model") } || request["model"]
|
|
48
|
-
usage = stream_usage(events)
|
|
49
|
-
response_id = find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
|
|
50
|
-
|
|
51
|
-
if usage
|
|
52
|
-
build_stream_result(
|
|
53
|
-
model: model,
|
|
54
|
-
usage: usage,
|
|
55
|
-
response_id: response_id,
|
|
56
|
-
pricing_mode: pricing_mode(request: request, usage: usage)
|
|
57
|
-
)
|
|
58
|
-
else
|
|
59
|
-
build_unknown_stream_usage(
|
|
60
|
-
provider: "anthropic",
|
|
61
|
-
model: model,
|
|
62
|
-
provider_response_id: response_id,
|
|
63
|
-
pricing_mode: pricing_mode(request: request, usage: usage)
|
|
64
|
-
)
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def provider_for(_request_url)
|
|
69
|
-
"anthropic"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
def stream_usage(events)
|
|
75
|
-
latest_delta = find_event_value(events, reverse: true) do |data|
|
|
76
|
-
data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
|
|
77
|
-
end
|
|
78
|
-
return nil unless latest_delta
|
|
79
|
-
|
|
80
|
-
start_usage = find_event_value(events, reverse: true) do |data|
|
|
81
|
-
data.dig("message", "usage") if data["type"] == "message_start"
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
(start_usage || {}).merge(latest_delta) do |_key, start_val, delta_val|
|
|
85
|
-
delta_val || start_val
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def build_stream_result(model:, usage:, response_id:, pricing_mode:)
|
|
90
|
-
cache_read = usage["cache_read_input_tokens"].to_i
|
|
91
|
-
|
|
92
|
-
Event.build(
|
|
93
|
-
provider: "anthropic",
|
|
94
|
-
provider_response_id: response_id,
|
|
95
|
-
pricing_mode: pricing_mode,
|
|
96
|
-
model: model,
|
|
97
|
-
token_usage: token_usage(usage: usage, cache_read: cache_read),
|
|
98
|
-
stream: true,
|
|
99
|
-
usage_source: :stream_final,
|
|
100
|
-
service_line_items: service_line_items(usage)
|
|
101
|
-
)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def service_line_items(usage)
|
|
105
|
-
server_tool_use = usage["server_tool_use"]
|
|
106
|
-
return [] unless server_tool_use.is_a?(Hash)
|
|
107
|
-
|
|
108
|
-
Providers::Anthropic::ServerTools::LINE_ITEMS.filter_map do |component_key, count_key|
|
|
109
|
-
quantity = server_tool_use[count_key.to_s].to_i
|
|
110
|
-
next if quantity.zero?
|
|
111
|
-
|
|
112
|
-
Billing::LineItem.build(
|
|
113
|
-
component_key: component_key,
|
|
114
|
-
quantity: quantity,
|
|
115
|
-
cost_status: Billing::CostStatus::UNKNOWN,
|
|
116
|
-
pricing_basis: :provider_usage,
|
|
117
|
-
provider_field: "usage.server_tool_use.#{count_key}"
|
|
118
|
-
)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def token_usage(usage:, cache_read:)
|
|
123
|
-
input = usage["input_tokens"].to_i
|
|
124
|
-
output = usage["output_tokens"].to_i
|
|
125
|
-
cache_creation = usage["cache_creation"]
|
|
126
|
-
if cache_creation.is_a?(Hash)
|
|
127
|
-
cache_write = cache_creation["ephemeral_5m_input_tokens"].to_i
|
|
128
|
-
cache_write_extended = cache_creation["ephemeral_1h_input_tokens"].to_i
|
|
129
|
-
else
|
|
130
|
-
warn_unexpected_cache_creation(cache_creation, usage)
|
|
131
|
-
cache_write = usage["cache_creation_input_tokens"].to_i
|
|
132
|
-
cache_write_extended = 0
|
|
133
|
-
end
|
|
134
|
-
hidden_output = (
|
|
135
|
-
usage["thinking_tokens"] || usage["thinking_output_tokens"] ||
|
|
136
|
-
usage.dig("output_tokens_details", "reasoning_tokens")
|
|
137
|
-
).to_i
|
|
138
|
-
|
|
139
|
-
TokenUsage.build(
|
|
140
|
-
input_tokens: input,
|
|
141
|
-
output_tokens: output,
|
|
142
|
-
total_tokens: input + output + cache_read + cache_write + cache_write_extended,
|
|
143
|
-
cache_read_input_tokens: cache_read,
|
|
144
|
-
cache_write_input_tokens: cache_write,
|
|
145
|
-
cache_write_extended_input_tokens: cache_write_extended,
|
|
146
|
-
hidden_output_tokens: hidden_output
|
|
147
|
-
)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def warn_unexpected_cache_creation(cache_creation, usage)
|
|
151
|
-
return if cache_creation.nil? || usage.key?("cache_creation_input_tokens")
|
|
152
|
-
|
|
153
|
-
Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def pricing_mode(request:, usage:)
|
|
157
|
-
modes = []
|
|
158
|
-
speed = usage&.fetch("speed", nil) || request["speed"]
|
|
159
|
-
service_tier = usage&.fetch("service_tier", nil) || request["service_tier"]
|
|
160
|
-
service_tier = nil if Providers::Anthropic::TierClassification.standard_equivalent_tier?(service_tier)
|
|
161
|
-
|
|
162
|
-
modes << Pricing::Mode.normalize(speed)
|
|
163
|
-
modes << Pricing::Mode.normalize(service_tier)
|
|
164
|
-
geo = inference_geo(request: request, usage: usage).downcase
|
|
165
|
-
modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
|
|
166
|
-
|
|
167
|
-
modes = modes.compact.uniq
|
|
168
|
-
modes.empty? ? nil : modes.join("_")
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def inference_geo(request:, usage:)
|
|
172
|
-
(usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
end
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "base"
|
|
4
|
-
require_relative "openai_usage"
|
|
5
|
-
require_relative "../providers/azure/hosts"
|
|
6
|
-
|
|
7
|
-
module LlmCostTracker
|
|
8
|
-
module Parsers
|
|
9
|
-
class Azure < Base
|
|
10
|
-
include OpenaiUsage
|
|
11
|
-
|
|
12
|
-
TRACKED_ENDPOINTS = %w[
|
|
13
|
-
chat/completions completions embeddings moderations responses
|
|
14
|
-
audio/transcriptions audio/translations audio/speech
|
|
15
|
-
images/generations images/edits images/variations
|
|
16
|
-
].freeze
|
|
17
|
-
|
|
18
|
-
PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
|
|
19
|
-
|
|
20
|
-
class << self
|
|
21
|
-
def match?(url)
|
|
22
|
-
uri_matches?(url) do |uri|
|
|
23
|
-
LlmCostTracker::Providers::Azure::Hosts.openai?(uri.host) && uri.path.to_s.match?(PATH_PATTERN)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def provider_names
|
|
28
|
-
%w[azure_openai]
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def provider_for(_request_url)
|
|
33
|
-
"azure_openai"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def model_for(request_url, request_parsed)
|
|
37
|
-
body_model = super
|
|
38
|
-
return body_model if body_model
|
|
39
|
-
|
|
40
|
-
uri = parsed_uri(request_url)
|
|
41
|
-
match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
|
|
42
|
-
match && match[1]
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|