llm_cost_tracker 0.9.0 → 0.10.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 +29 -1
- data/README.md +2 -1
- data/app/controllers/llm_cost_tracker/application_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +16 -4
- data/app/helpers/llm_cost_tracker/application_helper.rb +1 -1
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/llm_cost_tracker/calls/show.html.erb +1 -1
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +1 -1
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +15 -49
- data/lib/llm_cost_tracker/budget.rb +28 -6
- data/lib/llm_cost_tracker/capture/stream_collector.rb +35 -29
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -1
- data/lib/llm_cost_tracker/configuration.rb +31 -28
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +8 -8
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -2
- data/lib/llm_cost_tracker/doctor.rb +6 -17
- data/lib/llm_cost_tracker/engine.rb +1 -2
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +47 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{durable_ingestion_generator.rb → async_ingestion_generator.rb} +8 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +4 -23
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/{create_llm_cost_tracker_durable_ingestion.rb.erb → create_llm_cost_tracker_async_ingestion.rb.erb} +3 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +6 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +14 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +23 -8
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +25 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +31 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +31 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +5 -2
- data/lib/llm_cost_tracker/ingestion/inbox.rb +3 -24
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +22 -36
- data/lib/llm_cost_tracker/ingestion.rb +8 -9
- data/lib/llm_cost_tracker/integrations/anthropic.rb +28 -42
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +93 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +18 -20
- data/lib/llm_cost_tracker/integrations.rb +14 -13
- data/lib/llm_cost_tracker/ledger/period/totals.rb +5 -3
- data/lib/llm_cost_tracker/ledger/rollups.rb +4 -13
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +11 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +0 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +13 -3
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +19 -9
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +26 -11
- data/lib/llm_cost_tracker/ledger/store.rb +21 -18
- data/lib/llm_cost_tracker/ledger/tags/query.rb +0 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +44 -16
- data/lib/llm_cost_tracker/parsers/anthropic.rb +21 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -47
- data/lib/llm_cost_tracker/parsers/gemini.rb +20 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -43
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +45 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +16 -20
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +567 -579
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +2 -4
- data/lib/llm_cost_tracker/pricing/estimator.rb +33 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -1
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +5 -9
- data/lib/llm_cost_tracker/pricing/{sync_change_printer.rb → sync/change_printer.rb} +3 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +14 -2
- data/lib/llm_cost_tracker/pricing/sync.rb +1 -9
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +72 -27
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +22 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +17 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +17 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +35 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +51 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -1
- data/lib/llm_cost_tracker/reconciliation/diff.rb +26 -45
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -4
- data/lib/llm_cost_tracker/reconciliation/importer.rb +1 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +4 -3
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +20 -8
- data/lib/llm_cost_tracker/tags/sanitizer.rb +13 -17
- data/lib/llm_cost_tracker/token_usage.rb +4 -0
- data/lib/llm_cost_tracker/tracker.rb +33 -74
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +11 -15
- data/lib/tasks/llm_cost_tracker.rake +16 -2
- metadata +18 -7
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -18,16 +18,25 @@ module LlmCostTracker
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(request_env)
|
|
21
|
-
return @app.call(request_env) unless
|
|
21
|
+
return @app.call(request_env) unless enabled?
|
|
22
22
|
|
|
23
23
|
request_url = request_env.url.to_s
|
|
24
24
|
request_body = read_body(request_env.body)
|
|
25
25
|
parser = Parsers.find_for(request_url)
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
request_parsed = parser ? safe_json_parse(request_body) : nil
|
|
27
|
+
streaming = parser&.streaming_request?(request_url, request_parsed)
|
|
28
|
+
if streaming
|
|
29
|
+
request_body = inject_stream_usage_flag(request_env, parser, request_url, request_parsed) || request_body
|
|
30
|
+
end
|
|
28
31
|
stream_buffer = install_stream_tap(request_env) if streaming
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
if parser
|
|
34
|
+
Tracker.enforce_budget!(
|
|
35
|
+
provider: parser.provider_for(request_url),
|
|
36
|
+
model: parser.model_for(request_url, request_parsed),
|
|
37
|
+
request: request_parsed
|
|
38
|
+
)
|
|
39
|
+
end
|
|
31
40
|
context_tags, metadata = tag_snapshot(request_env) if parser
|
|
32
41
|
started_at = LlmCostTracker::Timing.now_monotonic
|
|
33
42
|
|
|
@@ -40,6 +49,26 @@ module LlmCostTracker
|
|
|
40
49
|
|
|
41
50
|
private
|
|
42
51
|
|
|
52
|
+
def enabled?
|
|
53
|
+
return @enabled if defined?(@enabled)
|
|
54
|
+
|
|
55
|
+
@enabled = LlmCostTracker.configuration.enabled
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def safe_json_parse(body)
|
|
59
|
+
return {} if body.nil? || body.empty?
|
|
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
|
+
|
|
43
72
|
def invoke_app_with_capture(request_env:, parser:, request_url:, request_body:, streaming:,
|
|
44
73
|
stream_buffer:, context_tags:, metadata:, started_at:)
|
|
45
74
|
response_received = false
|
|
@@ -63,16 +92,15 @@ module LlmCostTracker
|
|
|
63
92
|
raise
|
|
64
93
|
end
|
|
65
94
|
|
|
66
|
-
def inject_stream_usage_flag(request_env, parser, request_url)
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
return body_string unless parser&.auto_enable_stream_usage?(request_url)
|
|
95
|
+
def inject_stream_usage_flag(request_env, parser, request_url, request_parsed)
|
|
96
|
+
return nil unless auto_enable_stream_usage?
|
|
97
|
+
return nil unless parser&.auto_enable_stream_usage?(request_url)
|
|
70
98
|
|
|
71
|
-
|
|
72
|
-
return
|
|
99
|
+
stream_options = request_parsed["stream_options"]
|
|
100
|
+
return nil if stream_options.is_a?(Hash) && stream_options.key?("include_usage")
|
|
73
101
|
|
|
74
|
-
|
|
75
|
-
new_body =
|
|
102
|
+
request_parsed["stream_options"] = (stream_options || {}).merge("include_usage" => true)
|
|
103
|
+
new_body = request_parsed.to_json
|
|
76
104
|
request_env.body = new_body
|
|
77
105
|
new_body
|
|
78
106
|
end
|
|
@@ -80,9 +108,9 @@ module LlmCostTracker
|
|
|
80
108
|
def process_interrupted_stream(parser:, request_url:, request_body:, latency_ms:,
|
|
81
109
|
context_tags:, metadata:, error:)
|
|
82
110
|
request = parser.safe_json_parse(request_body)
|
|
83
|
-
|
|
111
|
+
event = Event.build(
|
|
84
112
|
provider: parser.provider_for(request_url),
|
|
85
|
-
model: request["model"] ||
|
|
113
|
+
model: request["model"] || Event::UNKNOWN_MODEL,
|
|
86
114
|
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
87
115
|
stream: true,
|
|
88
116
|
usage_source: :unknown
|
|
@@ -92,7 +120,7 @@ module LlmCostTracker
|
|
|
92
120
|
stream_interrupted_error: "#{error.class}: #{error.message}"
|
|
93
121
|
)
|
|
94
122
|
Tracker.record(
|
|
95
|
-
|
|
123
|
+
event: event,
|
|
96
124
|
latency_ms: latency_ms,
|
|
97
125
|
metadata: merged_metadata,
|
|
98
126
|
context_tags: context_tags
|
|
@@ -125,7 +153,7 @@ module LlmCostTracker
|
|
|
125
153
|
return unless parsed
|
|
126
154
|
|
|
127
155
|
Tracker.record(
|
|
128
|
-
|
|
156
|
+
event: parsed,
|
|
129
157
|
latency_ms: latency_ms,
|
|
130
158
|
metadata: metadata,
|
|
131
159
|
context_tags: context_tags
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../providers/anthropic/tier_classification"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Parsers
|
|
7
8
|
class Anthropic < Base
|
|
8
9
|
HOSTS = %w[api.anthropic.com].freeze
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
class << self
|
|
12
|
+
def match?(url)
|
|
13
|
+
match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
|
|
14
|
+
end
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
def provider_names
|
|
17
|
+
%w[anthropic]
|
|
18
|
+
end
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def parse(request_body:, response_status:, response_body:, **)
|
|
@@ -25,10 +28,10 @@ module LlmCostTracker
|
|
|
25
28
|
request = safe_json_parse(request_body)
|
|
26
29
|
cache_read = usage["cache_read_input_tokens"].to_i
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
Event.build(
|
|
29
32
|
provider: "anthropic",
|
|
30
33
|
provider_response_id: response["id"],
|
|
31
|
-
pricing_mode: pricing_mode(request: request,
|
|
34
|
+
pricing_mode: pricing_mode(request: request, usage: usage),
|
|
32
35
|
model: response["model"] || request["model"],
|
|
33
36
|
token_usage: token_usage(usage: usage, cache_read: cache_read),
|
|
34
37
|
usage_source: :response,
|
|
@@ -49,14 +52,14 @@ module LlmCostTracker
|
|
|
49
52
|
model: model,
|
|
50
53
|
usage: usage,
|
|
51
54
|
response_id: response_id,
|
|
52
|
-
pricing_mode: pricing_mode(request: request,
|
|
55
|
+
pricing_mode: pricing_mode(request: request, usage: usage)
|
|
53
56
|
)
|
|
54
57
|
else
|
|
55
58
|
build_unknown_stream_usage(
|
|
56
59
|
provider: "anthropic",
|
|
57
60
|
model: model,
|
|
58
61
|
provider_response_id: response_id,
|
|
59
|
-
pricing_mode: pricing_mode(request: request,
|
|
62
|
+
pricing_mode: pricing_mode(request: request, usage: usage)
|
|
60
63
|
)
|
|
61
64
|
end
|
|
62
65
|
end
|
|
@@ -65,10 +68,6 @@ module LlmCostTracker
|
|
|
65
68
|
"anthropic"
|
|
66
69
|
end
|
|
67
70
|
|
|
68
|
-
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
69
|
-
STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
|
|
70
|
-
private_constant :DATA_RESIDENCY_GEOS, :STANDARD_EQUIVALENT_SERVICE_TIERS
|
|
71
|
-
|
|
72
71
|
private
|
|
73
72
|
|
|
74
73
|
def stream_usage(events)
|
|
@@ -89,7 +88,7 @@ module LlmCostTracker
|
|
|
89
88
|
def build_stream_result(model:, usage:, response_id:, pricing_mode:)
|
|
90
89
|
cache_read = usage["cache_read_input_tokens"].to_i
|
|
91
90
|
|
|
92
|
-
|
|
91
|
+
Event.build(
|
|
93
92
|
provider: "anthropic",
|
|
94
93
|
provider_response_id: response_id,
|
|
95
94
|
pricing_mode: pricing_mode,
|
|
@@ -171,29 +170,23 @@ module LlmCostTracker
|
|
|
171
170
|
Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
|
|
172
171
|
end
|
|
173
172
|
|
|
174
|
-
def pricing_mode(request:,
|
|
173
|
+
def pricing_mode(request:, usage:)
|
|
175
174
|
modes = []
|
|
176
|
-
speed = usage&.fetch("speed", nil) ||
|
|
177
|
-
service_tier = usage&.fetch("service_tier", nil) ||
|
|
178
|
-
|
|
179
|
-
request["service_tier"]
|
|
180
|
-
service_tier = nil if STANDARD_EQUIVALENT_SERVICE_TIERS.include?(service_tier.to_s)
|
|
175
|
+
speed = usage&.fetch("speed", nil) || request["speed"]
|
|
176
|
+
service_tier = usage&.fetch("service_tier", nil) || request["service_tier"]
|
|
177
|
+
service_tier = nil if Providers::Anthropic::TierClassification.standard_equivalent_tier?(service_tier)
|
|
181
178
|
|
|
182
179
|
modes << Pricing.normalize_mode(speed)
|
|
183
180
|
modes << Pricing.normalize_mode(service_tier)
|
|
184
|
-
geo = inference_geo(request: request,
|
|
185
|
-
modes << "data_residency" if
|
|
181
|
+
geo = inference_geo(request: request, usage: usage).downcase
|
|
182
|
+
modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
|
|
186
183
|
|
|
187
184
|
modes = modes.compact.uniq
|
|
188
185
|
modes.empty? ? nil : modes.join("_")
|
|
189
186
|
end
|
|
190
187
|
|
|
191
|
-
def inference_geo(request:,
|
|
192
|
-
(
|
|
193
|
-
usage&.fetch("inference_geo", nil) ||
|
|
194
|
-
response&.fetch("inference_geo", nil) ||
|
|
195
|
-
request["inference_geo"]
|
|
196
|
-
).to_s
|
|
188
|
+
def inference_geo(request:, usage:)
|
|
189
|
+
(usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
|
|
197
190
|
end
|
|
198
191
|
end
|
|
199
192
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "openai_usage"
|
|
5
|
+
require_relative "../providers/azure/hosts"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Parsers
|
|
9
|
+
class Azure < Base
|
|
10
|
+
include OpenaiUsage
|
|
11
|
+
|
|
12
|
+
TRACKED_ENDPOINTS = %w[
|
|
13
|
+
chat/completions completions embeddings moderations responses
|
|
14
|
+
audio/transcriptions audio/translations audio/speech
|
|
15
|
+
images/generations images/edits images/variations
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
PATH_PATTERN = %r{\A/openai/(?:deployments/[^/]+|v1)/(?:#{TRACKED_ENDPOINTS.join('|')})\z}
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def match?(url)
|
|
22
|
+
uri_matches?(url) do |uri|
|
|
23
|
+
LlmCostTracker::Providers::Azure::Hosts.openai?(uri.host) && uri.path.to_s.match?(PATH_PATTERN)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def provider_names
|
|
28
|
+
%w[azure_openai]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def provider_for(_request_url)
|
|
33
|
+
"azure_openai"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model_for(request_url, request_parsed)
|
|
37
|
+
body_model = super
|
|
38
|
+
return body_model if body_model
|
|
39
|
+
|
|
40
|
+
uri = parsed_uri(request_url)
|
|
41
|
+
match = uri&.path&.match(%r{/openai/deployments/([^/]+)/})
|
|
42
|
+
match && match[1]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -6,51 +6,7 @@ require "uri"
|
|
|
6
6
|
|
|
7
7
|
module LlmCostTracker
|
|
8
8
|
module Parsers
|
|
9
|
-
|
|
10
|
-
def parse(**)
|
|
11
|
-
raise NotImplementedError
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def provider_names
|
|
15
|
-
[]
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def match?(url)
|
|
19
|
-
raise NotImplementedError
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def streaming_request?(_request_url, request_body)
|
|
23
|
-
return false if request_body.blank?
|
|
24
|
-
|
|
25
|
-
body = request_body.to_s
|
|
26
|
-
|
|
27
|
-
request = safe_json_parse(body)
|
|
28
|
-
request.is_a?(Hash) && request["stream"] == true
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def parse_stream(**)
|
|
32
|
-
nil
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def auto_enable_stream_usage?(_request_url)
|
|
36
|
-
false
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def safe_json_parse(body)
|
|
40
|
-
return {} if body.blank?
|
|
41
|
-
|
|
42
|
-
JSON.parse(body)
|
|
43
|
-
rescue JSON::ParserError
|
|
44
|
-
{}
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
def uri_matches?(url)
|
|
50
|
-
uri = parsed_uri(url)
|
|
51
|
-
uri ? yield(uri) : false
|
|
52
|
-
end
|
|
53
|
-
|
|
9
|
+
module UrlMatchers
|
|
54
10
|
def match_uri?(url, hosts: nil, exact_paths: nil, path_includes: nil, path_suffixes: nil, path_pattern: nil)
|
|
55
11
|
uri_matches?(url) do |uri|
|
|
56
12
|
host_match = hosts.nil? || hosts.include?(uri.host.to_s.downcase)
|
|
@@ -70,6 +26,11 @@ module LlmCostTracker
|
|
|
70
26
|
end
|
|
71
27
|
end
|
|
72
28
|
|
|
29
|
+
def uri_matches?(url)
|
|
30
|
+
uri = parsed_uri(url)
|
|
31
|
+
uri ? yield(uri) : false
|
|
32
|
+
end
|
|
33
|
+
|
|
73
34
|
def parsed_uri(url)
|
|
74
35
|
URI.parse(url.to_s)
|
|
75
36
|
rescue URI::InvalidURIError
|
|
@@ -88,6 +49,51 @@ module LlmCostTracker
|
|
|
88
49
|
|
|
89
50
|
matches
|
|
90
51
|
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Base
|
|
55
|
+
extend UrlMatchers
|
|
56
|
+
include UrlMatchers
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
def match?(_url)
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def provider_names
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse(**)
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def streaming_request?(_request_url, request_parsed)
|
|
73
|
+
request_parsed.is_a?(Hash) && request_parsed["stream"] == true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def model_for(_request_url, request_parsed)
|
|
77
|
+
request_parsed["model"] if request_parsed.is_a?(Hash)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parse_stream(**)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def auto_enable_stream_usage?(_request_url)
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def safe_json_parse(body)
|
|
89
|
+
return {} if body.blank?
|
|
90
|
+
|
|
91
|
+
JSON.parse(body)
|
|
92
|
+
rescue JSON::ParserError
|
|
93
|
+
{}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
91
97
|
|
|
92
98
|
def each_event_data(events, reverse: false)
|
|
93
99
|
enumerator = reverse ? events.reverse_each : events.each
|
|
@@ -109,11 +115,11 @@ module LlmCostTracker
|
|
|
109
115
|
|
|
110
116
|
def build_unknown_stream_usage(provider:, model:, provider_response_id:, pricing_mode: nil,
|
|
111
117
|
service_line_items: nil)
|
|
112
|
-
|
|
118
|
+
Event.build(
|
|
113
119
|
provider: provider,
|
|
114
120
|
provider_response_id: provider_response_id,
|
|
115
121
|
pricing_mode: pricing_mode,
|
|
116
|
-
model: model ||
|
|
122
|
+
model: model || Event::UNKNOWN_MODEL,
|
|
117
123
|
token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
|
|
118
124
|
stream: true,
|
|
119
125
|
usage_source: :unknown,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../billing/line_item"
|
|
4
4
|
require_relative "base"
|
|
5
|
+
require_relative "../providers/gemini/model_families"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
7
8
|
module Parsers
|
|
@@ -9,17 +10,18 @@ module LlmCostTracker
|
|
|
9
10
|
HOSTS = %w[generativelanguage.googleapis.com].freeze
|
|
10
11
|
TRACKED_PATH_PATTERN = %r{/models/[^/:]+:(?:generateContent|streamGenerateContent)\z}
|
|
11
12
|
STREAM_PATH_PATTERN = /:streamGenerateContent\z/
|
|
12
|
-
PER_QUERY_GROUNDING_MODEL_PATTERN = /\bgemini-(?:[3-9]|[1-9]\d)\b/i
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
class << self
|
|
15
|
+
def match?(url)
|
|
16
|
+
match_uri?(url, hosts: HOSTS, path_pattern: TRACKED_PATH_PATTERN)
|
|
17
|
+
end
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
def provider_names
|
|
20
|
+
%w[gemini]
|
|
21
|
+
end
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
def streaming_request?(request_url,
|
|
24
|
+
def streaming_request?(request_url, request_parsed)
|
|
23
25
|
return true if match_uri?(request_url, path_pattern: STREAM_PATH_PATTERN)
|
|
24
26
|
|
|
25
27
|
super
|
|
@@ -34,13 +36,13 @@ module LlmCostTracker
|
|
|
34
36
|
|
|
35
37
|
request = safe_json_parse(request_body)
|
|
36
38
|
model = extract_model_from_url(request_url)
|
|
37
|
-
|
|
39
|
+
build_event(
|
|
38
40
|
request_url: request_url,
|
|
39
41
|
usage: usage,
|
|
40
42
|
usage_source: :response,
|
|
41
43
|
provider_response_id: response["responseId"],
|
|
42
44
|
pricing_mode: pricing_mode(request: request, response_headers: response_headers),
|
|
43
|
-
service_line_items:
|
|
45
|
+
service_line_items: grounding_line_items(grounding_request_count(response["candidates"]), model: model)
|
|
44
46
|
)
|
|
45
47
|
end
|
|
46
48
|
|
|
@@ -55,7 +57,7 @@ module LlmCostTracker
|
|
|
55
57
|
service_line_items = grounding_line_items_for_stream(events, model: model)
|
|
56
58
|
|
|
57
59
|
if usage
|
|
58
|
-
|
|
60
|
+
build_event(
|
|
59
61
|
request_url: request_url,
|
|
60
62
|
usage: usage,
|
|
61
63
|
stream: true,
|
|
@@ -75,20 +77,24 @@ module LlmCostTracker
|
|
|
75
77
|
end
|
|
76
78
|
end
|
|
77
79
|
|
|
80
|
+
def model_for(request_url, _request_parsed)
|
|
81
|
+
extract_model_from_url(request_url)
|
|
82
|
+
end
|
|
83
|
+
|
|
78
84
|
def provider_for(_request_url)
|
|
79
85
|
"gemini"
|
|
80
86
|
end
|
|
81
87
|
|
|
82
88
|
private
|
|
83
89
|
|
|
84
|
-
def
|
|
85
|
-
|
|
90
|
+
def build_event(request_url:, usage:, usage_source:, stream: false, provider_response_id: nil,
|
|
91
|
+
pricing_mode: nil, service_line_items: nil)
|
|
86
92
|
cache_read = usage["cachedContentTokenCount"].to_i
|
|
87
93
|
tool_use_prompt = usage["toolUsePromptTokenCount"].to_i
|
|
88
94
|
audio_input = audio_input_tokens(usage)
|
|
89
95
|
audio_output = audio_output_tokens(usage)
|
|
90
96
|
|
|
91
|
-
|
|
97
|
+
Event.build(
|
|
92
98
|
provider: "gemini",
|
|
93
99
|
model: extract_model_from_url(request_url),
|
|
94
100
|
pricing_mode: pricing_mode,
|
|
@@ -184,10 +190,6 @@ module LlmCostTracker
|
|
|
184
190
|
headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
|
|
185
191
|
end
|
|
186
192
|
|
|
187
|
-
def grounding_line_items_for_response(response, model:)
|
|
188
|
-
grounding_line_items(grounding_request_count(response["candidates"]), model: model)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
193
|
def grounding_line_items_for_stream(events, model:)
|
|
192
194
|
quantity = find_event_value(events, reverse: true) do |data|
|
|
193
195
|
count = grounding_request_count(data["candidates"])
|
|
@@ -223,11 +225,7 @@ module LlmCostTracker
|
|
|
223
225
|
end
|
|
224
226
|
|
|
225
227
|
def grounding_billed_quantity(query_count, model:)
|
|
226
|
-
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def per_query_billing?(model)
|
|
230
|
-
model.to_s.match?(PER_QUERY_GROUNDING_MODEL_PATTERN)
|
|
228
|
+
LlmCostTracker::Providers::Gemini::ModelFamilies.per_query_grounding?(model) ? query_count : 1
|
|
231
229
|
end
|
|
232
230
|
end
|
|
233
231
|
end
|
|
@@ -2,25 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
4
|
require_relative "openai_usage"
|
|
5
|
+
require_relative "../providers/openai/hosts"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
7
8
|
module Parsers
|
|
8
9
|
class Openai < Base
|
|
9
10
|
include OpenaiUsage
|
|
10
11
|
|
|
11
|
-
HOSTS = %w[
|
|
12
|
-
api.openai.com
|
|
13
|
-
us.api.openai.com
|
|
14
|
-
eu.api.openai.com
|
|
15
|
-
au.api.openai.com
|
|
16
|
-
ca.api.openai.com
|
|
17
|
-
jp.api.openai.com
|
|
18
|
-
in.api.openai.com
|
|
19
|
-
sg.api.openai.com
|
|
20
|
-
kr.api.openai.com
|
|
21
|
-
gb.api.openai.com
|
|
22
|
-
ae.api.openai.com
|
|
23
|
-
].freeze
|
|
24
12
|
TRACKED_PATHS = %w[
|
|
25
13
|
/v1/chat/completions
|
|
26
14
|
/v1/completions
|
|
@@ -35,34 +23,14 @@ module LlmCostTracker
|
|
|
35
23
|
/v1/moderations
|
|
36
24
|
].freeze
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def provider_names
|
|
43
|
-
%w[openai]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def parse(request_url:, request_body:, response_status:, response_body:, **)
|
|
47
|
-
parse_openai_usage(
|
|
48
|
-
request_url: request_url,
|
|
49
|
-
request_body: request_body,
|
|
50
|
-
response_status: response_status,
|
|
51
|
-
response_body: response_body
|
|
52
|
-
)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
|
|
56
|
-
parse_openai_stream_usage(
|
|
57
|
-
request_url: request_url,
|
|
58
|
-
request_body: request_body,
|
|
59
|
-
response_status: response_status,
|
|
60
|
-
events: events
|
|
61
|
-
)
|
|
62
|
-
end
|
|
26
|
+
class << self
|
|
27
|
+
def match?(url)
|
|
28
|
+
match_uri?(url, hosts: Providers::Openai::Hosts::API_HOSTS, exact_paths: TRACKED_PATHS)
|
|
29
|
+
end
|
|
63
30
|
|
|
64
|
-
|
|
65
|
-
|
|
31
|
+
def provider_names
|
|
32
|
+
%w[openai]
|
|
33
|
+
end
|
|
66
34
|
end
|
|
67
35
|
|
|
68
36
|
def provider_for(_request_url)
|
|
@@ -10,58 +10,41 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
class << self
|
|
14
|
+
def match?(url)
|
|
15
|
+
match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
|
|
16
|
+
end
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
def provider_names
|
|
19
|
+
providers = LlmCostTracker.configuration.openai_compatible_providers
|
|
20
|
+
cached = @provider_names
|
|
21
|
+
return cached if cached && @provider_names_providers.equal?(providers)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
names = [
|
|
24
|
+
"openai_compatible",
|
|
25
|
+
*providers.each_value.map { |provider| provider.to_s.downcase }
|
|
26
|
+
].uniq.freeze
|
|
27
|
+
return names unless providers.frozen?
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
@provider_names_providers = providers
|
|
30
|
+
@provider_names = names
|
|
31
|
+
end
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
request_body: request_body,
|
|
36
|
-
response_status: response_status,
|
|
37
|
-
response_body: response_body
|
|
38
|
-
)
|
|
39
|
-
end
|
|
33
|
+
def provider_for(request_url)
|
|
34
|
+
provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
|
|
35
|
+
end
|
|
40
36
|
|
|
41
|
-
|
|
42
|
-
parse_openai_stream_usage(
|
|
43
|
-
request_url: request_url,
|
|
44
|
-
request_body: request_body,
|
|
45
|
-
response_status: response_status,
|
|
46
|
-
events: events
|
|
47
|
-
)
|
|
48
|
-
end
|
|
37
|
+
private
|
|
49
38
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
end
|
|
39
|
+
def provider_for_uri(uri)
|
|
40
|
+
return nil unless uri
|
|
53
41
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
provider_for_uri(uri) || "openai_compatible"
|
|
42
|
+
LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
43
|
+
end
|
|
57
44
|
end
|
|
58
45
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def provider_for_uri(uri)
|
|
62
|
-
return nil unless uri
|
|
63
|
-
|
|
64
|
-
LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
46
|
+
def provider_for(request_url)
|
|
47
|
+
self.class.provider_for(request_url)
|
|
65
48
|
end
|
|
66
49
|
end
|
|
67
50
|
end
|