llm_cost_tracker 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +11 -5
- data/app/assets/llm_cost_tracker/application.css +784 -802
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
- data/app/controllers/llm_cost_tracker/models_controller.rb +3 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +16 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
- data/app/views/llm_cost_tracker/errors/database.html.erb +2 -2
- data/app/views/llm_cost_tracker/models/index.html.erb +39 -59
- data/app/views/llm_cost_tracker/pricing/index.html.erb +93 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb +19 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +22 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +23 -0
- data/app/views/llm_cost_tracker/shared/_spend_chart.html.erb +3 -13
- data/app/views/llm_cost_tracker/shared/_tag_chips.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +16 -15
- data/app/views/llm_cost_tracker/tags/index.html.erb +27 -32
- data/app/views/llm_cost_tracker/tags/show.html.erb +85 -104
- data/config/routes.rb +3 -3
- data/lib/llm_cost_tracker/budget.rb +25 -28
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -61
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +66 -64
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +14 -11
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
- data/lib/llm_cost_tracker/parsers.rb +140 -29
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -295
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -7
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +33 -20
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +92 -58
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
- data/app/views/llm_cost_tracker/shared/_active_filters.html.erb +0 -16
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +0 -66
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +0 -13
- data/lib/llm_cost_tracker/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module Ingestion
|
|
9
|
+
module Leases
|
|
10
|
+
extend Base
|
|
11
|
+
|
|
12
|
+
columns :name, :locked_by, :locked_until, :created_at, :updated_at
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "schema/adapter"
|
|
4
|
+
require_relative "schema/calls"
|
|
5
|
+
require_relative "schema/call_rollups"
|
|
6
|
+
require_relative "schema/call_line_items"
|
|
7
|
+
require_relative "schema/call_tags"
|
|
8
|
+
require_relative "schema/ingestion/inbox_entries"
|
|
9
|
+
require_relative "schema/ingestion/leases"
|
|
10
|
+
|
|
11
|
+
module LlmCostTracker
|
|
12
|
+
module Ledger
|
|
13
|
+
module Schema
|
|
14
|
+
CORE_SCHEMAS = [
|
|
15
|
+
[Calls, "llm_cost_tracker_calls"],
|
|
16
|
+
[CallLineItems, "llm_cost_tracker_call_line_items"],
|
|
17
|
+
[CallTags, "llm_cost_tracker_call_tags"]
|
|
18
|
+
].freeze
|
|
19
|
+
CACHE_ROLLUPS_SCHEMA = [CallRollups, "llm_cost_tracker_call_rollups"].freeze
|
|
20
|
+
ASYNC_SCHEMAS = [
|
|
21
|
+
[Ingestion::InboxEntries, "llm_cost_tracker_ingestion_inbox_entries"],
|
|
22
|
+
[Ingestion::Leases, "llm_cost_tracker_ingestion_leases"]
|
|
23
|
+
].freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -3,28 +3,24 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
require_relative "../pricing"
|
|
6
|
-
require_relative "../billing/line_item"
|
|
7
6
|
require_relative "rollups"
|
|
8
7
|
require_relative "tags/encoding"
|
|
9
8
|
|
|
10
9
|
module LlmCostTracker
|
|
11
10
|
module Ledger
|
|
12
|
-
|
|
11
|
+
module Store
|
|
13
12
|
class << self
|
|
14
|
-
def insert(events
|
|
13
|
+
def insert(events)
|
|
15
14
|
events = Array(events)
|
|
16
15
|
return if events.empty?
|
|
17
16
|
|
|
18
|
-
insertable = skip_existence_check ? events : insertable_events(events)
|
|
19
|
-
return unless insertable.any?
|
|
20
|
-
|
|
21
17
|
LlmCostTracker::Call.transaction do
|
|
22
|
-
rows =
|
|
23
|
-
call_ids = insert_calls_returning_ids(rows,
|
|
24
|
-
insert_line_items(
|
|
25
|
-
insert_call_tags(
|
|
18
|
+
rows = events.map { |event| attributes_for(event) }
|
|
19
|
+
call_ids = insert_calls_returning_ids(rows, events)
|
|
20
|
+
insert_line_items(events, call_ids)
|
|
21
|
+
insert_call_tags(events, call_ids)
|
|
26
22
|
end
|
|
27
|
-
|
|
23
|
+
Ledger::Rollups.increment_safely!(events) if LlmCostTracker.configuration.cache_rollups
|
|
28
24
|
end
|
|
29
25
|
|
|
30
26
|
private
|
|
@@ -45,22 +41,22 @@ module LlmCostTracker
|
|
|
45
41
|
provider: event.provider,
|
|
46
42
|
model: event.model,
|
|
47
43
|
tracked_at: event.tracked_at,
|
|
48
|
-
pricing_mode: event.pricing_mode
|
|
44
|
+
pricing_mode: event.pricing_mode,
|
|
49
45
|
latency_ms: event.latency_ms,
|
|
50
46
|
stream: event.stream,
|
|
51
|
-
usage_source: event.usage_source
|
|
47
|
+
usage_source: event.usage_source,
|
|
52
48
|
provider_response_id: event.provider_response_id,
|
|
53
49
|
provider_project_id: event.provider_project_id,
|
|
54
50
|
provider_api_key_id: event.provider_api_key_id,
|
|
55
51
|
provider_workspace_id: event.provider_workspace_id,
|
|
56
|
-
batch: event.batch
|
|
52
|
+
batch: event.batch?,
|
|
57
53
|
cost_status: event.cost_status,
|
|
58
54
|
pricing_snapshot: event.pricing_snapshot
|
|
59
55
|
}
|
|
60
56
|
|
|
61
57
|
attributes
|
|
62
58
|
.merge(event.token_usage.to_h)
|
|
63
|
-
.merge(
|
|
59
|
+
.merge(total_cost: event.cost&.total)
|
|
64
60
|
end
|
|
65
61
|
|
|
66
62
|
def call_ids_for(events)
|
|
@@ -89,20 +85,20 @@ module LlmCostTracker
|
|
|
89
85
|
{
|
|
90
86
|
llm_cost_tracker_call_id: call_id,
|
|
91
87
|
position: position,
|
|
92
|
-
kind: line_item.kind
|
|
93
|
-
direction: line_item.direction
|
|
94
|
-
modality: line_item.modality
|
|
95
|
-
cache_state: line_item.cache_state
|
|
88
|
+
kind: line_item.kind,
|
|
89
|
+
direction: line_item.direction,
|
|
90
|
+
modality: line_item.modality,
|
|
91
|
+
cache_state: line_item.cache_state,
|
|
96
92
|
quantity: line_item.quantity,
|
|
97
|
-
unit: line_item.unit
|
|
93
|
+
unit: line_item.unit,
|
|
98
94
|
rate_amount: line_item.rate_amount,
|
|
99
95
|
rate_quantity: line_item.rate_quantity,
|
|
100
96
|
cost: line_item.cost,
|
|
101
97
|
currency: line_item.currency,
|
|
102
98
|
cost_status: line_item.cost_status,
|
|
103
|
-
pricing_basis: line_item.pricing_basis
|
|
99
|
+
pricing_basis: line_item.pricing_basis,
|
|
104
100
|
price_key: line_item.price_key,
|
|
105
|
-
price_source: line_item.price_source
|
|
101
|
+
price_source: line_item.price_source,
|
|
106
102
|
price_source_version: line_item.price_source_version,
|
|
107
103
|
provider_field: line_item.provider_field,
|
|
108
104
|
provider_item_id: line_item.provider_item_id,
|
|
@@ -129,26 +125,6 @@ module LlmCostTracker
|
|
|
129
125
|
def stored_details(details)
|
|
130
126
|
(details || {}).transform_keys(&:to_s).transform_values { |value| Tags::Encoding.normalize_value(value) }
|
|
131
127
|
end
|
|
132
|
-
|
|
133
|
-
def increment_rollups_safely(events)
|
|
134
|
-
Ledger::Rollups.increment_many!(events)
|
|
135
|
-
rescue StandardError => e
|
|
136
|
-
raise if LlmCostTracker::Call.connection.open_transactions.positive?
|
|
137
|
-
|
|
138
|
-
LlmCostTracker::Logging.warn(
|
|
139
|
-
"Rollup increment failed for #{events.size} events after ledger commit: #{e.class}: #{e.message}"
|
|
140
|
-
)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def insertable_events(events)
|
|
144
|
-
existing_ids = LlmCostTracker::Call.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
|
|
145
|
-
seen_ids = Set.new
|
|
146
|
-
|
|
147
|
-
events.select do |event|
|
|
148
|
-
event_id = event.event_id
|
|
149
|
-
!existing_ids.include?(event_id) && seen_ids.add?(event_id)
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
128
|
end
|
|
153
129
|
end
|
|
154
130
|
end
|
|
@@ -6,9 +6,7 @@ module LlmCostTracker
|
|
|
6
6
|
module Ledger
|
|
7
7
|
module Tags
|
|
8
8
|
module Encoding
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def encode(value)
|
|
9
|
+
def self.encode(value)
|
|
12
10
|
case value
|
|
13
11
|
when Hash then JSON.generate(normalize_hash(value))
|
|
14
12
|
when Array then JSON.generate(normalize_array(value))
|
|
@@ -16,15 +14,15 @@ module LlmCostTracker
|
|
|
16
14
|
end
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
def normalize_hash(hash)
|
|
17
|
+
def self.normalize_hash(hash)
|
|
20
18
|
hash.transform_keys(&:to_s).sort.to_h.transform_values { |v| normalize_value(v) }
|
|
21
19
|
end
|
|
22
20
|
|
|
23
|
-
def normalize_array(array)
|
|
21
|
+
def self.normalize_array(array)
|
|
24
22
|
array.map { |v| normalize_value(v) }
|
|
25
23
|
end
|
|
26
24
|
|
|
27
|
-
def normalize_value(value)
|
|
25
|
+
def self.normalize_value(value)
|
|
28
26
|
case value
|
|
29
27
|
when Hash then normalize_hash(value)
|
|
30
28
|
when Array then normalize_array(value)
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "ledger/schema
|
|
4
|
-
require_relative "ledger/schema/calls"
|
|
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"
|
|
10
|
-
require_relative "ledger/tags/query"
|
|
11
|
-
require_relative "ledger/tags/sql"
|
|
3
|
+
require_relative "ledger/schema"
|
|
12
4
|
require_relative "ledger/period"
|
|
13
|
-
require_relative "ledger/rollups/upsert_sql"
|
|
14
5
|
require_relative "ledger/rollups"
|
|
15
6
|
require_relative "ledger/store"
|
|
16
|
-
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Ledger
|
|
10
|
+
module Tags
|
|
11
|
+
autoload :Query, "llm_cost_tracker/ledger/tags/query"
|
|
12
|
+
autoload :Breakdown, "llm_cost_tracker/ledger/tags/breakdown"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Period
|
|
16
|
+
autoload :Totals, "llm_cost_tracker/ledger/period/totals"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -2,32 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Logging
|
|
5
|
-
PREFIX = "[LlmCostTracker]"
|
|
6
|
-
|
|
7
5
|
class << self
|
|
8
|
-
def debug(message)
|
|
9
|
-
log(:debug, message)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def warn(message)
|
|
13
|
-
log(:warn, message)
|
|
14
|
-
end
|
|
6
|
+
def debug(message) = tagged.debug(message)
|
|
15
7
|
|
|
16
|
-
def
|
|
17
|
-
message = prefixed(message)
|
|
18
|
-
logger = Rails.logger
|
|
19
|
-
return Kernel.warn(message) unless logger
|
|
20
|
-
|
|
21
|
-
logger.public_send(level, message)
|
|
22
|
-
end
|
|
8
|
+
def warn(message) = tagged.warn(message)
|
|
23
9
|
|
|
24
10
|
private
|
|
25
11
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
return message if message.start_with?(PREFIX)
|
|
29
|
-
|
|
30
|
-
"#{PREFIX} #{message}"
|
|
12
|
+
def tagged
|
|
13
|
+
Rails.logger.tagged(LlmCostTracker.name)
|
|
31
14
|
end
|
|
32
15
|
end
|
|
33
16
|
end
|
|
@@ -5,8 +5,7 @@ require "json"
|
|
|
5
5
|
require "stringio"
|
|
6
6
|
require "uri"
|
|
7
7
|
|
|
8
|
-
require_relative "../
|
|
9
|
-
require_relative "../capture/stream"
|
|
8
|
+
require_relative "../capture/sse"
|
|
10
9
|
require_relative "../timing"
|
|
11
10
|
|
|
12
11
|
module LlmCostTracker
|
|
@@ -18,12 +17,12 @@ module LlmCostTracker
|
|
|
18
17
|
end
|
|
19
18
|
|
|
20
19
|
def call(request_env)
|
|
21
|
-
return @app.call(request_env) unless enabled
|
|
20
|
+
return @app.call(request_env) unless LlmCostTracker.configuration.enabled
|
|
22
21
|
|
|
23
22
|
request_url = request_env.url.to_s
|
|
24
23
|
request_body = read_body(request_env.body)
|
|
25
24
|
parser = Parsers.find_for(request_url)
|
|
26
|
-
request_parsed = parser
|
|
25
|
+
request_parsed = parser&.safe_json_parse(request_body)
|
|
27
26
|
streaming = parser&.streaming_request?(request_url, request_parsed)
|
|
28
27
|
if streaming
|
|
29
28
|
request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
|
|
@@ -31,7 +30,7 @@ module LlmCostTracker
|
|
|
31
30
|
stream_buffer = install_stream_tap(request_env) if streaming
|
|
32
31
|
|
|
33
32
|
if parser
|
|
34
|
-
|
|
33
|
+
Budget.enforce!(
|
|
35
34
|
provider: parser.provider_for(request_url),
|
|
36
35
|
model: parser.model_for(request_url, request_parsed),
|
|
37
36
|
request: request_parsed
|
|
@@ -41,59 +40,61 @@ module LlmCostTracker
|
|
|
41
40
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
42
41
|
|
|
43
42
|
invoke_app_with_capture(
|
|
44
|
-
request_env: request_env,
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
request_env: request_env,
|
|
44
|
+
parser: parser,
|
|
45
|
+
request_url: request_url,
|
|
46
|
+
request_body: request_body,
|
|
47
|
+
streaming: streaming,
|
|
48
|
+
stream_buffer: stream_buffer,
|
|
49
|
+
context_tags: context_tags,
|
|
50
|
+
metadata: metadata,
|
|
51
|
+
started_at: started_at
|
|
47
52
|
)
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
private
|
|
51
56
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
JSON.parse(body)
|
|
62
|
-
rescue JSON::ParserError
|
|
63
|
-
{}
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def auto_enable_stream_usage?
|
|
67
|
-
return @auto_enable_stream_usage if defined?(@auto_enable_stream_usage)
|
|
68
|
-
|
|
69
|
-
@auto_enable_stream_usage = LlmCostTracker.configuration.auto_enable_stream_usage
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
|
|
73
|
-
stream_buffer:, context_tags:, metadata:, started_at:)
|
|
57
|
+
def invoke_app_with_capture(request_env:,
|
|
58
|
+
parser:,
|
|
59
|
+
request_url:,
|
|
60
|
+
request_body:,
|
|
61
|
+
streaming:,
|
|
62
|
+
stream_buffer:,
|
|
63
|
+
context_tags:,
|
|
64
|
+
metadata:,
|
|
65
|
+
started_at:)
|
|
74
66
|
response_received = false
|
|
75
67
|
@app.call(request_env).on_complete do |response_env|
|
|
76
68
|
response_received = true
|
|
77
69
|
process(
|
|
78
|
-
parser: parser,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
70
|
+
parser: parser,
|
|
71
|
+
request_url: request_url,
|
|
72
|
+
request_body: request_body,
|
|
73
|
+
response_env: response_env,
|
|
74
|
+
latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
75
|
+
streaming: streaming,
|
|
76
|
+
stream_buffer: stream_buffer,
|
|
77
|
+
context_tags: context_tags,
|
|
78
|
+
metadata: metadata
|
|
82
79
|
)
|
|
83
80
|
end
|
|
84
81
|
rescue StandardError => e
|
|
85
82
|
if streaming && parser && !response_received
|
|
86
83
|
process_interrupted_stream(
|
|
87
|
-
parser: parser,
|
|
84
|
+
parser: parser,
|
|
85
|
+
request_url: request_url,
|
|
86
|
+
request_body: request_body,
|
|
88
87
|
latency_ms: LlmCostTracker::Timing.elapsed_ms(started_at),
|
|
89
|
-
context_tags: context_tags,
|
|
88
|
+
context_tags: context_tags,
|
|
89
|
+
metadata: metadata,
|
|
90
|
+
error: e
|
|
90
91
|
)
|
|
91
92
|
end
|
|
92
93
|
raise
|
|
93
94
|
end
|
|
94
95
|
|
|
95
96
|
def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
|
|
96
|
-
return nil unless auto_enable_stream_usage
|
|
97
|
+
return nil unless LlmCostTracker.configuration.auto_enable_stream_usage
|
|
97
98
|
return nil unless parser&.auto_enable_stream_usage?(request_url)
|
|
98
99
|
|
|
99
100
|
stream_options = request_parsed["stream_options"]
|
|
@@ -105,15 +106,20 @@ module LlmCostTracker
|
|
|
105
106
|
new_body
|
|
106
107
|
end
|
|
107
108
|
|
|
108
|
-
def process_interrupted_stream(parser:,
|
|
109
|
-
|
|
109
|
+
def process_interrupted_stream(parser:,
|
|
110
|
+
request_url:,
|
|
111
|
+
request_body:,
|
|
112
|
+
latency_ms:,
|
|
113
|
+
context_tags:,
|
|
114
|
+
metadata:,
|
|
115
|
+
error:)
|
|
110
116
|
request = parser.safe_json_parse(request_body)
|
|
111
117
|
event = Event.build(
|
|
112
118
|
provider: parser.provider_for(request_url),
|
|
113
119
|
model: request["model"] || Event::UNKNOWN_MODEL,
|
|
114
|
-
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
120
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
115
121
|
stream: true,
|
|
116
|
-
usage_source:
|
|
122
|
+
usage_source: Usage::Source::UNKNOWN
|
|
117
123
|
)
|
|
118
124
|
merged_metadata = (metadata || {}).merge(
|
|
119
125
|
stream_interrupted: true,
|
|
@@ -129,8 +135,15 @@ module LlmCostTracker
|
|
|
129
135
|
Logging.warn("Error recording interrupted stream: #{e.class}: #{e.message}")
|
|
130
136
|
end
|
|
131
137
|
|
|
132
|
-
def process(parser:,
|
|
133
|
-
|
|
138
|
+
def process(parser:,
|
|
139
|
+
request_url:,
|
|
140
|
+
request_body:,
|
|
141
|
+
response_env:,
|
|
142
|
+
latency_ms:,
|
|
143
|
+
streaming:,
|
|
144
|
+
stream_buffer:,
|
|
145
|
+
context_tags:,
|
|
146
|
+
metadata:)
|
|
134
147
|
return unless parser
|
|
135
148
|
|
|
136
149
|
parsed =
|
|
@@ -201,7 +214,7 @@ module LlmCostTracker
|
|
|
201
214
|
)
|
|
202
215
|
end
|
|
203
216
|
|
|
204
|
-
events = overflowed ? [] :
|
|
217
|
+
events = overflowed ? [] : Capture::SSE.parse(body)
|
|
205
218
|
parser.parse_stream(
|
|
206
219
|
request_url: request_url,
|
|
207
220
|
request_body: request_body,
|
|
@@ -232,7 +245,7 @@ module LlmCostTracker
|
|
|
232
245
|
state = { buffer: StringIO.new, bytes: 0, overflowed: false }
|
|
233
246
|
request.on_data = proc do |chunk, size, env|
|
|
234
247
|
chunk = chunk.to_s
|
|
235
|
-
remaining = Capture::
|
|
248
|
+
remaining = Capture::SSE::LIMIT_BYTES - state[:bytes]
|
|
236
249
|
if chunk.bytesize <= remaining
|
|
237
250
|
state[:buffer] << chunk
|
|
238
251
|
state[:bytes] += chunk.bytesize
|
|
@@ -279,13 +292,12 @@ module LlmCostTracker
|
|
|
279
292
|
end
|
|
280
293
|
|
|
281
294
|
def capture_warning(request_url, stream_buffer)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
295
|
+
suffix = "recording usage_source=#{Usage::Source::UNKNOWN}. " \
|
|
296
|
+
"Use LlmCostTracker.track_stream for manual capture."
|
|
297
|
+
label = request_url_label(request_url)
|
|
298
|
+
return "Unable to capture streaming response for #{label}; #{suffix}" unless stream_buffer&.dig(:overflowed)
|
|
286
299
|
|
|
287
|
-
"Streaming response for #{
|
|
288
|
-
"recording usage_source=unknown. Use LlmCostTracker.track_stream for manual capture."
|
|
300
|
+
"Streaming response for #{label} exceeded #{Capture::SSE::LIMIT_BYTES} bytes; #{suffix}"
|
|
289
301
|
end
|
|
290
302
|
|
|
291
303
|
def request_url_label(value)
|
|
@@ -1,47 +1,158 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "providers"
|
|
8
|
+
|
|
3
9
|
module LlmCostTracker
|
|
4
10
|
module Parsers
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
autoload :Azure, "llm_cost_tracker/parsers/azure"
|
|
11
|
-
autoload :OpenaiCompatible, "llm_cost_tracker/parsers/openai_compatible"
|
|
12
|
-
autoload :Anthropic, "llm_cost_tracker/parsers/anthropic"
|
|
13
|
-
autoload :Gemini, "llm_cost_tracker/parsers/gemini"
|
|
14
|
-
|
|
15
|
-
MUTEX = Mutex.new
|
|
16
|
-
PARSER_CONSTANTS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
|
|
17
|
-
|
|
18
|
-
module_function
|
|
19
|
-
|
|
20
|
-
def find_for(url)
|
|
21
|
-
PARSER_CONSTANTS.each do |name|
|
|
22
|
-
klass = const_get(name)
|
|
23
|
-
return instance_for(klass) if klass.match?(url)
|
|
11
|
+
PARSER_PROVIDERS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
|
|
12
|
+
|
|
13
|
+
def self.find_for(url)
|
|
14
|
+
instances.each do |klass, instance|
|
|
15
|
+
return instance if klass.match?(url)
|
|
24
16
|
end
|
|
25
17
|
nil
|
|
26
18
|
end
|
|
27
19
|
|
|
28
|
-
def find_for_provider(provider)
|
|
20
|
+
def self.find_for_provider(provider)
|
|
29
21
|
provider_name = provider.to_s.downcase
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return instance_for(klass) if klass.provider_names.include?(provider_name)
|
|
22
|
+
instances.each do |klass, instance|
|
|
23
|
+
return instance if klass.provider_names.include?(provider_name)
|
|
33
24
|
end
|
|
34
25
|
nil
|
|
35
26
|
end
|
|
36
27
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
|
|
28
|
+
def self.parser_classes
|
|
29
|
+
PARSER_PROVIDERS.map { |name| Providers.const_get(name)::Parser }
|
|
30
|
+
end
|
|
31
|
+
private_class_method :parser_classes
|
|
32
|
+
|
|
33
|
+
def self.instances
|
|
34
|
+
@instances ||= parser_classes.to_h { |klass| [klass, klass.new] }.freeze
|
|
35
|
+
end
|
|
36
|
+
private_class_method :instances
|
|
37
|
+
|
|
38
|
+
module UrlMatchers
|
|
39
|
+
def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
40
|
+
uri_matches?(url) do |uri|
|
|
41
|
+
host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
|
|
42
|
+
path_match = path_matches?(
|
|
43
|
+
uri,
|
|
44
|
+
exact_paths: exact_paths,
|
|
45
|
+
path_includes: path_includes,
|
|
46
|
+
path_suffixes: path_suffixes,
|
|
47
|
+
path_pattern: path_pattern
|
|
48
|
+
)
|
|
49
|
+
extra_match = block_given? ? yield(uri) : true
|
|
50
|
+
|
|
51
|
+
!!(host_match && path_match && extra_match)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def uri_matches?(url)
|
|
56
|
+
uri = parsed_uri(url)
|
|
57
|
+
uri ? yield(uri) : false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parsed_uri(url)
|
|
61
|
+
URI.parse(url.to_s)
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def path_matches?(uri, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
67
|
+
path = uri.path.to_s
|
|
68
|
+
matches = true
|
|
69
|
+
matches &&= exact_paths.include?(path) if exact_paths
|
|
70
|
+
matches &&= Array(path_includes).all? { |fragment| path.include?(fragment) } if path_includes
|
|
71
|
+
matches &&= path.match?(path_pattern) if path_pattern
|
|
72
|
+
matches &&= path_suffixes.any? { |suffix| path == suffix || path.end_with?(suffix) } if path_suffixes
|
|
73
|
+
matches
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class Base
|
|
78
|
+
extend UrlMatchers
|
|
79
|
+
include UrlMatchers
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
def match?(_url)
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def provider_names
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse(**)
|
|
92
|
+
raise NotImplementedError
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def streaming_request?(_request_url, request_parsed)
|
|
96
|
+
request_parsed["stream"] == true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def model_for(_request_url, request_parsed)
|
|
100
|
+
request_parsed["model"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_stream(**)
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def auto_enable_stream_usage?(_request_url)
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def safe_json_parse(body)
|
|
112
|
+
return {} if body.blank?
|
|
113
|
+
|
|
114
|
+
parsed = JSON.parse(body)
|
|
115
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
116
|
+
rescue JSON::ParserError
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def each_event_data(events, reverse: false)
|
|
123
|
+
enumerator = reverse ? events.reverse_each : events.each
|
|
124
|
+
|
|
125
|
+
enumerator.each do |event|
|
|
126
|
+
data = event[:data]
|
|
127
|
+
yield data if data.is_a?(Hash)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def find_event_value(events, reverse: false)
|
|
132
|
+
each_event_data(events, reverse:) do |data|
|
|
133
|
+
value = yield(data)
|
|
134
|
+
return value if value.present?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
40
139
|
|
|
41
|
-
|
|
42
|
-
|
|
140
|
+
def build_unknown_stream_usage(provider:,
|
|
141
|
+
model:,
|
|
142
|
+
provider_response_id:,
|
|
143
|
+
pricing_mode: nil,
|
|
144
|
+
service_line_items: nil)
|
|
145
|
+
Event.build(
|
|
146
|
+
provider: provider,
|
|
147
|
+
provider_response_id: provider_response_id,
|
|
148
|
+
pricing_mode: pricing_mode,
|
|
149
|
+
model: model || Event::UNKNOWN_MODEL,
|
|
150
|
+
token_usage: Usage::TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
151
|
+
stream: true,
|
|
152
|
+
usage_source: Usage::Source::UNKNOWN,
|
|
153
|
+
service_line_items: service_line_items
|
|
154
|
+
)
|
|
43
155
|
end
|
|
44
156
|
end
|
|
45
|
-
private_class_method :instance_for
|
|
46
157
|
end
|
|
47
158
|
end
|