llm_cost_tracker 0.7.3 → 0.9.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 +173 -0
- data/README.md +60 -220
- data/app/assets/llm_cost_tracker/application.css +282 -45
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
- 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 +18 -21
- 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/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 +24 -7
- data/app/models/llm_cost_tracker/call.rb +166 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
- data/app/models/llm_cost_tracker/call_tag.rb +12 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
- 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/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
- 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/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 +66 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- 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 +64 -36
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +95 -0
- data/lib/llm_cost_tracker/billing/components.yml +188 -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 +26 -36
- data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +86 -17
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- 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 +34 -0
- data/lib/llm_cost_tracker/doctor.rb +111 -44
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +11 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- 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_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -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/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
- data/lib/llm_cost_tracker/ingestion.rb +66 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
- data/lib/llm_cost_tracker/integrations/base.rb +56 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
- data/lib/llm_cost_tracker/integrations.rb +21 -3
- data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +103 -20
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +5 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
- data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
- data/lib/llm_cost_tracker/parsers/base.rb +13 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
- 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 +198 -22
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -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/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +220 -28
- data/lib/llm_cost_tracker/railtie.rb +6 -8
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -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 +19 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +22 -9
- data/lib/llm_cost_tracker/tags/context.rb +2 -5
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +64 -42
- data/lib/llm_cost_tracker/tracker.rb +97 -27
- 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 +45 -35
- data/lib/tasks/llm_cost_tracker.rake +45 -17
- metadata +71 -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_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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Ledger
|
|
5
|
+
module Schema
|
|
6
|
+
module CallTags
|
|
7
|
+
REQUIRED_COLUMNS = %w[llm_cost_tracker_call_id key value].freeze
|
|
8
|
+
|
|
9
|
+
REQUIRED_INDEX_COLUMNS = [
|
|
10
|
+
%w[key value],
|
|
11
|
+
%w[llm_cost_tracker_call_id]
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def current_schema_errors
|
|
16
|
+
connection = LlmCostTracker::Call.connection
|
|
17
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
18
|
+
table_name = LlmCostTracker::CallTag.table_name
|
|
19
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
20
|
+
|
|
21
|
+
columns = LlmCostTracker::CallTag.columns_hash
|
|
22
|
+
errors = []
|
|
23
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
24
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
25
|
+
errors.concat(missing_index_errors(connection, table_name))
|
|
26
|
+
errors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def missing_index_errors(connection, table_name)
|
|
30
|
+
existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
|
|
31
|
+
REQUIRED_INDEX_COLUMNS.filter_map do |required|
|
|
32
|
+
next if existing.any? { |columns| (required - columns).empty? }
|
|
33
|
+
|
|
34
|
+
"missing index on (#{required.join(', ')})"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -15,23 +15,37 @@ module LlmCostTracker
|
|
|
15
15
|
total_tokens
|
|
16
16
|
cache_read_input_tokens
|
|
17
17
|
cache_write_input_tokens
|
|
18
|
-
|
|
18
|
+
cache_write_extended_input_tokens
|
|
19
|
+
audio_input_tokens
|
|
20
|
+
audio_output_tokens
|
|
21
|
+
image_input_tokens
|
|
22
|
+
image_output_tokens
|
|
19
23
|
hidden_output_tokens
|
|
20
|
-
input_cost
|
|
21
|
-
output_cost
|
|
22
24
|
total_cost
|
|
23
|
-
cache_read_input_cost
|
|
24
|
-
cache_write_input_cost
|
|
25
|
-
cache_write_1h_input_cost
|
|
26
25
|
latency_ms
|
|
27
26
|
stream
|
|
28
27
|
usage_source
|
|
29
28
|
provider_response_id
|
|
29
|
+
provider_project_id
|
|
30
|
+
provider_api_key_id
|
|
31
|
+
provider_workspace_id
|
|
32
|
+
batch
|
|
30
33
|
pricing_mode
|
|
31
|
-
|
|
34
|
+
cost_status
|
|
35
|
+
pricing_snapshot
|
|
32
36
|
tracked_at
|
|
33
37
|
].freeze
|
|
34
38
|
|
|
39
|
+
REQUIRED_INDEXES = [
|
|
40
|
+
{ columns: :event_id, unique: true },
|
|
41
|
+
{ columns: :tracked_at },
|
|
42
|
+
{ columns: %i[provider tracked_at] },
|
|
43
|
+
{ columns: %i[model tracked_at] },
|
|
44
|
+
{ columns: :cost_status },
|
|
45
|
+
{ columns: :provider_response_id }
|
|
46
|
+
].freeze
|
|
47
|
+
private_constant :REQUIRED_INDEXES
|
|
48
|
+
|
|
35
49
|
class << self
|
|
36
50
|
def current_schema?
|
|
37
51
|
current_schema_errors.empty?
|
|
@@ -48,8 +62,8 @@ module LlmCostTracker
|
|
|
48
62
|
private
|
|
49
63
|
|
|
50
64
|
def schema_capabilities
|
|
51
|
-
columns =
|
|
52
|
-
adapter_name =
|
|
65
|
+
columns = LlmCostTracker::Call.columns_hash
|
|
66
|
+
adapter_name = LlmCostTracker::Call.connection.adapter_name
|
|
53
67
|
cache = @schema_capabilities
|
|
54
68
|
|
|
55
69
|
return cache.fetch(:values) if cache && cache.fetch(:columns).equal?(columns) &&
|
|
@@ -73,22 +87,21 @@ module LlmCostTracker
|
|
|
73
87
|
errors = []
|
|
74
88
|
missing = missing_columns_for(columns)
|
|
75
89
|
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
90
|
+
errors.concat(Adapter.json_column_errors(columns["pricing_snapshot"], adapter_name, "pricing_snapshot"))
|
|
91
|
+
errors.concat(missing_index_errors)
|
|
92
|
+
errors
|
|
93
|
+
end
|
|
76
94
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
valid_type =
|
|
82
|
-
if postgresql
|
|
83
|
-
tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb"
|
|
84
|
-
else
|
|
85
|
-
tag_column.type == :json
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
errors << "tags column must use #{expected_type}" unless valid_type
|
|
89
|
-
end
|
|
95
|
+
def missing_index_errors
|
|
96
|
+
connection = LlmCostTracker::Call.connection
|
|
97
|
+
REQUIRED_INDEXES.filter_map do |spec|
|
|
98
|
+
next if connection.index_exists?(LlmCostTracker::Call.table_name, spec[:columns], **spec.except(:columns))
|
|
90
99
|
|
|
91
|
-
|
|
100
|
+
prefix = spec[:unique] ? "unique " : ""
|
|
101
|
+
"missing #{prefix}index: #{Array(spec[:columns]).join(', ')}"
|
|
102
|
+
end
|
|
103
|
+
rescue StandardError
|
|
104
|
+
[]
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
def missing_columns_for(columns)
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
errors = []
|
|
32
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::InboxEntry.columns_hash.keys
|
|
33
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
34
|
+
errors << "missing unique index: event_id" unless event_id_unique_index?(connection, table_name)
|
|
35
|
+
errors
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def event_id_unique_index?(connection, table_name)
|
|
41
|
+
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
errors = []
|
|
27
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::Ingestion::Lease.columns_hash.keys
|
|
28
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
29
|
+
errors << "missing unique index: name" unless name_unique_index?(connection, table_name)
|
|
30
|
+
errors
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def name_unique_index?(connection, table_name)
|
|
36
|
+
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
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 cursor window_start window_end state last_error
|
|
11
|
+
rows_imported started_at finished_at
|
|
12
|
+
].freeze
|
|
13
|
+
SOURCE_STARTED_AT_INDEX = %i[source 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
|
+
errors = []
|
|
23
|
+
errors.concat(column_errors)
|
|
24
|
+
errors.concat(index_errors(connection, table_name))
|
|
25
|
+
errors
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def column_errors
|
|
31
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoiceImport.columns_hash.keys
|
|
32
|
+
return [] if missing.empty?
|
|
33
|
+
|
|
34
|
+
["missing columns: #{missing.join(', ')}"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def index_errors(connection, table_name)
|
|
38
|
+
return [] if connection.index_exists?(table_name, SOURCE_STARTED_AT_INDEX)
|
|
39
|
+
|
|
40
|
+
["missing index: source, started_at"]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
errors = []
|
|
23
|
+
errors.concat(column_errors)
|
|
24
|
+
errors.concat(metadata_type_errors(connection))
|
|
25
|
+
errors.concat(index_errors(connection, table_name))
|
|
26
|
+
errors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def column_errors
|
|
32
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::ProviderInvoice.columns_hash.keys
|
|
33
|
+
return [] if missing.empty?
|
|
34
|
+
|
|
35
|
+
["missing columns: #{missing.join(', ')}"]
|
|
36
|
+
end
|
|
37
|
+
|
|
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
|
+
def index_errors(connection, table_name)
|
|
44
|
+
errors = []
|
|
45
|
+
unless connection.index_exists?(table_name, UNIQUE_INDEX_COLUMNS, unique: true)
|
|
46
|
+
errors << "missing unique index: external_id"
|
|
47
|
+
end
|
|
48
|
+
unless connection.index_exists?(table_name, SOURCE_PERIOD_INDEX_COLUMNS)
|
|
49
|
+
errors << "missing index: source, currency, period_start"
|
|
50
|
+
end
|
|
51
|
+
errors
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
require_relative "../pricing"
|
|
6
|
+
require_relative "../billing/line_item"
|
|
4
7
|
require_relative "rollups"
|
|
8
|
+
require_relative "tags/encoding"
|
|
5
9
|
|
|
6
10
|
module LlmCostTracker
|
|
7
11
|
module Ledger
|
|
@@ -14,9 +18,14 @@ module LlmCostTracker
|
|
|
14
18
|
insertable = insertable_events(events)
|
|
15
19
|
|
|
16
20
|
if insertable.any?
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
LlmCostTracker::Call.transaction do
|
|
22
|
+
rows = insertable.map { |event| attributes_for(event) }
|
|
23
|
+
LlmCostTracker::Call.insert_all!(rows, record_timestamps: true, returning: false)
|
|
24
|
+
call_ids = call_ids_for(insertable)
|
|
25
|
+
insert_line_items(insertable, call_ids)
|
|
26
|
+
insert_call_tags(insertable, call_ids)
|
|
27
|
+
end
|
|
28
|
+
increment_rollups_safely(insertable) if LlmCostTracker.configuration.cache_rollups
|
|
20
29
|
end
|
|
21
30
|
events
|
|
22
31
|
end
|
|
@@ -28,40 +37,114 @@ module LlmCostTracker
|
|
|
28
37
|
event_id: event.event_id,
|
|
29
38
|
provider: event.provider,
|
|
30
39
|
model: event.model,
|
|
31
|
-
tags: stored_tags(event.tags),
|
|
32
40
|
tracked_at: event.tracked_at,
|
|
33
|
-
pricing_mode: event.pricing_mode,
|
|
41
|
+
pricing_mode: event.pricing_mode&.name,
|
|
34
42
|
latency_ms: event.latency_ms,
|
|
35
43
|
stream: event.stream,
|
|
36
|
-
usage_source: event.usage_source,
|
|
37
|
-
provider_response_id: event.provider_response_id
|
|
44
|
+
usage_source: event.usage_source&.name,
|
|
45
|
+
provider_response_id: event.provider_response_id,
|
|
46
|
+
provider_project_id: event.provider_project_id,
|
|
47
|
+
provider_api_key_id: event.provider_api_key_id,
|
|
48
|
+
provider_workspace_id: event.provider_workspace_id,
|
|
49
|
+
batch: event.batch,
|
|
50
|
+
cost_status: event.cost_status,
|
|
51
|
+
pricing_snapshot: event.pricing_snapshot
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
attributes
|
|
41
|
-
.merge(event.token_usage.
|
|
55
|
+
.merge(event.token_usage.to_h)
|
|
42
56
|
.merge(Pricing.stored_cost_attributes(event.cost || {}))
|
|
43
57
|
end
|
|
44
58
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
def call_ids_for(events)
|
|
60
|
+
LlmCostTracker::Call
|
|
61
|
+
.where(event_id: events.map(&:event_id))
|
|
62
|
+
.pluck(:event_id, :id)
|
|
63
|
+
.to_h
|
|
64
|
+
end
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
def insert_line_items(events, call_ids)
|
|
67
|
+
rows = events.flat_map do |event|
|
|
68
|
+
(event.line_items || []).each_with_index.map do |line_item, position|
|
|
69
|
+
line_item_attributes(
|
|
70
|
+
call_id: call_ids.fetch(event.event_id),
|
|
71
|
+
line_item: line_item,
|
|
72
|
+
position: position
|
|
73
|
+
)
|
|
74
|
+
end
|
|
52
75
|
end
|
|
76
|
+
return if rows.empty?
|
|
77
|
+
|
|
78
|
+
LlmCostTracker::CallLineItem.insert_all!(rows, record_timestamps: false, returning: false)
|
|
53
79
|
end
|
|
54
80
|
|
|
55
|
-
def
|
|
56
|
-
|
|
81
|
+
def line_item_attributes(call_id:, line_item:, position:)
|
|
82
|
+
{
|
|
83
|
+
llm_cost_tracker_call_id: call_id,
|
|
84
|
+
position: position,
|
|
85
|
+
kind: line_item.kind&.to_s,
|
|
86
|
+
direction: line_item.direction&.to_s,
|
|
87
|
+
modality: line_item.modality&.to_s,
|
|
88
|
+
cache_state: line_item.cache_state&.to_s || "none",
|
|
89
|
+
quantity: line_item.quantity,
|
|
90
|
+
unit: line_item.unit&.to_s,
|
|
91
|
+
rate_amount: line_item.rate_amount,
|
|
92
|
+
rate_quantity: line_item.rate_quantity,
|
|
93
|
+
cost: line_item.cost,
|
|
94
|
+
currency: line_item.currency,
|
|
95
|
+
cost_status: line_item.cost_status,
|
|
96
|
+
pricing_basis: line_item.pricing_basis&.to_s,
|
|
97
|
+
price_key: line_item.price_key,
|
|
98
|
+
price_source: line_item.price_source&.to_s,
|
|
99
|
+
price_source_version: line_item.price_source_version,
|
|
100
|
+
provider_field: line_item.provider_field,
|
|
101
|
+
provider_item_id: line_item.provider_item_id,
|
|
102
|
+
details: stored_details(line_item.details),
|
|
103
|
+
created_at: Time.now.utc
|
|
104
|
+
}
|
|
57
105
|
end
|
|
58
106
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
107
|
+
def insert_call_tags(events, call_ids)
|
|
108
|
+
rows = events.flat_map do |event|
|
|
109
|
+
(event.tags || {}).map do |key, value|
|
|
110
|
+
{
|
|
111
|
+
llm_cost_tracker_call_id: call_ids.fetch(event.event_id),
|
|
112
|
+
key: key.to_s,
|
|
113
|
+
value: tag_row_value(value)
|
|
114
|
+
}
|
|
115
|
+
end
|
|
62
116
|
end
|
|
117
|
+
return if rows.empty?
|
|
118
|
+
|
|
119
|
+
LlmCostTracker::CallTag.insert_all!(rows, record_timestamps: false, returning: false)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def tag_row_value(value)
|
|
123
|
+
Tags::Encoding.encode(value)
|
|
124
|
+
end
|
|
63
125
|
|
|
64
|
-
|
|
126
|
+
def stored_details(details)
|
|
127
|
+
(details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def increment_rollups_safely(events)
|
|
131
|
+
Ledger::Rollups.increment_many!(events)
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
raise if LlmCostTracker::Call.connection.open_transactions.positive?
|
|
134
|
+
|
|
135
|
+
LlmCostTracker::Logging.warn(
|
|
136
|
+
"Rollup increment failed for #{events.size} events after ledger commit: #{e.class}: #{e.message}"
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def insertable_events(events)
|
|
141
|
+
existing_ids = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
|
|
142
|
+
seen_ids = Set.new
|
|
143
|
+
|
|
144
|
+
events.select do |event|
|
|
145
|
+
event_id = event.event_id
|
|
146
|
+
!existing_ids.include?(event_id) && seen_ids.add?(event_id)
|
|
147
|
+
end
|
|
65
148
|
end
|
|
66
149
|
end
|
|
67
150
|
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,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
3
|
require_relative "../schema/adapter"
|
|
4
|
+
require_relative "encoding"
|
|
6
5
|
|
|
7
6
|
module LlmCostTracker
|
|
8
7
|
module Ledger
|
|
@@ -10,16 +9,12 @@ module LlmCostTracker
|
|
|
10
9
|
module Query
|
|
11
10
|
class << self
|
|
12
11
|
def apply(tags)
|
|
13
|
-
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values(
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
connection = Ledger::Call.connection
|
|
17
|
-
json = normalized_tags.to_json
|
|
12
|
+
normalized_tags = (tags || {}).to_h.transform_keys(&:to_s).transform_values { |v| Encoding.encode(v) }
|
|
13
|
+
return LlmCostTracker::Call.all if normalized_tags.empty?
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Ledger::Call.where("JSON_CONTAINS(tags, ?)", json)
|
|
15
|
+
normalized_tags.inject(LlmCostTracker::Call.all) do |relation, (key, value)|
|
|
16
|
+
relation.where(id: LlmCostTracker::CallTag.where(key: key,
|
|
17
|
+
value: value).select(:llm_cost_tracker_call_id))
|
|
23
18
|
end
|
|
24
19
|
end
|
|
25
20
|
end
|
|
@@ -1,31 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../schema/adapter"
|
|
4
3
|
require_relative "../../tags/key"
|
|
5
4
|
|
|
6
5
|
module LlmCostTracker
|
|
7
6
|
module Ledger
|
|
8
7
|
module Tags
|
|
9
8
|
module Sql
|
|
9
|
+
UNTAGGED_LABEL = "(untagged)"
|
|
10
|
+
|
|
10
11
|
class << self
|
|
11
|
-
def
|
|
12
|
-
|
|
13
|
-
connection =
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
12
|
+
def join_relation(scope, key)
|
|
13
|
+
validated_key = LlmCostTracker::Tags::Key.validate!(key)
|
|
14
|
+
connection = scope.connection
|
|
15
|
+
join = "LEFT OUTER JOIN #{call_tag_table} ON " \
|
|
16
|
+
"#{call_tag_table}.llm_cost_tracker_call_id = #{scope.quoted_table_name}.id AND " \
|
|
17
|
+
"#{call_tag_table}.#{connection.quote_column_name('key')} = #{connection.quote(validated_key)}"
|
|
18
|
+
scope.joins(join)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def value_arel
|
|
22
|
+
Arel.sql("#{call_tag_table}.#{quote_column('value')}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def label_sql(connection)
|
|
26
|
+
"COALESCE(NULLIF(#{raw_value_sql(connection)}, ''), #{connection.quote(UNTAGGED_LABEL)})"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raw_value_sql(connection)
|
|
30
|
+
"#{call_tag_table}.#{connection.quote_column_name('value')}"
|
|
23
31
|
end
|
|
24
32
|
|
|
25
33
|
private
|
|
26
34
|
|
|
27
|
-
def
|
|
28
|
-
|
|
35
|
+
def call_tag_table
|
|
36
|
+
LlmCostTracker::CallTag.quoted_table_name
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def quote_column(name)
|
|
40
|
+
LlmCostTracker::CallTag.connection.quote_column_name(name)
|
|
29
41
|
end
|
|
30
42
|
end
|
|
31
43
|
end
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "ledger/schema/adapter"
|
|
4
4
|
require_relative "ledger/schema/calls"
|
|
5
|
-
require_relative "ledger/schema/
|
|
5
|
+
require_relative "ledger/schema/call_rollups"
|
|
6
|
+
require_relative "ledger/schema/call_line_items"
|
|
7
|
+
require_relative "ledger/schema/call_tags"
|
|
8
|
+
require_relative "ledger/schema/ingestion_inbox_entries"
|
|
9
|
+
require_relative "ledger/schema/ingestion_leases"
|
|
6
10
|
require_relative "ledger/tags/query"
|
|
7
11
|
require_relative "ledger/tags/sql"
|
|
8
12
|
require_relative "ledger/period"
|
|
9
|
-
require_relative "ledger/rollups/batch"
|
|
10
13
|
require_relative "ledger/rollups/upsert_sql"
|
|
11
14
|
require_relative "ledger/rollups"
|
|
12
15
|
require_relative "ledger/store"
|
|
@@ -20,12 +20,9 @@ module LlmCostTracker
|
|
|
20
20
|
def log(level, message)
|
|
21
21
|
message = prefixed(message)
|
|
22
22
|
logger = Rails.logger
|
|
23
|
+
return Kernel.warn(message) unless logger
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
logger.try(level, message)
|
|
26
|
-
else
|
|
27
|
-
Kernel.warn(message)
|
|
28
|
-
end
|
|
25
|
+
logger.public_send(level, message)
|
|
29
26
|
end
|
|
30
27
|
|
|
31
28
|
private
|