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
|
@@ -5,6 +5,7 @@ require "securerandom"
|
|
|
5
5
|
require_relative "doctor/check"
|
|
6
6
|
require_relative "errors"
|
|
7
7
|
require_relative "ledger"
|
|
8
|
+
require_relative "ingestion/inline"
|
|
8
9
|
require_relative "ingestion/lease_claim"
|
|
9
10
|
require_relative "ingestion/inbox"
|
|
10
11
|
require_relative "ingestion/batch"
|
|
@@ -15,31 +16,59 @@ module LlmCostTracker
|
|
|
15
16
|
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
16
17
|
|
|
17
18
|
class << self
|
|
19
|
+
def table_name_prefix
|
|
20
|
+
"llm_cost_tracker_ingestion_"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
CORE_SCHEMA_GUARDS = [
|
|
24
|
+
["llm_cost_tracker_calls", Ledger::Schema::Calls],
|
|
25
|
+
["llm_cost_tracker_call_line_items", Ledger::Schema::CallLineItems],
|
|
26
|
+
["llm_cost_tracker_call_tags", Ledger::Schema::CallTags]
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
ROLLUPS_SCHEMA_GUARD = ["llm_cost_tracker_call_rollups", Ledger::Schema::CallRollups].freeze
|
|
30
|
+
|
|
31
|
+
DURABLE_SCHEMA_GUARDS = [
|
|
32
|
+
["llm_cost_tracker_ingestion_inbox_entries", Ledger::Schema::IngestionInboxEntries],
|
|
33
|
+
["llm_cost_tracker_ingestion_leases", Ledger::Schema::IngestionLeases]
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
18
36
|
def ensure_current_schema!
|
|
19
|
-
unless
|
|
20
|
-
raise Error, "
|
|
37
|
+
unless LlmCostTracker::Call.table_exists?
|
|
38
|
+
raise Error, "llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
guards_for_current_config.each do |table_name, schema_module|
|
|
42
|
+
errors = schema_module.current_schema_errors
|
|
43
|
+
next if errors.empty?
|
|
44
|
+
|
|
45
|
+
raise Error,
|
|
46
|
+
"#{table_name} table is not on the current schema: #{errors.join('; ')}; see docs/upgrading.md"
|
|
21
47
|
end
|
|
48
|
+
end
|
|
22
49
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
50
|
+
def durable?
|
|
51
|
+
LlmCostTracker.configuration.durable_ingestion
|
|
52
|
+
end
|
|
26
53
|
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
def cache_rollups?
|
|
55
|
+
LlmCostTracker.configuration.cache_rollups
|
|
56
|
+
end
|
|
29
57
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
58
|
+
def guards_for_current_config
|
|
59
|
+
guards = CORE_SCHEMA_GUARDS.dup
|
|
60
|
+
guards << ROLLUPS_SCHEMA_GUARD if cache_rollups?
|
|
61
|
+
guards += DURABLE_SCHEMA_GUARDS if durable?
|
|
62
|
+
guards
|
|
34
63
|
end
|
|
35
64
|
|
|
36
65
|
def verify
|
|
37
|
-
unless LlmCostTracker::
|
|
66
|
+
unless LlmCostTracker::Call.table_exists?
|
|
38
67
|
return [
|
|
39
68
|
LlmCostTracker::Doctor::Check.new(
|
|
40
69
|
:error,
|
|
41
70
|
"active_record",
|
|
42
|
-
"
|
|
71
|
+
"llm_cost_tracker_calls table is missing; run install generator and migrate"
|
|
43
72
|
)
|
|
44
73
|
]
|
|
45
74
|
end
|
|
@@ -60,13 +89,12 @@ module LlmCostTracker
|
|
|
60
89
|
event = LlmCostTracker.track(
|
|
61
90
|
provider: provider,
|
|
62
91
|
model: model,
|
|
63
|
-
|
|
64
|
-
output_tokens: 1,
|
|
92
|
+
tokens: { input: 1, output: 1 },
|
|
65
93
|
provider_response_id: response_id,
|
|
66
|
-
feature: VERIFY_TAG
|
|
94
|
+
tags: { feature: VERIFY_TAG }
|
|
67
95
|
)
|
|
68
|
-
LlmCostTracker.flush!
|
|
69
|
-
persisted = LlmCostTracker::
|
|
96
|
+
LlmCostTracker::Ingestion::Worker.flush! if durable?
|
|
97
|
+
persisted = LlmCostTracker::Call.where(provider_response_id: response_id).exists?
|
|
70
98
|
|
|
71
99
|
return capture_success if persisted && notifications.any?
|
|
72
100
|
|
|
@@ -83,7 +111,7 @@ module LlmCostTracker
|
|
|
83
111
|
LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
|
|
84
112
|
ensure
|
|
85
113
|
cleanup_verification_call(response_id) if response_id
|
|
86
|
-
|
|
114
|
+
cleanup_verification_inbox(event: event, response_id: response_id)
|
|
87
115
|
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
|
|
88
116
|
end
|
|
89
117
|
|
|
@@ -94,10 +122,11 @@ module LlmCostTracker
|
|
|
94
122
|
end
|
|
95
123
|
|
|
96
124
|
def capture_success
|
|
125
|
+
path = durable? ? "durable inbox" : "inline writer"
|
|
97
126
|
LlmCostTracker::Doctor::Check.new(
|
|
98
127
|
:ok,
|
|
99
128
|
"active_record capture",
|
|
100
|
-
"manual event emitted and persisted through
|
|
129
|
+
"manual event emitted and persisted through #{path}"
|
|
101
130
|
)
|
|
102
131
|
end
|
|
103
132
|
|
|
@@ -109,14 +138,29 @@ module LlmCostTracker
|
|
|
109
138
|
end
|
|
110
139
|
|
|
111
140
|
def cleanup_verification_call(response_id)
|
|
112
|
-
relation = LlmCostTracker::
|
|
113
|
-
rows = relation.pluck(:id, :tracked_at, :total_cost)
|
|
141
|
+
relation = LlmCostTracker::Call.where(provider_response_id: response_id)
|
|
142
|
+
rows = relation.pluck(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider)
|
|
114
143
|
return if rows.empty?
|
|
115
144
|
|
|
116
145
|
relation.delete_all
|
|
146
|
+
return unless cache_rollups?
|
|
147
|
+
|
|
117
148
|
LlmCostTracker::Ledger::Rollups.decrement!(rows)
|
|
118
149
|
end
|
|
119
150
|
|
|
151
|
+
def cleanup_verification_inbox(event:, response_id:)
|
|
152
|
+
return unless durable? && LlmCostTracker::Ingestion::InboxEntry.table_exists?
|
|
153
|
+
|
|
154
|
+
if event
|
|
155
|
+
LlmCostTracker::Ingestion::InboxEntry.where(event_id: event.event_id).delete_all
|
|
156
|
+
elsif response_id
|
|
157
|
+
escaped = ActiveRecord::Base.sanitize_sql_like(response_id)
|
|
158
|
+
LlmCostTracker::Ingestion::InboxEntry
|
|
159
|
+
.where("payload LIKE ?", "%\"provider_response_id\":\"#{escaped}\"%")
|
|
160
|
+
.delete_all
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
120
164
|
def sample_priced_identity
|
|
121
165
|
key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
|
|
122
166
|
model_id.include?("/") && prices[:input] && prices[:output]
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
require_relative "../
|
|
5
|
-
require_relative "../capture/stream_tracker"
|
|
4
|
+
require_relative "../billing/line_item"
|
|
6
5
|
|
|
7
6
|
module LlmCostTracker
|
|
8
7
|
module Integrations
|
|
@@ -52,22 +51,58 @@ module LlmCostTracker
|
|
|
52
51
|
pricing_mode: pricing_mode(message: message, request: request, usage: usage),
|
|
53
52
|
token_usage: token_usage(usage: usage, input_tokens: input_tokens, output_tokens: output_tokens),
|
|
54
53
|
usage_source: :sdk_response,
|
|
55
|
-
provider_response_id: object_value(message, :id)
|
|
54
|
+
provider_response_id: object_value(message, :id),
|
|
55
|
+
service_line_items: service_line_items_from(usage)
|
|
56
56
|
),
|
|
57
57
|
latency_ms: latency_ms
|
|
58
58
|
)
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
def service_line_items_from(usage)
|
|
63
|
+
server_tool_use = object_value(usage, :server_tool_use)
|
|
64
|
+
return [] unless server_tool_use
|
|
65
|
+
|
|
66
|
+
[
|
|
67
|
+
line_item_for_server_tool(server_tool_use, :web_search_request, :web_search_requests,
|
|
68
|
+
"usage.server_tool_use.web_search_requests"),
|
|
69
|
+
line_item_for_server_tool(server_tool_use, :web_fetch_request, :web_fetch_requests,
|
|
70
|
+
"usage.server_tool_use.web_fetch_requests"),
|
|
71
|
+
line_item_for_server_tool(server_tool_use, :code_execution_request, :code_execution_requests,
|
|
72
|
+
"usage.server_tool_use.code_execution_requests")
|
|
73
|
+
].compact
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def line_item_for_server_tool(server_tool_use, component_key, count_key, provider_field)
|
|
77
|
+
quantity = server_tool_count(server_tool_use, count_key)
|
|
78
|
+
return nil if quantity.zero?
|
|
79
|
+
|
|
80
|
+
Billing::LineItem.build(
|
|
81
|
+
component_key: component_key,
|
|
82
|
+
quantity: quantity,
|
|
83
|
+
cost_status: Billing::CostStatus::UNKNOWN,
|
|
84
|
+
pricing_basis: :provider_usage,
|
|
85
|
+
provider_field: provider_field
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def server_tool_count(server_tool_use, count_key)
|
|
90
|
+
direct = object_value(server_tool_use, count_key).to_i
|
|
91
|
+
return direct if direct.positive?
|
|
92
|
+
return 0 unless server_tool_use.respond_to?(:to_h)
|
|
93
|
+
|
|
94
|
+
server_tool_use.to_h[count_key].to_i
|
|
95
|
+
end
|
|
96
|
+
|
|
62
97
|
def token_usage(usage:, input_tokens:, output_tokens:)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
98
|
+
cache_creation = object_value(usage, :cache_creation)
|
|
99
|
+
if cache_creation
|
|
100
|
+
cache_write_default = object_value(cache_creation, :ephemeral_5m_input_tokens).to_i
|
|
101
|
+
cache_write_extended = object_value(cache_creation, :ephemeral_1h_input_tokens).to_i
|
|
102
|
+
else
|
|
103
|
+
cache_write_default = object_value(usage, :cache_creation_input_tokens).to_i
|
|
104
|
+
cache_write_extended = 0
|
|
105
|
+
end
|
|
71
106
|
hidden_output = (
|
|
72
107
|
object_value(usage, :thinking_tokens, :thinking_output_tokens) ||
|
|
73
108
|
object_dig(usage, :output_tokens_details, :reasoning_tokens)
|
|
@@ -77,57 +112,48 @@ module LlmCostTracker
|
|
|
77
112
|
input_tokens: input_tokens.to_i,
|
|
78
113
|
output_tokens: output_tokens.to_i,
|
|
79
114
|
cache_read_input_tokens: object_value(usage, :cache_read_input_tokens).to_i,
|
|
80
|
-
cache_write_input_tokens:
|
|
81
|
-
|
|
115
|
+
cache_write_input_tokens: cache_write_default,
|
|
116
|
+
cache_write_extended_input_tokens: cache_write_extended,
|
|
82
117
|
hidden_output_tokens: hidden_output
|
|
83
118
|
)
|
|
84
119
|
end
|
|
85
120
|
|
|
121
|
+
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
122
|
+
# Anthropic Priority Tier is committed throughput (tokens/min capacity), not a per-token
|
|
123
|
+
# surcharge. Treat it as standard pricing so cost_status doesn't fall to :unknown.
|
|
124
|
+
STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
|
|
125
|
+
|
|
86
126
|
def pricing_mode(message:, request:, usage:)
|
|
127
|
+
service_tier = object_value(usage, :service_tier) ||
|
|
128
|
+
object_value(message, :service_tier) ||
|
|
129
|
+
request[:service_tier]
|
|
130
|
+
service_tier = nil if STANDARD_EQUIVALENT_SERVICE_TIERS.include?(service_tier.to_s)
|
|
131
|
+
|
|
87
132
|
modes = [
|
|
88
133
|
Pricing.normalize_mode(object_value(usage, :speed) || object_value(message, :speed) || request[:speed]),
|
|
89
|
-
Pricing.normalize_mode(
|
|
90
|
-
object_value(usage, :service_tier) || object_value(message, :service_tier) || request[:service_tier]
|
|
91
|
-
)
|
|
134
|
+
Pricing.normalize_mode(service_tier)
|
|
92
135
|
]
|
|
93
|
-
|
|
136
|
+
geo = inference_geo(message: message, request: request, usage: usage).to_s.downcase
|
|
137
|
+
modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
|
|
94
138
|
modes = modes.compact.uniq
|
|
95
139
|
modes.empty? ? nil : modes.join("_")
|
|
96
140
|
end
|
|
97
141
|
|
|
142
|
+
def stream_pricing_mode(request)
|
|
143
|
+
pricing_mode(message: nil, request: request || {}, usage: nil)
|
|
144
|
+
end
|
|
145
|
+
|
|
98
146
|
def inference_geo(message:, request:, usage:)
|
|
99
147
|
object_value(usage, :inference_geo) ||
|
|
100
148
|
object_value(message, :inference_geo) ||
|
|
101
149
|
request[:inference_geo]
|
|
102
150
|
end
|
|
103
|
-
|
|
104
|
-
def track_stream(stream, collector:)
|
|
105
|
-
return stream unless active?
|
|
106
|
-
|
|
107
|
-
LlmCostTracker::Capture::StreamTracker.new(
|
|
108
|
-
stream: stream,
|
|
109
|
-
collector: collector,
|
|
110
|
-
active: -> { active? },
|
|
111
|
-
finish: ->(errored:) { finish_stream(collector, errored: errored) }
|
|
112
|
-
).wrap
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def stream_collector(request)
|
|
116
|
-
LlmCostTracker::Capture::StreamCollector.new(
|
|
117
|
-
provider: "anthropic",
|
|
118
|
-
model: request[:model]
|
|
119
|
-
)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def finish_stream(collector, errored:)
|
|
123
|
-
record_safely { collector.finish!(errored: errored) }
|
|
124
|
-
end
|
|
125
151
|
end
|
|
126
152
|
|
|
127
153
|
module MessagesPatch
|
|
128
154
|
def create(*args, **kwargs)
|
|
129
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
130
155
|
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
156
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
131
157
|
message = super
|
|
132
158
|
LlmCostTracker::Integrations::Anthropic.record_message(
|
|
133
159
|
message,
|
|
@@ -139,16 +165,16 @@ module LlmCostTracker
|
|
|
139
165
|
|
|
140
166
|
def stream(*args, **kwargs)
|
|
141
167
|
request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
|
|
142
|
-
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
143
168
|
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
169
|
+
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
144
170
|
stream = super
|
|
145
171
|
LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
|
|
146
172
|
end
|
|
147
173
|
|
|
148
174
|
def stream_raw(*args, **kwargs)
|
|
149
175
|
request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
|
|
150
|
-
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
151
176
|
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
177
|
+
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
152
178
|
stream = super
|
|
153
179
|
LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
|
|
154
180
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/hash/indifferent_access"
|
|
4
|
-
require "active_support/core_ext/object/try"
|
|
5
4
|
require "active_support/core_ext/string/inflections"
|
|
6
5
|
|
|
7
6
|
require_relative "../logging"
|
|
7
|
+
require_relative "../timing"
|
|
8
|
+
require_relative "../capture/stream_collector"
|
|
9
|
+
require_relative "../capture/stream_tracker"
|
|
8
10
|
|
|
9
11
|
module LlmCostTracker
|
|
10
12
|
module Integrations
|
|
@@ -30,17 +32,16 @@ module LlmCostTracker
|
|
|
30
32
|
return Result.new(name, :warn, "#{name} integration cannot be installed: #{problems.join('; ')}")
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
installed = required_targets.count do |target|
|
|
35
|
+
installed = patch_targets.reject { |target| target.fetch(:optional) }.all? do |target|
|
|
35
36
|
target.fetch(:constant_name).to_s.safe_constantize&.ancestors&.include?(target.fetch(:patch))
|
|
36
37
|
end
|
|
37
|
-
return Result.new(name, :ok, "#{name} integration installed") if installed
|
|
38
|
+
return Result.new(name, :ok, "#{name} integration installed") if installed
|
|
38
39
|
|
|
39
40
|
Result.new(name, :warn, "#{name} integration is enabled but not installed")
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def elapsed_ms(started_at)
|
|
43
|
-
|
|
44
|
+
Timing.elapsed_ms(started_at)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def enforce_budget!
|
|
@@ -56,8 +57,45 @@ module LlmCostTracker
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def request_params(args, kwargs)
|
|
59
|
-
params =
|
|
60
|
+
params =
|
|
61
|
+
case args.first
|
|
62
|
+
when Hash then args.first
|
|
63
|
+
when nil then {}
|
|
64
|
+
else args.first.respond_to?(:to_h) ? args.first.to_h : {}
|
|
65
|
+
end
|
|
60
66
|
params.merge(kwargs).with_indifferent_access
|
|
67
|
+
rescue StandardError
|
|
68
|
+
kwargs.to_h.with_indifferent_access
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_sdk_args(args, kwargs)
|
|
72
|
+
return args if args.any? || kwargs.empty?
|
|
73
|
+
|
|
74
|
+
[kwargs]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def track_stream(stream, collector:)
|
|
78
|
+
return stream unless active?
|
|
79
|
+
|
|
80
|
+
LlmCostTracker::Capture::StreamTracker.new(
|
|
81
|
+
stream: stream,
|
|
82
|
+
collector: collector,
|
|
83
|
+
active: -> { active? },
|
|
84
|
+
finish: ->(errored) { record_safely { collector.finish!(errored: errored) } }
|
|
85
|
+
).wrap
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stream_collector(request)
|
|
89
|
+
LlmCostTracker::Capture::StreamCollector.new(
|
|
90
|
+
provider: integration_name.to_s,
|
|
91
|
+
model: request[:model],
|
|
92
|
+
pricing_mode: stream_pricing_mode(request),
|
|
93
|
+
request: request
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stream_pricing_mode(_request)
|
|
98
|
+
nil
|
|
61
99
|
end
|
|
62
100
|
|
|
63
101
|
def object_value(object, *keys)
|
|
@@ -69,15 +107,6 @@ module LlmCostTracker
|
|
|
69
107
|
end
|
|
70
108
|
|
|
71
109
|
def object_dig(object, *path)
|
|
72
|
-
if object.respond_to?(:dig)
|
|
73
|
-
begin
|
|
74
|
-
value = object.dig(*path)
|
|
75
|
-
return value unless value.nil?
|
|
76
|
-
rescue NameError, TypeError
|
|
77
|
-
nil
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
110
|
path.reduce(object) do |current, key|
|
|
82
111
|
return nil if current.nil?
|
|
83
112
|
|
|
@@ -91,12 +120,13 @@ module LlmCostTracker
|
|
|
91
120
|
|
|
92
121
|
def patch_targets = []
|
|
93
122
|
|
|
94
|
-
def patch_target(constant_name, with:, methods:, optional: false)
|
|
123
|
+
def patch_target(constant_name, with:, methods:, optional: false, skip_when_methods_missing: false)
|
|
95
124
|
{
|
|
96
125
|
constant_name: constant_name,
|
|
97
126
|
patch: with,
|
|
98
127
|
method_names: Array(methods),
|
|
99
|
-
optional: optional
|
|
128
|
+
optional: optional,
|
|
129
|
+
skip_when_methods_missing: skip_when_methods_missing
|
|
100
130
|
}
|
|
101
131
|
end
|
|
102
132
|
|
|
@@ -106,25 +136,17 @@ module LlmCostTracker
|
|
|
106
136
|
|
|
107
137
|
def read_object_value(object, key)
|
|
108
138
|
return nil if object.nil?
|
|
109
|
-
return object[key] if object.try(:key?, key)
|
|
110
|
-
|
|
111
|
-
string_key = key.to_s
|
|
112
|
-
return object[string_key] if object.try(:key?, string_key)
|
|
113
139
|
|
|
114
|
-
|
|
115
|
-
|
|
140
|
+
if object.is_a?(Hash)
|
|
141
|
+
return object[key] if object.key?(key)
|
|
142
|
+
return object[key.name] if key.is_a?(Symbol) && object.key?(key.name)
|
|
143
|
+
end
|
|
116
144
|
|
|
117
|
-
|
|
145
|
+
object.public_send(key) if object.respond_to?(key)
|
|
118
146
|
end
|
|
119
147
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
rescue IndexError, NameError, TypeError
|
|
123
|
-
nil
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
module_function :read_object_value, :indexed_object_value
|
|
127
|
-
private_class_method :read_object_value, :indexed_object_value
|
|
148
|
+
module_function :read_object_value
|
|
149
|
+
private_class_method :read_object_value
|
|
128
150
|
|
|
129
151
|
def validate_contract!
|
|
130
152
|
problems = version_problems + target_problems
|
|
@@ -165,6 +187,8 @@ module LlmCostTracker
|
|
|
165
187
|
end
|
|
166
188
|
|
|
167
189
|
def missing_methods(target_class, target)
|
|
190
|
+
return [] if target[:skip_when_methods_missing]
|
|
191
|
+
|
|
168
192
|
target.fetch(:method_names).filter_map do |method_name|
|
|
169
193
|
next if target_class.method_defined?(method_name) || target_class.private_method_defined?(method_name)
|
|
170
194
|
|