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
|
@@ -2,29 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
4
|
require "active_support/core_ext/object/deep_dup"
|
|
5
|
+
require "json"
|
|
5
6
|
|
|
6
7
|
require_relative "stream"
|
|
8
|
+
require_relative "../pricing/mode"
|
|
9
|
+
require_relative "../timing"
|
|
7
10
|
|
|
8
11
|
module LlmCostTracker
|
|
9
12
|
module Capture
|
|
10
13
|
class StreamCollector
|
|
11
14
|
attr_reader :provider
|
|
12
15
|
|
|
13
|
-
def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil,
|
|
14
|
-
|
|
16
|
+
def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, provider_project_id: nil,
|
|
17
|
+
provider_api_key_id: nil, provider_workspace_id: nil, batch: nil, pricing_mode: nil,
|
|
18
|
+
metadata: {}, context_tags: nil, request: nil)
|
|
15
19
|
@provider = provider.to_s
|
|
16
20
|
@model = model
|
|
17
21
|
@latency_ms = latency_ms
|
|
18
22
|
@provider_response_id = provider_response_id
|
|
23
|
+
@provider_project_id = provider_project_id
|
|
24
|
+
@provider_api_key_id = provider_api_key_id
|
|
25
|
+
@provider_workspace_id = provider_workspace_id
|
|
26
|
+
@batch = batch
|
|
19
27
|
@pricing_mode = pricing_mode
|
|
20
28
|
@metadata = (metadata || {}).deep_dup
|
|
21
29
|
@context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).deep_dup
|
|
30
|
+
@request = request
|
|
22
31
|
@events = []
|
|
23
32
|
@captured_bytes = 0
|
|
24
33
|
@overflowed = false
|
|
25
34
|
@explicit_usage = nil
|
|
26
|
-
@started_at =
|
|
35
|
+
@started_at = LlmCostTracker::Timing.now_monotonic
|
|
27
36
|
@finished = false
|
|
37
|
+
@recording = false
|
|
28
38
|
@mutex = Mutex.new
|
|
29
39
|
end
|
|
30
40
|
|
|
@@ -66,19 +76,35 @@ module LlmCostTracker
|
|
|
66
76
|
@mutex.synchronize do
|
|
67
77
|
ensure_open!
|
|
68
78
|
@provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
|
|
69
|
-
@
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
@provider_project_id = extra.delete(:provider_project_id) || @provider_project_id
|
|
80
|
+
@provider_api_key_id = extra.delete(:provider_api_key_id) || @provider_api_key_id
|
|
81
|
+
@provider_workspace_id = extra.delete(:provider_workspace_id) || @provider_workspace_id
|
|
82
|
+
batch = extra.delete(:batch)
|
|
83
|
+
@batch = batch unless batch.nil?
|
|
84
|
+
@explicit_usage = TokenUsage.build(
|
|
85
|
+
**extra.slice(*TokenUsage.members),
|
|
86
|
+
input_tokens: input_tokens,
|
|
87
|
+
output_tokens: output_tokens
|
|
88
|
+
)
|
|
73
89
|
end
|
|
74
90
|
self
|
|
75
91
|
end
|
|
76
92
|
|
|
77
93
|
def finish!(errored: false)
|
|
78
|
-
snapshot =
|
|
79
|
-
|
|
94
|
+
snapshot = claim_recording_slot
|
|
95
|
+
return if snapshot.nil?
|
|
80
96
|
|
|
81
|
-
|
|
97
|
+
record_snapshot(snapshot, errored: errored)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def claim_recording_slot
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
return nil if @finished || @recording
|
|
105
|
+
|
|
106
|
+
@recording = true
|
|
107
|
+
pricing_mode = Pricing.normalize_mode(@pricing_mode)
|
|
82
108
|
{
|
|
83
109
|
events: @events.dup,
|
|
84
110
|
overflowed: @overflowed,
|
|
@@ -86,27 +112,65 @@ module LlmCostTracker
|
|
|
86
112
|
model: @model,
|
|
87
113
|
latency_ms: @latency_ms,
|
|
88
114
|
provider_response_id: @provider_response_id,
|
|
89
|
-
|
|
115
|
+
capture_dimensions: capture_dimensions(pricing_mode),
|
|
116
|
+
pricing_mode: pricing_mode,
|
|
90
117
|
metadata: @metadata.deep_dup,
|
|
91
|
-
context_tags: @context_tags.deep_dup
|
|
118
|
+
context_tags: @context_tags.deep_dup,
|
|
119
|
+
request: @request
|
|
92
120
|
}
|
|
93
121
|
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_snapshot(snapshot, errored:)
|
|
125
|
+
save_succeeded = false
|
|
126
|
+
begin
|
|
127
|
+
capture = build_usage_capture(snapshot)
|
|
128
|
+
provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
|
|
129
|
+
capture = capture.with(provider_response_id: provider_response_id)
|
|
130
|
+
|
|
131
|
+
Tracker.record(
|
|
132
|
+
capture: capture,
|
|
133
|
+
latency_ms: snapshot[:latency_ms] || LlmCostTracker::Timing.elapsed_ms(@started_at),
|
|
134
|
+
pricing_mode: pricing_mode_for(capture: capture, snapshot: snapshot),
|
|
135
|
+
metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
|
|
136
|
+
context_tags: snapshot[:context_tags]
|
|
137
|
+
) { |stage| save_succeeded = true if stage == :after_save }
|
|
138
|
+
ensure
|
|
139
|
+
@mutex.synchronize do
|
|
140
|
+
@finished = save_succeeded
|
|
141
|
+
@recording = false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
94
145
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
146
|
+
HOST_DERIVED_MODE_TOKENS = %i[data_residency].freeze
|
|
147
|
+
STANDARD_LIKE_MODE_TOKENS = %i[standard standard_only auto default].freeze
|
|
148
|
+
private_constant :HOST_DERIVED_MODE_TOKENS, :STANDARD_LIKE_MODE_TOKENS
|
|
98
149
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
latency_ms: snapshot[:latency_ms] ||
|
|
102
|
-
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
|
|
103
|
-
pricing_mode: snapshot[:pricing_mode],
|
|
104
|
-
metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata]),
|
|
105
|
-
context_tags: snapshot[:context_tags]
|
|
106
|
-
)
|
|
150
|
+
def pricing_mode_for(capture:, snapshot:)
|
|
151
|
+
merge_pricing_modes(capture.pricing_mode, snapshot[:pricing_mode])
|
|
107
152
|
end
|
|
108
153
|
|
|
109
|
-
|
|
154
|
+
def merge_pricing_modes(provider_mode, request_mode)
|
|
155
|
+
return Pricing.normalize_mode(request_mode) if provider_mode.to_s.strip.empty?
|
|
156
|
+
|
|
157
|
+
provider_tokens = Pricing::Mode.tokenize(provider_mode) - STANDARD_LIKE_MODE_TOKENS
|
|
158
|
+
request_host_tokens = Pricing::Mode.tokenize(request_mode || "") & HOST_DERIVED_MODE_TOKENS
|
|
159
|
+
combined = provider_tokens | request_host_tokens
|
|
160
|
+
return nil if combined.empty?
|
|
161
|
+
|
|
162
|
+
Pricing.normalize_mode(combined.join("_"))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def capture_dimensions(pricing_mode)
|
|
166
|
+
batch = @batch.nil? ? UsageCapture.batch_from_pricing_mode?(pricing_mode).presence : @batch
|
|
167
|
+
{
|
|
168
|
+
provider_project_id: @provider_project_id.to_s.strip.presence,
|
|
169
|
+
provider_api_key_id: @provider_api_key_id.to_s.strip.presence,
|
|
170
|
+
provider_workspace_id: @provider_workspace_id.to_s.strip.presence,
|
|
171
|
+
batch: batch
|
|
172
|
+
}.compact
|
|
173
|
+
end
|
|
110
174
|
|
|
111
175
|
def ensure_open!
|
|
112
176
|
return unless @finished
|
|
@@ -120,16 +184,25 @@ module LlmCostTracker
|
|
|
120
184
|
|
|
121
185
|
capture = Parsers.find_for_provider(@provider)&.parse_stream(
|
|
122
186
|
response_status: 200,
|
|
123
|
-
events: snapshot[:events]
|
|
187
|
+
events: snapshot[:events],
|
|
188
|
+
request_body: request_body_for(snapshot[:request])
|
|
124
189
|
)
|
|
125
190
|
if capture
|
|
126
191
|
model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
|
|
127
|
-
return capture.with(provider: @provider, model: model)
|
|
192
|
+
return capture.with(provider: @provider, model: model, **snapshot.fetch(:capture_dimensions))
|
|
128
193
|
end
|
|
129
194
|
|
|
130
195
|
build_unknown_usage(snapshot)
|
|
131
196
|
end
|
|
132
197
|
|
|
198
|
+
def request_body_for(request)
|
|
199
|
+
return nil unless request
|
|
200
|
+
|
|
201
|
+
JSON.generate(request)
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
133
206
|
def present_model(value)
|
|
134
207
|
return nil if value.nil?
|
|
135
208
|
|
|
@@ -145,7 +218,9 @@ module LlmCostTracker
|
|
|
145
218
|
model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
|
|
146
219
|
token_usage: snapshot[:explicit_usage],
|
|
147
220
|
stream: true,
|
|
148
|
-
usage_source: :manual
|
|
221
|
+
usage_source: :manual,
|
|
222
|
+
pricing_mode: snapshot[:pricing_mode],
|
|
223
|
+
**snapshot.fetch(:capture_dimensions)
|
|
149
224
|
)
|
|
150
225
|
end
|
|
151
226
|
|
|
@@ -155,33 +230,45 @@ module LlmCostTracker
|
|
|
155
230
|
model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
|
|
156
231
|
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
157
232
|
stream: true,
|
|
158
|
-
usage_source: :unknown
|
|
233
|
+
usage_source: :unknown,
|
|
234
|
+
pricing_mode: snapshot[:pricing_mode],
|
|
235
|
+
**snapshot.fetch(:capture_dimensions)
|
|
159
236
|
)
|
|
160
237
|
end
|
|
161
238
|
|
|
239
|
+
IGNORED_PAYLOAD_KEYS = %w[b64_json partial_image_b64].freeze
|
|
240
|
+
private_constant :IGNORED_PAYLOAD_KEYS
|
|
241
|
+
|
|
242
|
+
HEAVY_STRING_BYTES = 8 * 1024
|
|
243
|
+
private_constant :HEAVY_STRING_BYTES
|
|
244
|
+
|
|
162
245
|
def capture_event(data, type:)
|
|
163
|
-
|
|
246
|
+
event = { event: type, data: strip_heavy_payload(data) }
|
|
247
|
+
size = JSON.generate(event).bytesize
|
|
164
248
|
if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
|
|
165
|
-
@events <<
|
|
249
|
+
@events << event.deep_dup
|
|
166
250
|
@captured_bytes += size
|
|
167
251
|
else
|
|
168
252
|
@overflowed = true
|
|
169
|
-
@events.clear
|
|
170
253
|
end
|
|
254
|
+
rescue JSON::JSONError, TypeError, SystemStackError
|
|
255
|
+
@overflowed = true
|
|
171
256
|
end
|
|
172
257
|
|
|
173
|
-
def
|
|
258
|
+
def strip_heavy_payload(value)
|
|
174
259
|
case value
|
|
175
260
|
when Hash
|
|
176
|
-
value.
|
|
261
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
262
|
+
next if IGNORED_PAYLOAD_KEYS.include?(key.to_s)
|
|
263
|
+
|
|
264
|
+
out[key] = strip_heavy_payload(nested)
|
|
265
|
+
end
|
|
177
266
|
when Array
|
|
178
|
-
value.
|
|
267
|
+
value.map { |nested| strip_heavy_payload(nested) }
|
|
179
268
|
when String
|
|
180
|
-
value.bytesize
|
|
181
|
-
when Numeric, true, false, nil
|
|
182
|
-
value.to_s.bytesize
|
|
269
|
+
value.bytesize > HEAVY_STRING_BYTES ? "" : value
|
|
183
270
|
else
|
|
184
|
-
value
|
|
271
|
+
value
|
|
185
272
|
end
|
|
186
273
|
end
|
|
187
274
|
end
|
|
@@ -12,8 +12,9 @@ module LlmCostTracker
|
|
|
12
12
|
@stream = stream
|
|
13
13
|
@collector = collector
|
|
14
14
|
@active = active
|
|
15
|
-
@finish = finish || proc { |errored
|
|
16
|
-
@
|
|
15
|
+
@finish = finish || proc { |errored| collector.finish!(errored: errored) }
|
|
16
|
+
@finished_ref = [false]
|
|
17
|
+
@attempted_ref = [false]
|
|
17
18
|
@capture_failed = false
|
|
18
19
|
@mutex = Mutex.new
|
|
19
20
|
end
|
|
@@ -33,6 +34,7 @@ module LlmCostTracker
|
|
|
33
34
|
end
|
|
34
35
|
wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
|
|
35
36
|
|
|
37
|
+
register_orphan_finalizer
|
|
36
38
|
@stream
|
|
37
39
|
rescue StandardError => e
|
|
38
40
|
Logging.warn("stream integration failed to install wrapper: #{e.class}: #{e.message}")
|
|
@@ -120,14 +122,47 @@ module LlmCostTracker
|
|
|
120
122
|
|
|
121
123
|
def finish!(errored:)
|
|
122
124
|
should_finish = @mutex.synchronize do
|
|
123
|
-
|
|
125
|
+
@attempted_ref[0] = true
|
|
126
|
+
next false if @finished_ref[0]
|
|
124
127
|
|
|
125
|
-
@
|
|
128
|
+
@finished_ref[0] = true
|
|
126
129
|
true
|
|
127
130
|
end
|
|
128
131
|
return unless should_finish && @active.call
|
|
129
132
|
|
|
130
|
-
|
|
133
|
+
begin
|
|
134
|
+
@finish.call(errored)
|
|
135
|
+
rescue StandardError
|
|
136
|
+
@mutex.synchronize { @finished_ref[0] = false }
|
|
137
|
+
raise
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def register_orphan_finalizer
|
|
142
|
+
finished_ref = @finished_ref
|
|
143
|
+
attempted_ref = @attempted_ref
|
|
144
|
+
finish_proc = @finish
|
|
145
|
+
active_proc = @active
|
|
146
|
+
mutex = @mutex
|
|
147
|
+
finalizer = lambda do |_object_id|
|
|
148
|
+
should_finish = mutex.synchronize do
|
|
149
|
+
next false if finished_ref[0] || attempted_ref[0]
|
|
150
|
+
|
|
151
|
+
finished_ref[0] = true
|
|
152
|
+
attempted_ref[0] = true
|
|
153
|
+
true
|
|
154
|
+
end
|
|
155
|
+
next unless should_finish && active_proc.call
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
finish_proc.call(false)
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
ObjectSpace.define_finalizer(@stream, finalizer)
|
|
164
|
+
rescue TypeError, ArgumentError
|
|
165
|
+
nil
|
|
131
166
|
end
|
|
132
167
|
end
|
|
133
168
|
end
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "pricing/registry"
|
|
4
5
|
require_relative "tags/key"
|
|
5
|
-
require_relative "configuration/instrumentation"
|
|
6
6
|
|
|
7
7
|
module LlmCostTracker
|
|
8
8
|
class Configuration
|
|
9
|
-
include ConfigurationInstrumentation
|
|
10
|
-
|
|
11
9
|
OPENAI_COMPATIBLE_PROVIDERS = {
|
|
12
10
|
"openrouter.ai" => "openrouter",
|
|
13
11
|
"api.deepseek.com" => "deepseek",
|
|
@@ -16,8 +14,8 @@ module LlmCostTracker
|
|
|
16
14
|
|
|
17
15
|
BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
|
|
18
16
|
UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
|
|
19
|
-
SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget
|
|
20
|
-
prices_file max_tag_count max_tag_value_bytesize].freeze
|
|
17
|
+
SHARED_SCALAR_ATTRIBUTES = %i[enabled default_tags on_budget_exceeded monthly_budget daily_budget per_call_budget
|
|
18
|
+
log_level prices_file max_tag_count max_tag_value_bytesize].freeze
|
|
21
19
|
SHARED_ENUM_ATTRIBUTES = {
|
|
22
20
|
budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
|
|
23
21
|
unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
|
|
@@ -27,13 +25,17 @@ module LlmCostTracker
|
|
|
27
25
|
attr_reader(
|
|
28
26
|
*SHARED_SCALAR_ATTRIBUTES,
|
|
29
27
|
:budget_exceeded_behavior,
|
|
30
|
-
:
|
|
31
|
-
:pricing_overrides,
|
|
28
|
+
:durable_ingestion,
|
|
32
29
|
:instrumented_integrations,
|
|
30
|
+
:pricing_overrides,
|
|
33
31
|
:report_tag_breakdowns,
|
|
34
32
|
:redacted_tag_keys,
|
|
35
33
|
:unknown_pricing_behavior,
|
|
36
|
-
:openai_compatible_providers
|
|
34
|
+
:openai_compatible_providers,
|
|
35
|
+
:reconciliation_importers,
|
|
36
|
+
:reconciliation_enabled,
|
|
37
|
+
:auto_enable_stream_usage,
|
|
38
|
+
:cache_rollups
|
|
37
39
|
)
|
|
38
40
|
|
|
39
41
|
def initialize
|
|
@@ -49,19 +51,61 @@ module LlmCostTracker
|
|
|
49
51
|
@prices_file = nil
|
|
50
52
|
@max_tag_count = 50
|
|
51
53
|
@max_tag_value_bytesize = 1024
|
|
52
|
-
|
|
53
|
-
@instrumented_integrations =
|
|
54
|
+
self.pricing_overrides = {}
|
|
55
|
+
@instrumented_integrations = Set.new
|
|
54
56
|
@report_tag_breakdowns = []
|
|
55
57
|
@redacted_tag_keys = DEFAULT_REDACTED_TAG_KEYS.dup
|
|
56
58
|
self.openai_compatible_providers = OPENAI_COMPATIBLE_PROVIDERS
|
|
59
|
+
@reconciliation_importers = {}
|
|
60
|
+
@reconciliation_enabled = false
|
|
61
|
+
@auto_enable_stream_usage = true
|
|
62
|
+
@durable_ingestion = false
|
|
63
|
+
@cache_rollups = false
|
|
57
64
|
@finalized = false
|
|
58
65
|
end
|
|
59
66
|
|
|
60
|
-
def
|
|
67
|
+
def durable_ingestion=(value)
|
|
68
|
+
ensure_shared_configuration_mutable!
|
|
69
|
+
@durable_ingestion = value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cache_rollups=(value)
|
|
73
|
+
ensure_shared_configuration_mutable!
|
|
74
|
+
@cache_rollups = value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reconciliation_enabled=(value)
|
|
61
78
|
ensure_shared_configuration_mutable!
|
|
62
|
-
@
|
|
79
|
+
@reconciliation_enabled = value
|
|
63
80
|
end
|
|
64
81
|
|
|
82
|
+
def auto_enable_stream_usage=(value)
|
|
83
|
+
ensure_shared_configuration_mutable!
|
|
84
|
+
@auto_enable_stream_usage = value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def reconciliation_importers=(importers)
|
|
88
|
+
ensure_shared_configuration_mutable!
|
|
89
|
+
raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
|
|
90
|
+
|
|
91
|
+
@reconciliation_importers = (importers || {}).to_h do |source, importer|
|
|
92
|
+
raise Error, "reconciliation_importers[#{source}] must respond to call" unless importer.respond_to?(:call)
|
|
93
|
+
|
|
94
|
+
[source.to_sym, importer]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def register_reconciliation_importer(source, &block)
|
|
99
|
+
ensure_shared_configuration_mutable!
|
|
100
|
+
raise Error, RECONCILIATION_DISABLED_MESSAGE unless @reconciliation_enabled
|
|
101
|
+
raise Error, "register_reconciliation_importer requires a block" unless block
|
|
102
|
+
|
|
103
|
+
@reconciliation_importers[source.to_sym] = block
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
RECONCILIATION_DISABLED_MESSAGE = "reconciliation is disabled; set config.reconciliation_enabled = true first"
|
|
107
|
+
private_constant :RECONCILIATION_DISABLED_MESSAGE
|
|
108
|
+
|
|
65
109
|
def openai_compatible_providers=(providers)
|
|
66
110
|
ensure_shared_configuration_mutable!
|
|
67
111
|
@openai_compatible_providers = normalize_openai_compatible_providers(providers)
|
|
@@ -69,7 +113,9 @@ module LlmCostTracker
|
|
|
69
113
|
|
|
70
114
|
def pricing_overrides=(value)
|
|
71
115
|
ensure_shared_configuration_mutable!
|
|
72
|
-
@pricing_overrides = value
|
|
116
|
+
@pricing_overrides = Pricing::Registry.normalize_price_table(value || {})
|
|
117
|
+
rescue ArgumentError => e
|
|
118
|
+
raise Error, "invalid pricing_overrides: #{e.message}"
|
|
73
119
|
end
|
|
74
120
|
|
|
75
121
|
def report_tag_breakdowns=(value)
|
|
@@ -82,6 +128,15 @@ module LlmCostTracker
|
|
|
82
128
|
@redacted_tag_keys = Array(value).map(&:to_s)
|
|
83
129
|
end
|
|
84
130
|
|
|
131
|
+
def instrument(*names)
|
|
132
|
+
ensure_shared_configuration_mutable!
|
|
133
|
+
@instrumented_integrations.merge(normalize_instrumentation_names(names))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def instrumented?(name)
|
|
137
|
+
@instrumented_integrations.include?(name)
|
|
138
|
+
end
|
|
139
|
+
|
|
85
140
|
SHARED_SCALAR_ATTRIBUTES.each do |name|
|
|
86
141
|
define_method("#{name}=") do |value|
|
|
87
142
|
ensure_shared_configuration_mutable!
|
|
@@ -99,10 +154,12 @@ module LlmCostTracker
|
|
|
99
154
|
def finalize!
|
|
100
155
|
@default_tags = deep_freeze(@default_tags || {})
|
|
101
156
|
@pricing_overrides = deep_freeze(@pricing_overrides || {})
|
|
102
|
-
@instrumented_integrations = deep_freeze(@instrumented_integrations ||
|
|
157
|
+
@instrumented_integrations = deep_freeze(@instrumented_integrations || Set.new)
|
|
103
158
|
@report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
|
|
104
159
|
@redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
|
|
105
|
-
@openai_compatible_providers = deep_freeze(
|
|
160
|
+
@openai_compatible_providers = deep_freeze(
|
|
161
|
+
normalize_openai_compatible_providers(@openai_compatible_providers)
|
|
162
|
+
)
|
|
106
163
|
@finalized = true
|
|
107
164
|
self
|
|
108
165
|
end
|
|
@@ -115,7 +172,6 @@ module LlmCostTracker
|
|
|
115
172
|
|
|
116
173
|
def normalize_enum(name, value, allowed, default:)
|
|
117
174
|
value = default if value.nil?
|
|
118
|
-
value = value.to_sym
|
|
119
175
|
return value if allowed.include?(value)
|
|
120
176
|
|
|
121
177
|
raise Error, "Unknown #{name}: #{value.inspect}. Use one of: #{allowed.join(', ')}"
|
|
@@ -127,6 +183,19 @@ module LlmCostTracker
|
|
|
127
183
|
end
|
|
128
184
|
end
|
|
129
185
|
|
|
186
|
+
def normalize_instrumentation_names(names)
|
|
187
|
+
names = names.flatten
|
|
188
|
+
integrations = Integrations.names
|
|
189
|
+
return integrations if names == [:all]
|
|
190
|
+
|
|
191
|
+
names.each do |name|
|
|
192
|
+
next if integrations.include?(name)
|
|
193
|
+
|
|
194
|
+
raise Error, "Unknown integration: #{name.inspect}. Use one of: #{integrations.join(', ')}"
|
|
195
|
+
end
|
|
196
|
+
names
|
|
197
|
+
end
|
|
198
|
+
|
|
130
199
|
def ensure_shared_configuration_mutable!
|
|
131
200
|
return unless finalized?
|
|
132
201
|
|
|
@@ -141,7 +210,7 @@ module LlmCostTracker
|
|
|
141
210
|
deep_freeze(nested_value)
|
|
142
211
|
end
|
|
143
212
|
value.frozen? ? value : value.freeze
|
|
144
|
-
when Array
|
|
213
|
+
when Array, Set
|
|
145
214
|
value.each { |nested_value| deep_freeze(nested_value) }
|
|
146
215
|
value.frozen? ? value : value.freeze
|
|
147
216
|
when String
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ledger/schema/calls"
|
|
4
|
+
require_relative "ledger/schema/call_line_items"
|
|
5
|
+
require_relative "ledger/schema/call_tags"
|
|
6
|
+
require_relative "ledger/schema/call_rollups"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module DashboardSetupState
|
|
10
|
+
SetupRequired = Data.define(:message, :details)
|
|
11
|
+
DOCS_HINT = "See docs/upgrading.md for the migration path."
|
|
12
|
+
MUTEX = Mutex.new
|
|
13
|
+
|
|
14
|
+
CORE_SCHEMA_CHECKS = [
|
|
15
|
+
[
|
|
16
|
+
LlmCostTracker::Ledger::Schema::Calls,
|
|
17
|
+
"The llm_cost_tracker_calls table does not match the current LLM Cost Tracker schema."
|
|
18
|
+
],
|
|
19
|
+
[
|
|
20
|
+
LlmCostTracker::Ledger::Schema::CallLineItems,
|
|
21
|
+
"The llm_cost_tracker_call_line_items table does not match the current LLM Cost Tracker schema."
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
LlmCostTracker::Ledger::Schema::CallTags,
|
|
25
|
+
"The llm_cost_tracker_call_tags table does not match the current LLM Cost Tracker schema."
|
|
26
|
+
]
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
OPTIONAL_CALL_ROLLUPS_CHECK = [
|
|
30
|
+
LlmCostTracker::Ledger::Schema::CallRollups,
|
|
31
|
+
"The llm_cost_tracker_call_rollups table does not match the current LLM Cost Tracker schema."
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
private_constant :MUTEX, :CORE_SCHEMA_CHECKS, :OPTIONAL_CALL_ROLLUPS_CHECK, :DOCS_HINT
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def current
|
|
38
|
+
return @cached if defined?(@cached)
|
|
39
|
+
|
|
40
|
+
MUTEX.synchronize do
|
|
41
|
+
@cached = compute unless defined?(@cached)
|
|
42
|
+
end
|
|
43
|
+
@cached
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reset!
|
|
47
|
+
MUTEX.synchronize do
|
|
48
|
+
remove_instance_variable(:@cached) if defined?(@cached)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def compute
|
|
55
|
+
LlmCostTracker::Logging.debug("DashboardSetupState recomputing")
|
|
56
|
+
return calls_table_missing unless LlmCostTracker::Call.table_exists?
|
|
57
|
+
|
|
58
|
+
core_drift = drift_in(schema_checks_for_current_config)
|
|
59
|
+
return core_drift if core_drift
|
|
60
|
+
return nil unless LlmCostTracker.reconciliation_enabled?
|
|
61
|
+
|
|
62
|
+
reconciliation_drift
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def schema_checks_for_current_config
|
|
66
|
+
return CORE_SCHEMA_CHECKS unless LlmCostTracker.configuration.cache_rollups
|
|
67
|
+
|
|
68
|
+
CORE_SCHEMA_CHECKS + [OPTIONAL_CALL_ROLLUPS_CHECK]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def drift_in(checks)
|
|
72
|
+
checks.each do |schema, message|
|
|
73
|
+
errors = schema.current_schema_errors
|
|
74
|
+
next if errors.empty?
|
|
75
|
+
|
|
76
|
+
return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
|
|
77
|
+
end
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def reconciliation_drift
|
|
82
|
+
LlmCostTracker.const_get(:Reconciliation) # autoload reconciliation + its ledger schemas
|
|
83
|
+
connection = ActiveRecord::Base.connection
|
|
84
|
+
LlmCostTracker::Reconciliation::SCHEMA_TABLES.each do |schema, table|
|
|
85
|
+
unless connection.data_source_exists?(table)
|
|
86
|
+
return SetupRequired.new(
|
|
87
|
+
message: "The #{table} table is required when reconciliation is enabled.",
|
|
88
|
+
details: ["run bin/rails generate llm_cost_tracker:reconciliation && bin/rails db:migrate", DOCS_HINT]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
errors = schema.current_schema_errors
|
|
93
|
+
next if errors.empty?
|
|
94
|
+
|
|
95
|
+
message = "The #{table} table does not match the current LLM Cost Tracker schema."
|
|
96
|
+
return SetupRequired.new(message: message, details: errors + [DOCS_HINT])
|
|
97
|
+
end
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def calls_table_missing
|
|
102
|
+
SetupRequired.new(
|
|
103
|
+
message: "The llm_cost_tracker_calls table is not available yet.",
|
|
104
|
+
details: nil
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "check"
|
|
6
|
+
require_relative "probe"
|
|
7
|
+
require_relative "../ledger/rollups"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
class Doctor
|
|
11
|
+
class CostDriftCheck
|
|
12
|
+
SAMPLE_SIZE = 200
|
|
13
|
+
EPSILON = BigDecimal("0.00000001")
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
return unless Probe.table_exists?("llm_cost_tracker_calls")
|
|
17
|
+
return unless Probe.table_exists?("llm_cost_tracker_call_line_items")
|
|
18
|
+
|
|
19
|
+
sampled = LlmCostTracker::Call
|
|
20
|
+
.where.not(total_cost: nil)
|
|
21
|
+
.where(cost_status: %w[complete free partial])
|
|
22
|
+
.order(id: :desc)
|
|
23
|
+
.limit(SAMPLE_SIZE)
|
|
24
|
+
.pluck(:id, :total_cost, :cost_status)
|
|
25
|
+
return Check.new(:ok, "cost drift", "no priced calls to inspect") if sampled.empty?
|
|
26
|
+
|
|
27
|
+
line_item_totals = LlmCostTracker::CallLineItem
|
|
28
|
+
.where(llm_cost_tracker_call_id: sampled.map(&:first))
|
|
29
|
+
.where(currency: Ledger::Rollups::DEFAULT_CURRENCY)
|
|
30
|
+
.group(:llm_cost_tracker_call_id)
|
|
31
|
+
.sum(:cost)
|
|
32
|
+
|
|
33
|
+
drifted = sampled.filter_map do |id, total_cost, cost_status|
|
|
34
|
+
line_total = line_item_totals[id] || BigDecimal("0")
|
|
35
|
+
header = BigDecimal(total_cost.to_s)
|
|
36
|
+
next if cost_status == "partial" && header >= line_total
|
|
37
|
+
next if (header - line_total).abs <= EPSILON
|
|
38
|
+
|
|
39
|
+
"##{id}: header=#{header.to_s('F')} line_items=#{line_total.to_s('F')}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if drifted.empty?
|
|
43
|
+
return Check.new(:ok, "cost drift",
|
|
44
|
+
"header total_cost matches line items in #{sampled.size} sampled calls")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Check.new(
|
|
48
|
+
:warn,
|
|
49
|
+
"cost drift",
|
|
50
|
+
"header total_cost diverges from line items in #{drifted.size}/#{sampled.size} sampled calls: " \
|
|
51
|
+
"#{drifted.first(5).join('; ')}#{'; ...' if drifted.size > 5}"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|