llm_cost_tracker 0.9.0 → 0.11.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 +55 -0
- data/README.md +6 -2
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +15 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +39 -20
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +0 -3
- 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/reconciliation_controller.rb +13 -19
- data/app/controllers/llm_cost_tracker/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +16 -4
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +9 -4
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +104 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +19 -5
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +80 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +119 -120
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +119 -158
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +109 -108
- 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/reconciliation/index.html.erb +49 -58
- 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 +83 -102
- data/config/routes.rb +1 -0
- 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 +29 -8
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +34 -42
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +2 -6
- data/lib/llm_cost_tracker/configuration.rb +30 -44
- 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 +80 -25
- 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 +27 -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 +36 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +27 -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 +4 -25
- 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 +46 -68
- data/lib/llm_cost_tracker/integrations/base.rb +14 -11
- data/lib/llm_cost_tracker/integrations/openai.rb +104 -131
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +27 -73
- 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/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/middleware/faraday.rb +46 -17
- data/lib/llm_cost_tracker/parsers/anthropic.rb +35 -59
- 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 +23 -27
- data/lib/llm_cost_tracker/parsers/openai.rb +8 -40
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -49
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +19 -23
- data/lib/llm_cost_tracker/parsers.rb +29 -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 +5 -2
- data/lib/llm_cost_tracker/pricing/lookup.rb +37 -2
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/registry.rb +0 -7
- data/lib/llm_cost_tracker/pricing/service_charges.rb +6 -10
- 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 +71 -43
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- 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/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +3 -5
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- 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 +3 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +10 -33
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +40 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +7 -31
- data/lib/llm_cost_tracker/report/formatter.rb +32 -19
- 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 +31 -12
- 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/dashboard_setup_state.rb +0 -109
- data/lib/llm_cost_tracker/ingestion/inline.rb +0 -22
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -126
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../providers/anthropic/server_tools"
|
|
5
|
+
require_relative "../providers/anthropic/tier_classification"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
6
8
|
module Parsers
|
|
7
9
|
class Anthropic < Base
|
|
8
10
|
HOSTS = %w[api.anthropic.com].freeze
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
class << self
|
|
13
|
+
def match?(url)
|
|
14
|
+
match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
|
|
15
|
+
end
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
def provider_names
|
|
18
|
+
%w[anthropic]
|
|
19
|
+
end
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def parse(request_body:, response_status:, response_body:, **)
|
|
@@ -25,10 +29,10 @@ module LlmCostTracker
|
|
|
25
29
|
request = safe_json_parse(request_body)
|
|
26
30
|
cache_read = usage["cache_read_input_tokens"].to_i
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
Event.build(
|
|
29
33
|
provider: "anthropic",
|
|
30
34
|
provider_response_id: response["id"],
|
|
31
|
-
pricing_mode: pricing_mode(request: request,
|
|
35
|
+
pricing_mode: pricing_mode(request: request, usage: usage),
|
|
32
36
|
model: response["model"] || request["model"],
|
|
33
37
|
token_usage: token_usage(usage: usage, cache_read: cache_read),
|
|
34
38
|
usage_source: :response,
|
|
@@ -49,14 +53,14 @@ module LlmCostTracker
|
|
|
49
53
|
model: model,
|
|
50
54
|
usage: usage,
|
|
51
55
|
response_id: response_id,
|
|
52
|
-
pricing_mode: pricing_mode(request: request,
|
|
56
|
+
pricing_mode: pricing_mode(request: request, usage: usage)
|
|
53
57
|
)
|
|
54
58
|
else
|
|
55
59
|
build_unknown_stream_usage(
|
|
56
60
|
provider: "anthropic",
|
|
57
61
|
model: model,
|
|
58
62
|
provider_response_id: response_id,
|
|
59
|
-
pricing_mode: pricing_mode(request: request,
|
|
63
|
+
pricing_mode: pricing_mode(request: request, usage: usage)
|
|
60
64
|
)
|
|
61
65
|
end
|
|
62
66
|
end
|
|
@@ -65,10 +69,6 @@ module LlmCostTracker
|
|
|
65
69
|
"anthropic"
|
|
66
70
|
end
|
|
67
71
|
|
|
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
72
|
private
|
|
73
73
|
|
|
74
74
|
def stream_usage(events)
|
|
@@ -89,7 +89,7 @@ module LlmCostTracker
|
|
|
89
89
|
def build_stream_result(model:, usage:, response_id:, pricing_mode:)
|
|
90
90
|
cache_read = usage["cache_read_input_tokens"].to_i
|
|
91
91
|
|
|
92
|
-
|
|
92
|
+
Event.build(
|
|
93
93
|
provider: "anthropic",
|
|
94
94
|
provider_response_id: response_id,
|
|
95
95
|
pricing_mode: pricing_mode,
|
|
@@ -105,36 +105,18 @@ module LlmCostTracker
|
|
|
105
105
|
server_tool_use = usage["server_tool_use"]
|
|
106
106
|
return [] unless server_tool_use.is_a?(Hash)
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
quantity: server_tool_use["web_search_requests"],
|
|
112
|
-
provider_field: "usage.server_tool_use.web_search_requests"
|
|
113
|
-
),
|
|
114
|
-
service_line_item(
|
|
115
|
-
component_key: :web_fetch_request,
|
|
116
|
-
quantity: server_tool_use["web_fetch_requests"],
|
|
117
|
-
provider_field: "usage.server_tool_use.web_fetch_requests"
|
|
118
|
-
),
|
|
119
|
-
service_line_item(
|
|
120
|
-
component_key: :code_execution_request,
|
|
121
|
-
quantity: server_tool_use["code_execution_requests"],
|
|
122
|
-
provider_field: "usage.server_tool_use.code_execution_requests"
|
|
123
|
-
)
|
|
124
|
-
].compact
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def service_line_item(component_key:, quantity:, provider_field:)
|
|
128
|
-
quantity = quantity.to_i
|
|
129
|
-
return if quantity.zero?
|
|
108
|
+
Providers::Anthropic::ServerTools::LINE_ITEMS.filter_map do |component_key, count_key|
|
|
109
|
+
quantity = server_tool_use[count_key.to_s].to_i
|
|
110
|
+
next if quantity.zero?
|
|
130
111
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
112
|
+
Billing::LineItem.build(
|
|
113
|
+
component_key: component_key,
|
|
114
|
+
quantity: quantity,
|
|
115
|
+
cost_status: Billing::CostStatus::UNKNOWN,
|
|
116
|
+
pricing_basis: :provider_usage,
|
|
117
|
+
provider_field: "usage.server_tool_use.#{count_key}"
|
|
118
|
+
)
|
|
119
|
+
end
|
|
138
120
|
end
|
|
139
121
|
|
|
140
122
|
def token_usage(usage:, cache_read:)
|
|
@@ -171,29 +153,23 @@ module LlmCostTracker
|
|
|
171
153
|
Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
|
|
172
154
|
end
|
|
173
155
|
|
|
174
|
-
def pricing_mode(request:,
|
|
156
|
+
def pricing_mode(request:, usage:)
|
|
175
157
|
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)
|
|
158
|
+
speed = usage&.fetch("speed", nil) || request["speed"]
|
|
159
|
+
service_tier = usage&.fetch("service_tier", nil) || request["service_tier"]
|
|
160
|
+
service_tier = nil if Providers::Anthropic::TierClassification.standard_equivalent_tier?(service_tier)
|
|
181
161
|
|
|
182
|
-
modes << Pricing.
|
|
183
|
-
modes << Pricing.
|
|
184
|
-
geo = inference_geo(request: request,
|
|
185
|
-
modes << "data_residency" if
|
|
162
|
+
modes << Pricing::Mode.normalize(speed)
|
|
163
|
+
modes << Pricing::Mode.normalize(service_tier)
|
|
164
|
+
geo = inference_geo(request: request, usage: usage).downcase
|
|
165
|
+
modes << "data_residency" if Providers::Anthropic::TierClassification.data_residency_geo?(geo)
|
|
186
166
|
|
|
187
167
|
modes = modes.compact.uniq
|
|
188
168
|
modes.empty? ? nil : modes.join("_")
|
|
189
169
|
end
|
|
190
170
|
|
|
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
|
|
171
|
+
def inference_geo(request:, usage:)
|
|
172
|
+
(usage&.fetch("inference_geo", nil) || request["inference_geo"]).to_s
|
|
197
173
|
end
|
|
198
174
|
end
|
|
199
175
|
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,
|
|
@@ -146,9 +152,7 @@ module LlmCostTracker
|
|
|
146
152
|
|
|
147
153
|
def modality_tokens(details, modality)
|
|
148
154
|
Array(details).sum do |detail|
|
|
149
|
-
next 0 unless detail.is_a?(Hash)
|
|
150
|
-
|
|
151
|
-
next 0 unless detail["modality"] == modality
|
|
155
|
+
next 0 unless detail.is_a?(Hash) && detail["modality"] == modality
|
|
152
156
|
|
|
153
157
|
(detail["tokenCount"] || detail["token_count"]).to_i
|
|
154
158
|
end
|
|
@@ -168,10 +172,10 @@ module LlmCostTracker
|
|
|
168
172
|
|
|
169
173
|
def pricing_mode(request:, response_headers:)
|
|
170
174
|
response_tier = response_header(response_headers, "x-gemini-service-tier")
|
|
171
|
-
response_mode = Pricing.
|
|
175
|
+
response_mode = Pricing::Mode.normalize(response_tier)
|
|
172
176
|
return response_mode if response_mode
|
|
173
177
|
|
|
174
|
-
request_mode = Pricing.
|
|
178
|
+
request_mode = Pricing::Mode.normalize(
|
|
175
179
|
request["service_tier"] ||
|
|
176
180
|
request["serviceTier"] ||
|
|
177
181
|
request.dig("config", "service_tier") ||
|
|
@@ -184,10 +188,6 @@ module LlmCostTracker
|
|
|
184
188
|
headers.to_h.find { |key, _value| key.to_s.downcase == name }&.last
|
|
185
189
|
end
|
|
186
190
|
|
|
187
|
-
def grounding_line_items_for_response(response, model:)
|
|
188
|
-
grounding_line_items(grounding_request_count(response["candidates"]), model: model)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
191
|
def grounding_line_items_for_stream(events, model:)
|
|
192
192
|
quantity = find_event_value(events, reverse: true) do |data|
|
|
193
193
|
count = grounding_request_count(data["candidates"])
|
|
@@ -223,11 +223,7 @@ module LlmCostTracker
|
|
|
223
223
|
end
|
|
224
224
|
|
|
225
225
|
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)
|
|
226
|
+
LlmCostTracker::Providers::Gemini::ModelFamilies.per_query_grounding?(model) ? query_count : 1
|
|
231
227
|
end
|
|
232
228
|
end
|
|
233
229
|
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,35 @@ module LlmCostTracker
|
|
|
10
10
|
|
|
11
11
|
TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
|
|
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
|
|
49
|
-
|
|
50
|
-
def auto_enable_stream_usage?(request_url)
|
|
51
|
-
openai_chat_completions_url?(request_url)
|
|
13
|
+
class << self
|
|
14
|
+
def match?(url)
|
|
15
|
+
match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
|
|
16
|
+
end
|
|
17
|
+
|
|
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)
|
|
22
|
+
|
|
23
|
+
names = [
|
|
24
|
+
"openai_compatible",
|
|
25
|
+
*providers.each_value.map { |provider| provider.to_s.downcase }
|
|
26
|
+
].uniq.freeze
|
|
27
|
+
return names unless providers.frozen?
|
|
28
|
+
|
|
29
|
+
@provider_names_providers = providers
|
|
30
|
+
@provider_names = names
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def provider_for_uri(uri)
|
|
34
|
+
return nil unless uri
|
|
35
|
+
|
|
36
|
+
LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
37
|
+
end
|
|
52
38
|
end
|
|
53
39
|
|
|
54
40
|
def provider_for(request_url)
|
|
55
|
-
|
|
56
|
-
provider_for_uri(uri) || "openai_compatible"
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
private
|
|
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
|
|
41
|
+
self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
|
|
65
42
|
end
|
|
66
43
|
end
|
|
67
44
|
end
|