llm_cost_tracker 0.7.2 → 0.8.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 +72 -1
- data/README.md +58 -221
- data/app/assets/llm_cost_tracker/application.css +218 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +30 -17
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +19 -14
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +10 -2
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -24
- 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/token_usage_helper.rb +20 -7
- data/app/models/llm_cost_tracker/call.rb +169 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +22 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +9 -0
- data/app/models/llm_cost_tracker/call_tag.rb +16 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +125 -34
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -2
- 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/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +62 -7
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -50
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +103 -126
- 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/shared/_filters.html.erb +63 -0
- 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 +5 -37
- data/lib/llm_cost_tracker/billing/components.rb +53 -0
- data/lib/llm_cost_tracker/billing/components.yml +117 -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 +23 -35
- data/lib/llm_cost_tracker/capture/stream_collector.rb +47 -33
- data/lib/llm_cost_tracker/configuration.rb +36 -19
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +54 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +24 -32
- 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 +31 -0
- data/lib/llm_cost_tracker/doctor.rb +43 -45
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +10 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +157 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -23
- data/lib/llm_cost_tracker/ingestion/worker.rb +14 -5
- data/lib/llm_cost_tracker/ingestion.rb +28 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +45 -38
- data/lib/llm_cost_tracker/integrations/base.rb +36 -29
- data/lib/llm_cost_tracker/integrations/openai.rb +85 -40
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +5 -5
- data/lib/llm_cost_tracker/integrations.rb +2 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +12 -9
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +4 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +76 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +50 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +34 -23
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +110 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +5 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -14
- data/lib/llm_cost_tracker/ledger.rb +4 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/middleware/faraday.rb +7 -6
- data/lib/llm_cost_tracker/parsers/anthropic.rb +52 -7
- data/lib/llm_cost_tracker/parsers/base.rb +8 -3
- data/lib/llm_cost_tracker/parsers/gemini.rb +101 -15
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +10 -2
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +87 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +48 -21
- 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 +105 -20
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +57 -19
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +38 -34
- data/lib/llm_cost_tracker/pricing/registry.rb +65 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +204 -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.rb +57 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +190 -26
- data/lib/llm_cost_tracker/railtie.rb +0 -8
- data/lib/llm_cost_tracker/report/data.rb +16 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +8 -8
- data/lib/llm_cost_tracker/tags/context.rb +2 -4
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +12 -17
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +56 -42
- data/lib/llm_cost_tracker/tracker.rb +67 -24
- 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 +36 -35
- data/lib/tasks/llm_cost_tracker.rake +22 -17
- metadata +36 -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_provider_response_id_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_streaming_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
|
@@ -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,48 @@ 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, :code_execution_request, :code_execution_requests,
|
|
70
|
+
"usage.server_tool_use.code_execution_requests")
|
|
71
|
+
].compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def line_item_for_server_tool(server_tool_use, component_key, count_key, provider_field)
|
|
75
|
+
quantity = object_value(server_tool_use, count_key).to_i
|
|
76
|
+
return nil if quantity.zero?
|
|
77
|
+
|
|
78
|
+
Billing::LineItem.build(
|
|
79
|
+
component_key: component_key,
|
|
80
|
+
quantity: quantity,
|
|
81
|
+
cost_status: Billing::CostStatus::UNKNOWN,
|
|
82
|
+
pricing_basis: :provider_usage,
|
|
83
|
+
provider_field: provider_field
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
62
87
|
def token_usage(usage:, input_tokens:, output_tokens:)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
cache_creation = object_value(usage, :cache_creation)
|
|
89
|
+
if cache_creation
|
|
90
|
+
cache_write_default = object_value(cache_creation, :ephemeral_5m_input_tokens).to_i
|
|
91
|
+
cache_write_extended = object_value(cache_creation, :ephemeral_1h_input_tokens).to_i
|
|
92
|
+
else
|
|
93
|
+
cache_write_default = object_value(usage, :cache_creation_input_tokens).to_i
|
|
94
|
+
cache_write_extended = 0
|
|
95
|
+
end
|
|
71
96
|
hidden_output = (
|
|
72
97
|
object_value(usage, :thinking_tokens, :thinking_output_tokens) ||
|
|
73
98
|
object_dig(usage, :output_tokens_details, :reasoning_tokens)
|
|
@@ -77,8 +102,8 @@ module LlmCostTracker
|
|
|
77
102
|
input_tokens: input_tokens.to_i,
|
|
78
103
|
output_tokens: output_tokens.to_i,
|
|
79
104
|
cache_read_input_tokens: object_value(usage, :cache_read_input_tokens).to_i,
|
|
80
|
-
cache_write_input_tokens:
|
|
81
|
-
|
|
105
|
+
cache_write_input_tokens: cache_write_default,
|
|
106
|
+
cache_write_extended_input_tokens: cache_write_extended,
|
|
82
107
|
hidden_output_tokens: hidden_output
|
|
83
108
|
)
|
|
84
109
|
end
|
|
@@ -95,39 +120,21 @@ module LlmCostTracker
|
|
|
95
120
|
modes.empty? ? nil : modes.join("_")
|
|
96
121
|
end
|
|
97
122
|
|
|
123
|
+
def stream_pricing_mode(request)
|
|
124
|
+
pricing_mode(message: nil, request: request || {}, usage: nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
98
127
|
def inference_geo(message:, request:, usage:)
|
|
99
128
|
object_value(usage, :inference_geo) ||
|
|
100
129
|
object_value(message, :inference_geo) ||
|
|
101
130
|
request[:inference_geo]
|
|
102
131
|
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
132
|
end
|
|
126
133
|
|
|
127
134
|
module MessagesPatch
|
|
128
135
|
def create(*args, **kwargs)
|
|
129
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
130
136
|
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
137
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
131
138
|
message = super
|
|
132
139
|
LlmCostTracker::Integrations::Anthropic.record_message(
|
|
133
140
|
message,
|
|
@@ -139,16 +146,16 @@ module LlmCostTracker
|
|
|
139
146
|
|
|
140
147
|
def stream(*args, **kwargs)
|
|
141
148
|
request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
|
|
142
|
-
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
143
149
|
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
150
|
+
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
144
151
|
stream = super
|
|
145
152
|
LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
|
|
146
153
|
end
|
|
147
154
|
|
|
148
155
|
def stream_raw(*args, **kwargs)
|
|
149
156
|
request = LlmCostTracker::Integrations::Anthropic.request_params(args, kwargs)
|
|
150
|
-
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
151
157
|
LlmCostTracker::Integrations::Anthropic.enforce_budget!
|
|
158
|
+
collector = LlmCostTracker::Integrations::Anthropic.stream_collector(request)
|
|
152
159
|
stream = super
|
|
153
160
|
LlmCostTracker::Integrations::Anthropic.track_stream(stream, collector: collector)
|
|
154
161
|
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!
|
|
@@ -60,6 +61,29 @@ module LlmCostTracker
|
|
|
60
61
|
params.merge(kwargs).with_indifferent_access
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
def track_stream(stream, collector:)
|
|
65
|
+
return stream unless active?
|
|
66
|
+
|
|
67
|
+
LlmCostTracker::Capture::StreamTracker.new(
|
|
68
|
+
stream: stream,
|
|
69
|
+
collector: collector,
|
|
70
|
+
active: -> { active? },
|
|
71
|
+
finish: ->(errored:) { record_safely { collector.finish!(errored: errored) } }
|
|
72
|
+
).wrap
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stream_collector(request)
|
|
76
|
+
LlmCostTracker::Capture::StreamCollector.new(
|
|
77
|
+
provider: integration_name.to_s,
|
|
78
|
+
model: request[:model],
|
|
79
|
+
pricing_mode: stream_pricing_mode(request)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stream_pricing_mode(_request)
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
63
87
|
def object_value(object, *keys)
|
|
64
88
|
keys.each do |key|
|
|
65
89
|
value = read_object_value(object, key)
|
|
@@ -69,15 +93,6 @@ module LlmCostTracker
|
|
|
69
93
|
end
|
|
70
94
|
|
|
71
95
|
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
96
|
path.reduce(object) do |current, key|
|
|
82
97
|
return nil if current.nil?
|
|
83
98
|
|
|
@@ -106,25 +121,17 @@ module LlmCostTracker
|
|
|
106
121
|
|
|
107
122
|
def read_object_value(object, key)
|
|
108
123
|
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
|
-
|
|
114
|
-
value = object.try(key)
|
|
115
|
-
return value unless value.nil?
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
if object.is_a?(Hash)
|
|
126
|
+
return object[key] if object.key?(key)
|
|
127
|
+
return object[key.name] if key.is_a?(Symbol) && object.key?(key.name)
|
|
128
|
+
end
|
|
119
129
|
|
|
120
|
-
|
|
121
|
-
object.try(:[], key)
|
|
122
|
-
rescue IndexError, NameError, TypeError
|
|
123
|
-
nil
|
|
130
|
+
object.public_send(key) if object.respond_to?(key)
|
|
124
131
|
end
|
|
125
132
|
|
|
126
|
-
module_function :read_object_value
|
|
127
|
-
private_class_method :read_object_value
|
|
133
|
+
module_function :read_object_value
|
|
134
|
+
private_class_method :read_object_value
|
|
128
135
|
|
|
129
136
|
def validate_contract!
|
|
130
137
|
problems = version_problems + target_problems
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
-
require_relative "../
|
|
5
|
-
require_relative "../
|
|
4
|
+
require_relative "../billing/line_item"
|
|
5
|
+
require_relative "../parsers/openai_service_charges"
|
|
6
6
|
|
|
7
7
|
module LlmCostTracker
|
|
8
8
|
module Integrations
|
|
@@ -14,6 +14,10 @@ module LlmCostTracker
|
|
|
14
14
|
:openai
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def stream_pricing_mode(request)
|
|
18
|
+
Pricing.normalize_mode((request || {})[:service_tier])
|
|
19
|
+
end
|
|
20
|
+
|
|
17
21
|
def minimum_version
|
|
18
22
|
"0.59.0"
|
|
19
23
|
end
|
|
@@ -54,65 +58,106 @@ module LlmCostTracker
|
|
|
54
58
|
provider: "openai",
|
|
55
59
|
model: object_value(response, :model) || request[:model],
|
|
56
60
|
pricing_mode: object_value(response, :service_tier) || request[:service_tier],
|
|
57
|
-
token_usage:
|
|
58
|
-
input_tokens: regular_input_tokens(input_tokens, cache_read),
|
|
59
|
-
output_tokens: output_tokens.to_i,
|
|
60
|
-
cache_read_input_tokens: cache_read,
|
|
61
|
-
hidden_output_tokens: hidden_output_tokens(usage)
|
|
62
|
-
),
|
|
61
|
+
token_usage: token_usage(usage:, input_tokens:, output_tokens:, cache_read:),
|
|
63
62
|
usage_source: :sdk_response,
|
|
64
|
-
provider_response_id: object_value(response, :id)
|
|
63
|
+
provider_response_id: object_value(response, :id),
|
|
64
|
+
service_line_items: service_line_items_from(response)
|
|
65
65
|
),
|
|
66
66
|
latency_ms: latency_ms
|
|
67
67
|
)
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def service_line_items_from(response)
|
|
72
|
+
output = object_value(response, :output)
|
|
73
|
+
return [] unless output.respond_to?(:each)
|
|
74
|
+
|
|
75
|
+
LlmCostTracker::Parsers::OpenaiServiceCharges
|
|
76
|
+
.line_items_from_output(output.map { |item| normalize_output_item(item) })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize_output_item(item)
|
|
80
|
+
return item if item.is_a?(Hash)
|
|
81
|
+
return nil if item.nil?
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
"type" => object_value(item, :type),
|
|
85
|
+
"id" => object_value(item, :id),
|
|
86
|
+
"status" => object_value(item, :status),
|
|
87
|
+
"container_id" => object_value(item, :container_id),
|
|
88
|
+
"action" => normalize_output_action(object_value(item, :action))
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_output_action(action)
|
|
93
|
+
return nil if action.nil?
|
|
94
|
+
return action if action.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
{ "type" => object_value(action, :type) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def token_usage(usage:, input_tokens:, output_tokens:, cache_read:)
|
|
100
|
+
audio_input = audio_input_tokens(usage)
|
|
101
|
+
audio_output = audio_output_tokens(usage)
|
|
102
|
+
|
|
103
|
+
TokenUsage.build(
|
|
104
|
+
input_tokens: regular_input_tokens(input_tokens, cache_read, audio_input),
|
|
105
|
+
output_tokens: regular_output_tokens(output_tokens, audio_output),
|
|
106
|
+
cache_read_input_tokens: cache_read,
|
|
107
|
+
audio_input_tokens: audio_input,
|
|
108
|
+
audio_output_tokens: audio_output,
|
|
109
|
+
hidden_output_tokens: hidden_output_tokens(usage)
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
|
|
114
|
+
OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
|
|
115
|
+
|
|
71
116
|
def cache_read_input_tokens(usage)
|
|
72
|
-
(
|
|
73
|
-
object_dig(usage, :input_tokens_details, :cached_tokens) ||
|
|
74
|
-
object_dig(usage, :prompt_tokens_details, :cached_tokens)
|
|
75
|
-
).to_i
|
|
117
|
+
input_detail(usage, :cached_tokens)
|
|
76
118
|
end
|
|
77
119
|
|
|
78
120
|
def hidden_output_tokens(usage)
|
|
79
|
-
(
|
|
80
|
-
object_dig(usage, :output_tokens_details, :reasoning_tokens) ||
|
|
81
|
-
object_dig(usage, :completion_tokens_details, :reasoning_tokens)
|
|
82
|
-
).to_i
|
|
121
|
+
output_detail(usage, :reasoning_tokens)
|
|
83
122
|
end
|
|
84
123
|
|
|
85
|
-
def
|
|
86
|
-
|
|
124
|
+
def audio_input_tokens(usage)
|
|
125
|
+
input_detail(usage, :audio_tokens)
|
|
87
126
|
end
|
|
88
127
|
|
|
89
|
-
def
|
|
90
|
-
|
|
128
|
+
def audio_output_tokens(usage)
|
|
129
|
+
output_detail(usage, :audio_tokens)
|
|
130
|
+
end
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
132
|
+
def input_detail(usage, key)
|
|
133
|
+
INPUT_DETAIL_KEYS.each do |container|
|
|
134
|
+
value = object_dig(usage, container, key)
|
|
135
|
+
return value.to_i if value
|
|
136
|
+
end
|
|
137
|
+
0
|
|
98
138
|
end
|
|
99
139
|
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
140
|
+
def output_detail(usage, key)
|
|
141
|
+
OUTPUT_DETAIL_KEYS.each do |container|
|
|
142
|
+
value = object_dig(usage, container, key)
|
|
143
|
+
return value.to_i if value
|
|
144
|
+
end
|
|
145
|
+
0
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def regular_input_tokens(input_tokens, cache_read, audio_input)
|
|
149
|
+
[input_tokens.to_i - cache_read - audio_input, 0].max
|
|
105
150
|
end
|
|
106
151
|
|
|
107
|
-
def
|
|
108
|
-
|
|
152
|
+
def regular_output_tokens(output_tokens, audio_output)
|
|
153
|
+
[output_tokens.to_i - audio_output, 0].max
|
|
109
154
|
end
|
|
110
155
|
end
|
|
111
156
|
|
|
112
157
|
module ResponsesPatch
|
|
113
158
|
def create(*args, **kwargs)
|
|
114
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
159
|
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
160
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
116
161
|
response = super
|
|
117
162
|
LlmCostTracker::Integrations::Openai.record_response(
|
|
118
163
|
response,
|
|
@@ -124,25 +169,25 @@ module LlmCostTracker
|
|
|
124
169
|
|
|
125
170
|
def stream(*args, **kwargs)
|
|
126
171
|
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
127
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
128
172
|
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
173
|
+
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
129
174
|
stream = super
|
|
130
175
|
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
131
176
|
end
|
|
132
177
|
|
|
133
178
|
def stream_raw(*args, **kwargs)
|
|
134
179
|
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
135
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
136
180
|
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
181
|
+
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
137
182
|
stream = super
|
|
138
183
|
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
139
184
|
end
|
|
140
185
|
|
|
141
186
|
def retrieve_streaming(response_id, *args, **kwargs)
|
|
142
187
|
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
188
|
+
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
143
189
|
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
144
190
|
collector.provider_response_id = response_id
|
|
145
|
-
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
146
191
|
stream = super
|
|
147
192
|
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
148
193
|
end
|
|
@@ -150,8 +195,8 @@ module LlmCostTracker
|
|
|
150
195
|
|
|
151
196
|
module ChatCompletionsPatch
|
|
152
197
|
def create(*args, **kwargs)
|
|
153
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
154
198
|
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
199
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
155
200
|
response = super
|
|
156
201
|
LlmCostTracker::Integrations::Openai.record_response(
|
|
157
202
|
response,
|
|
@@ -163,8 +208,8 @@ module LlmCostTracker
|
|
|
163
208
|
|
|
164
209
|
def stream_raw(*args, **kwargs)
|
|
165
210
|
request = LlmCostTracker::Integrations::Openai.request_params(args, kwargs)
|
|
166
|
-
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
167
211
|
LlmCostTracker::Integrations::Openai.enforce_budget!
|
|
212
|
+
collector = LlmCostTracker::Integrations::Openai.stream_collector(request)
|
|
168
213
|
stream = super
|
|
169
214
|
LlmCostTracker::Integrations::Openai.track_stream(stream, collector: collector)
|
|
170
215
|
end
|
|
@@ -89,7 +89,7 @@ module LlmCostTracker
|
|
|
89
89
|
hidden_output_tokens: hidden_output
|
|
90
90
|
),
|
|
91
91
|
stream: stream,
|
|
92
|
-
usage_source: :
|
|
92
|
+
usage_source: :sdk_response,
|
|
93
93
|
provider_response_id: provider_response_id(response)
|
|
94
94
|
),
|
|
95
95
|
latency_ms: latency_ms
|
|
@@ -98,7 +98,7 @@ module LlmCostTracker
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def regular_input_tokens(input_tokens, cache_read)
|
|
101
|
-
[input_tokens.to_i - cache_read
|
|
101
|
+
[input_tokens.to_i - cache_read, 0].max
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def provider_slug(provider)
|
|
@@ -133,8 +133,8 @@ module LlmCostTracker
|
|
|
133
133
|
def complete(*args, **kwargs, &)
|
|
134
134
|
integration = LlmCostTracker::Integrations::RubyLlm
|
|
135
135
|
request = integration.request_params(args, kwargs)
|
|
136
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
137
136
|
integration.enforce_budget!
|
|
137
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
138
138
|
response = super
|
|
139
139
|
integration.record_completion(
|
|
140
140
|
self,
|
|
@@ -149,8 +149,8 @@ module LlmCostTracker
|
|
|
149
149
|
def embed(*args, **kwargs)
|
|
150
150
|
integration = LlmCostTracker::Integrations::RubyLlm
|
|
151
151
|
request = integration.request_params(args, kwargs)
|
|
152
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
153
152
|
integration.enforce_budget!
|
|
153
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
154
154
|
response = super
|
|
155
155
|
integration.record_embedding(
|
|
156
156
|
self,
|
|
@@ -164,8 +164,8 @@ module LlmCostTracker
|
|
|
164
164
|
def transcribe(*args, **kwargs)
|
|
165
165
|
integration = LlmCostTracker::Integrations::RubyLlm
|
|
166
166
|
request = integration.request_params(args, kwargs)
|
|
167
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
168
167
|
integration.enforce_budget!
|
|
168
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
169
169
|
response = super
|
|
170
170
|
integration.record_transcription(
|
|
171
171
|
self,
|
|
@@ -26,11 +26,11 @@ module LlmCostTracker
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def normalize(names)
|
|
29
|
-
Array(names).flatten.
|
|
29
|
+
Array(names).flatten.uniq
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def fetch(name)
|
|
33
|
-
AVAILABLE.fetch(name
|
|
33
|
+
AVAILABLE.fetch(name) do
|
|
34
34
|
message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
|
|
35
35
|
raise LlmCostTracker::Error, message
|
|
36
36
|
end
|
|
@@ -27,38 +27,41 @@ module LlmCostTracker
|
|
|
27
27
|
|
|
28
28
|
def snapshot_totals
|
|
29
29
|
values = periods.to_h { |period| [period, 0.0] }
|
|
30
|
+
period_by_name = periods.to_h { |period| [period.name, period] }
|
|
30
31
|
sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
|
|
31
|
-
LlmCostTracker::
|
|
32
|
-
|
|
32
|
+
LlmCostTracker::Call.find_by_sql(sql).each do |row|
|
|
33
|
+
period = period_by_name.fetch(row.period_key)
|
|
34
|
+
values[period] = row.total_cost.to_f
|
|
33
35
|
end
|
|
34
36
|
values
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def snapshot_select(period)
|
|
38
40
|
start = Period.range_start(period, time)
|
|
39
|
-
"SELECT #{connection.quote(period.
|
|
41
|
+
"SELECT #{connection.quote(period.name)} AS period_key, " \
|
|
40
42
|
"(#{rollup_total_sql(period)}) + (#{pending_total_sql(start)}) AS total_cost"
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def rollup_total_sql(period)
|
|
44
|
-
table = connection.quote_table_name("
|
|
45
|
-
"COALESCE((SELECT total_cost FROM #{table} " \
|
|
46
|
+
table = connection.quote_table_name("llm_cost_tracker_call_rollups")
|
|
47
|
+
"COALESCE((SELECT SUM(total_cost) FROM #{table} " \
|
|
46
48
|
"WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
|
|
47
|
-
"AND period_start = #{connection.quote(Period.bucket(period, time))}
|
|
49
|
+
"AND period_start = #{connection.quote(Period.bucket(period, time))} " \
|
|
50
|
+
"AND currency = #{connection.quote(Ledger::Rollups::DEFAULT_CURRENCY)}), 0)"
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
def pending_total_sql(start)
|
|
51
|
-
table = connection.quote_table_name(Ingestion::
|
|
54
|
+
table = connection.quote_table_name(Ingestion::InboxEntry.table_name)
|
|
52
55
|
total_cost = connection.quote_column_name("total_cost")
|
|
53
56
|
tracked_at = connection.quote_column_name("tracked_at")
|
|
54
57
|
attempts = connection.quote_column_name("attempts")
|
|
55
58
|
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
56
|
-
"WHERE #{attempts} < #{Ingestion::
|
|
59
|
+
"WHERE #{attempts} < #{Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE} " \
|
|
57
60
|
"AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def connection
|
|
61
|
-
LlmCostTracker::
|
|
64
|
+
LlmCostTracker::Call.connection
|
|
62
65
|
end
|
|
63
66
|
end
|
|
64
67
|
end
|
|
@@ -4,22 +4,22 @@ module LlmCostTracker
|
|
|
4
4
|
module Ledger
|
|
5
5
|
module Period
|
|
6
6
|
PERIODS = {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
month: "month",
|
|
8
|
+
day: "day"
|
|
9
9
|
}.freeze
|
|
10
10
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
13
|
def valid_keys(periods)
|
|
14
|
-
periods.
|
|
14
|
+
periods.select { |period| PERIODS.key?(period) }
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def range_start(period, time)
|
|
18
18
|
utc_time = time.to_time.utc
|
|
19
19
|
|
|
20
20
|
case period
|
|
21
|
-
when :
|
|
22
|
-
when :
|
|
21
|
+
when :month then utc_time.beginning_of_month
|
|
22
|
+
when :day then utc_time.beginning_of_day
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -6,12 +6,8 @@ module LlmCostTracker
|
|
|
6
6
|
module Ledger
|
|
7
7
|
class Rollups
|
|
8
8
|
class UpsertSql
|
|
9
|
-
def self.call
|
|
10
|
-
new
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def initialize(model)
|
|
14
|
-
@model = model
|
|
9
|
+
def self.call
|
|
10
|
+
new.call
|
|
15
11
|
end
|
|
16
12
|
|
|
17
13
|
def call
|
|
@@ -23,13 +19,11 @@ module LlmCostTracker
|
|
|
23
19
|
|
|
24
20
|
private
|
|
25
21
|
|
|
26
|
-
attr_reader :model
|
|
27
|
-
|
|
28
22
|
def postgres_sql
|
|
29
23
|
total_cost = connection.quote_column_name("total_cost")
|
|
30
24
|
updated_at = connection.quote_column_name("updated_at")
|
|
31
25
|
|
|
32
|
-
"#{total_cost} = #{
|
|
26
|
+
"#{total_cost} = #{LlmCostTracker::CallRollup.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
33
27
|
"#{updated_at} = excluded.#{updated_at}"
|
|
34
28
|
end
|
|
35
29
|
|
|
@@ -38,7 +32,7 @@ module LlmCostTracker
|
|
|
38
32
|
end
|
|
39
33
|
|
|
40
34
|
def connection
|
|
41
|
-
|
|
35
|
+
LlmCostTracker::CallRollup.connection
|
|
42
36
|
end
|
|
43
37
|
end
|
|
44
38
|
end
|