llm_cost_tracker 0.8.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 +136 -0
- data/README.md +14 -6
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +21 -11
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +11 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +29 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +110 -0
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +26 -41
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +92 -53
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +21 -25
- data/lib/llm_cost_tracker/billing/line_item.rb +16 -50
- data/lib/llm_cost_tracker/budget.rb +31 -7
- data/lib/llm_cost_tracker/capture/stream_collector.rb +113 -34
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +72 -17
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +1 -1
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +30 -4
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- 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/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -14
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +3 -2
- data/lib/llm_cost_tracker/event.rb +48 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/async_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_async_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +60 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +35 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +35 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/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 +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- 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 -25
- data/lib/llm_cost_tracker/ingestion/pool.rb +44 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +28 -34
- data/lib/llm_cost_tracker/ingestion.rb +48 -11
- data/lib/llm_cost_tracker/integrations/anthropic.rb +31 -26
- data/lib/llm_cost_tracker/integrations/base.rb +35 -15
- data/lib/llm_cost_tracker/integrations/openai.rb +345 -84
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +111 -14
- data/lib/llm_cost_tracker/integrations.rb +33 -14
- data/lib/llm_cost_tracker/ledger/period/totals.rb +25 -7
- data/lib/llm_cost_tracker/ledger/rollups.rb +22 -17
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +41 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +16 -6
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +28 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -4
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +57 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +52 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +56 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +28 -13
- data/lib/llm_cost_tracker/ledger/store.rb +34 -31
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -2
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/logging.rb +0 -4
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +120 -33
- data/lib/llm_cost_tracker/parsers/anthropic.rb +36 -28
- data/lib/llm_cost_tracker/parsers/azure.rb +46 -0
- data/lib/llm_cost_tracker/parsers/base.rb +53 -43
- data/lib/llm_cost_tracker/parsers/gemini.rb +24 -22
- data/lib/llm_cost_tracker/parsers/openai.rb +20 -38
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +26 -39
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +81 -13
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +126 -59
- data/lib/llm_cost_tracker/parsers.rb +31 -4
- data/lib/llm_cost_tracker/prices.json +572 -493
- data/lib/llm_cost_tracker/pricing/backfill.rb +140 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +7 -40
- 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 +73 -5
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -8
- data/lib/llm_cost_tracker/pricing/service_charges.rb +14 -12
- 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 +62 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +4 -10
- data/lib/llm_cost_tracker/pricing/unknown.rb +5 -2
- data/lib/llm_cost_tracker/pricing.rb +117 -44
- 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 +8 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +409 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +44 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +254 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +172 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +31 -6
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +73 -21
- data/lib/llm_cost_tracker/token_usage.rb +14 -2
- data/lib/llm_cost_tracker/tracker.rb +41 -55
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +19 -14
- data/lib/tasks/llm_cost_tracker.rake +41 -4
- metadata +49 -3
- data/lib/llm_cost_tracker/usage_capture.rb +0 -58
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../billing/line_item"
|
|
4
|
+
require_relative "../providers/openai/model_families"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Parsers
|
|
@@ -8,15 +9,52 @@ module LlmCostTracker
|
|
|
8
9
|
RESPONSE_OUTPUT_COMPONENTS = {
|
|
9
10
|
"web_search_call" => :web_search_request,
|
|
10
11
|
"file_search_call" => :file_search_call,
|
|
11
|
-
"code_interpreter_call" => :container_session
|
|
12
|
+
"code_interpreter_call" => :container_session,
|
|
13
|
+
"mcp_call" => :mcp_call
|
|
12
14
|
}.freeze
|
|
13
15
|
|
|
14
16
|
module_function
|
|
15
17
|
|
|
16
|
-
def line_items_from_output(output_items)
|
|
18
|
+
def line_items_from_output(output_items, request: nil, model: nil)
|
|
17
19
|
deduped = {}
|
|
18
20
|
Array(output_items).each { |item| store_output_item(deduped, item) }
|
|
19
|
-
deduped.values
|
|
21
|
+
deduped.values
|
|
22
|
+
.select { |item| billable?(item) }
|
|
23
|
+
.filter_map { |item| build_line_item(item, request: request, model: model) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def service_line_items_for(response, request: nil, model: nil)
|
|
27
|
+
output_items = Array(response["output"])
|
|
28
|
+
output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
|
|
29
|
+
line_items_from_output(output_items, request: request, model: model)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
|
|
33
|
+
CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
|
|
34
|
+
|
|
35
|
+
def chat_completions_web_search_items(response, model: nil)
|
|
36
|
+
return [] unless response["choices"]
|
|
37
|
+
|
|
38
|
+
provider_field = chat_completions_search_provider_field(response["choices"], model)
|
|
39
|
+
return [] unless provider_field
|
|
40
|
+
|
|
41
|
+
[{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
|
|
42
|
+
"provider_field" => provider_field }]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def chat_completions_search_provider_field(choices, model)
|
|
46
|
+
return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
|
|
47
|
+
return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def chat_completions_used_web_search?(choices)
|
|
53
|
+
Array(choices).any? do |choice|
|
|
54
|
+
Array(choice.dig("message", "annotations")).any? do |annotation|
|
|
55
|
+
annotation.is_a?(Hash) && annotation["type"] == "url_citation"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
20
58
|
end
|
|
21
59
|
|
|
22
60
|
def billable?(item)
|
|
@@ -31,7 +69,7 @@ module LlmCostTracker
|
|
|
31
69
|
end
|
|
32
70
|
|
|
33
71
|
def store_output_item(output_items, item)
|
|
34
|
-
return unless
|
|
72
|
+
return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
|
|
35
73
|
|
|
36
74
|
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
37
75
|
key = if component == :container_session && item["container_id"]
|
|
@@ -42,8 +80,10 @@ module LlmCostTracker
|
|
|
42
80
|
output_items[key] = item
|
|
43
81
|
end
|
|
44
82
|
|
|
45
|
-
def build_line_item(item)
|
|
46
|
-
|
|
83
|
+
def build_line_item(item, request: nil, model: nil)
|
|
84
|
+
return nil unless item.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
component_key = component_key_for(item, request: request, model: model)
|
|
47
87
|
return nil unless component_key
|
|
48
88
|
|
|
49
89
|
provider_item_id = if component_key == :container_session
|
|
@@ -56,12 +96,44 @@ module LlmCostTracker
|
|
|
56
96
|
quantity: 1,
|
|
57
97
|
cost_status: Billing::CostStatus::UNKNOWN,
|
|
58
98
|
pricing_basis: :provider_usage,
|
|
59
|
-
provider_field: "response.output.#{item['type']}",
|
|
99
|
+
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
60
100
|
provider_item_id: provider_item_id,
|
|
61
101
|
details: line_item_details(item)
|
|
62
102
|
)
|
|
63
103
|
end
|
|
64
104
|
|
|
105
|
+
def component_key_for(item, request:, model:)
|
|
106
|
+
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
107
|
+
return component unless component == :web_search_request
|
|
108
|
+
return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
|
|
109
|
+
|
|
110
|
+
reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def web_search_preview_used?(request)
|
|
114
|
+
tools = request && (request[:tools] || request["tools"])
|
|
115
|
+
return false unless tools.respond_to?(:each)
|
|
116
|
+
|
|
117
|
+
tools.any? do |tool|
|
|
118
|
+
type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
|
|
119
|
+
type.to_s.include?("web_search_preview")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def chat_completions_search_model?(model)
|
|
124
|
+
return false unless model
|
|
125
|
+
|
|
126
|
+
name = model.to_s.split("/", 2).last
|
|
127
|
+
LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(name)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def reasoning_model?(model)
|
|
131
|
+
return false unless model
|
|
132
|
+
|
|
133
|
+
name = model.to_s.split("/", 2).last
|
|
134
|
+
LlmCostTracker::Providers::Openai::ModelFamilies.reasoning?(name)
|
|
135
|
+
end
|
|
136
|
+
|
|
65
137
|
def line_item_details(item)
|
|
66
138
|
{
|
|
67
139
|
"status" => item["status"],
|
|
@@ -70,17 +142,13 @@ module LlmCostTracker
|
|
|
70
142
|
}.compact
|
|
71
143
|
end
|
|
72
144
|
|
|
73
|
-
def
|
|
74
|
-
line_items_from_output(response["output"])
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def openai_stream_service_line_items(events)
|
|
145
|
+
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
78
146
|
output_items = []
|
|
79
147
|
each_event_data(events) do |data|
|
|
80
148
|
output_items.concat(Array(data.dig("response", "output")))
|
|
81
149
|
output_items << data["item"] if data["item"]
|
|
82
150
|
end
|
|
83
|
-
line_items_from_output(output_items)
|
|
151
|
+
line_items_from_output(output_items, request: request, model: model)
|
|
84
152
|
end
|
|
85
153
|
end
|
|
86
154
|
end
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "openai_service_charges"
|
|
4
|
+
require_relative "../providers/openai/hosts"
|
|
5
|
+
require_relative "../providers/openai/model_families"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
6
8
|
module Parsers
|
|
7
9
|
module OpenaiUsage
|
|
8
10
|
include OpenaiServiceCharges
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
class << self
|
|
13
|
+
def combined_pricing_mode(host:, model:, service_tier:)
|
|
14
|
+
modes = [Pricing.normalize_mode(service_tier)]
|
|
15
|
+
modes << "data_residency" if regional_processing?(host: host, model: model)
|
|
16
|
+
modes = modes.compact.uniq
|
|
17
|
+
modes.empty? ? nil : modes.join("_")
|
|
18
|
+
end
|
|
11
19
|
|
|
12
|
-
|
|
20
|
+
def regional_processing?(host:, model:)
|
|
21
|
+
LlmCostTracker::Providers::Openai::Hosts.data_residency?(host) &&
|
|
22
|
+
LlmCostTracker::Providers::Openai::ModelFamilies.data_residency?(model)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
13
25
|
|
|
14
|
-
def
|
|
26
|
+
def parse(request_url:, request_body:, response_status:, response_body:, **)
|
|
15
27
|
return nil unless response_status == 200
|
|
16
28
|
|
|
17
29
|
response = safe_json_parse(response_body)
|
|
@@ -23,7 +35,7 @@ module LlmCostTracker
|
|
|
23
35
|
|
|
24
36
|
model = response["model"] || request["model"]
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
Event.build(
|
|
27
39
|
provider: provider_for(request_url),
|
|
28
40
|
provider_response_id: response["id"],
|
|
29
41
|
pricing_mode: pricing_mode(
|
|
@@ -32,101 +44,141 @@ module LlmCostTracker
|
|
|
32
44
|
service_tier: response["service_tier"] || request["service_tier"]
|
|
33
45
|
),
|
|
34
46
|
model: model,
|
|
35
|
-
token_usage: token_usage(usage: usage, cache_read: cache_read),
|
|
47
|
+
token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
|
|
36
48
|
usage_source: :response,
|
|
37
|
-
service_line_items:
|
|
49
|
+
service_line_items: service_line_items_for(response, request: request, model: response["model"])
|
|
38
50
|
)
|
|
39
51
|
end
|
|
40
52
|
|
|
41
|
-
def
|
|
53
|
+
def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
|
|
42
54
|
return nil unless response_status == 200
|
|
43
55
|
|
|
44
56
|
request = safe_json_parse(request_body)
|
|
45
|
-
model = find_event_value(events) { |data| data["model"] || data.dig("response", "model") } || request["model"]
|
|
46
57
|
usage = detect_stream_usage(events)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
context = stream_capture_context(events: events, request: request, request_url: request_url)
|
|
59
|
+
|
|
60
|
+
return build_known_stream_usage(usage: usage, **context) if usage
|
|
61
|
+
|
|
62
|
+
warn_missing_stream_usage(request_url: request_url, request: request)
|
|
63
|
+
build_unknown_stream_usage(**context)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def auto_enable_stream_usage?(request_url)
|
|
67
|
+
openai_chat_completions_url?(request_url)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def stream_capture_context(events:, request:, request_url:)
|
|
73
|
+
model = find_event_value(events) do |data|
|
|
74
|
+
data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
|
|
75
|
+
end || request["model"]
|
|
76
|
+
{
|
|
77
|
+
provider: provider_for(request_url),
|
|
50
78
|
model: model,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
cache_read = cache_read_input_tokens(usage)
|
|
57
|
-
UsageCapture.build(
|
|
58
|
-
provider: provider_for(request_url),
|
|
59
|
-
provider_response_id: response_id,
|
|
60
|
-
pricing_mode: pricing_mode,
|
|
61
|
-
model: model,
|
|
62
|
-
token_usage: token_usage(usage: usage, cache_read: cache_read),
|
|
63
|
-
stream: true,
|
|
64
|
-
usage_source: :stream_final,
|
|
65
|
-
service_line_items: service_line_items
|
|
66
|
-
)
|
|
67
|
-
else
|
|
68
|
-
build_unknown_stream_usage(
|
|
69
|
-
provider: provider_for(request_url),
|
|
79
|
+
provider_response_id: find_event_value(events) do |data|
|
|
80
|
+
data["id"] || data.dig("response", "id") || data.dig("chunk", "id")
|
|
81
|
+
end,
|
|
82
|
+
pricing_mode: pricing_mode(
|
|
83
|
+
request_url: request_url,
|
|
70
84
|
model: model,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
service_tier: stream_pricing_mode(events) || request["service_tier"]
|
|
86
|
+
),
|
|
87
|
+
service_line_items: openai_stream_service_line_items(events, request: request, model: model)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_known_stream_usage(usage:, provider:, model:, provider_response_id:, pricing_mode:, service_line_items:)
|
|
92
|
+
cache_read = cache_read_input_tokens(usage)
|
|
93
|
+
Event.build(
|
|
94
|
+
provider: provider,
|
|
95
|
+
provider_response_id: provider_response_id,
|
|
96
|
+
pricing_mode: pricing_mode,
|
|
97
|
+
model: model,
|
|
98
|
+
token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
|
|
99
|
+
stream: true,
|
|
100
|
+
usage_source: :stream_final,
|
|
101
|
+
service_line_items: service_line_items
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def warn_missing_stream_usage(request_url:, request:)
|
|
106
|
+
return unless request.is_a?(Hash) && request["stream"]
|
|
107
|
+
return unless openai_chat_completions_url?(request_url)
|
|
108
|
+
return if request.dig("stream_options", "include_usage")
|
|
109
|
+
|
|
110
|
+
Logging.warn(
|
|
111
|
+
"OpenAI-compatible chat-completions stream finished without a final usage chunk. " \
|
|
112
|
+
"Set `stream_options: { include_usage: true }` in your request body so the gem can " \
|
|
113
|
+
"record token counts. This call was stored with usage_source=unknown."
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def openai_chat_completions_url?(request_url)
|
|
118
|
+
uri = parsed_uri(request_url)
|
|
119
|
+
uri && uri.path.to_s.end_with?("/chat/completions")
|
|
76
120
|
end
|
|
77
121
|
|
|
78
122
|
def detect_stream_usage(events)
|
|
79
123
|
find_event_value(events, reverse: true) do |data|
|
|
80
|
-
usage = data["usage"] || data.dig("response", "usage")
|
|
124
|
+
usage = data["usage"] || data.dig("response", "usage") || data.dig("chunk", "usage")
|
|
81
125
|
usage if usage.is_a?(Hash)
|
|
82
126
|
end
|
|
83
127
|
end
|
|
84
128
|
|
|
85
129
|
def stream_pricing_mode(events)
|
|
86
130
|
find_event_value(events, reverse: true) do |data|
|
|
87
|
-
data["service_tier"] || data.dig("response", "service_tier")
|
|
131
|
+
data["service_tier"] || data.dig("response", "service_tier") || data.dig("chunk", "service_tier")
|
|
88
132
|
end
|
|
89
133
|
end
|
|
90
134
|
|
|
91
135
|
def pricing_mode(request_url:, model:, service_tier:)
|
|
92
|
-
|
|
93
|
-
modes << "data_residency" if openai_regional_processing?(request_url: request_url, model: model)
|
|
94
|
-
modes = modes.compact.uniq
|
|
95
|
-
modes.empty? ? nil : modes.join("_")
|
|
136
|
+
OpenaiUsage.combined_pricing_mode(host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier)
|
|
96
137
|
end
|
|
97
138
|
|
|
98
|
-
def
|
|
99
|
-
uri = parsed_uri(request_url)
|
|
100
|
-
return false unless uri&.host.to_s.downcase.match?(OPENAI_DATA_RESIDENCY_HOST_PATTERN)
|
|
101
|
-
|
|
102
|
-
openai_data_residency_model?(model)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def openai_data_residency_model?(model)
|
|
106
|
-
model.to_s.match?(/\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro))?(?:-\d{4}-\d{2}-\d{2})?\z/)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def token_usage(usage:, cache_read:)
|
|
139
|
+
def token_usage(usage:, cache_read:, model: nil)
|
|
110
140
|
audio_input = audio_input_tokens(usage)
|
|
111
141
|
audio_output = audio_output_tokens(usage)
|
|
142
|
+
image_input = image_input_tokens(usage)
|
|
143
|
+
image_output_details = image_output_tokens(usage)
|
|
144
|
+
text_output_details = text_output_tokens(usage)
|
|
145
|
+
raw_output = (usage["completion_tokens"] || usage["output_tokens"]).to_i
|
|
146
|
+
image_output, regular_output_remainder = split_stream_image_output(
|
|
147
|
+
raw_output: raw_output, image_output_details: image_output_details,
|
|
148
|
+
text_output_details: text_output_details, audio_output: audio_output,
|
|
149
|
+
default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
|
|
150
|
+
)
|
|
112
151
|
|
|
113
152
|
TokenUsage.build(
|
|
114
|
-
input_tokens: regular_input_tokens(
|
|
115
|
-
|
|
153
|
+
input_tokens: regular_input_tokens(
|
|
154
|
+
usage: usage, cache_read: cache_read, audio_input: audio_input, image_input: image_input
|
|
155
|
+
),
|
|
156
|
+
output_tokens: regular_output_remainder,
|
|
116
157
|
total_tokens: usage["total_tokens"],
|
|
117
158
|
cache_read_input_tokens: cache_read,
|
|
118
159
|
audio_input_tokens: audio_input,
|
|
119
160
|
audio_output_tokens: audio_output,
|
|
161
|
+
image_input_tokens: image_input,
|
|
162
|
+
image_output_tokens: image_output,
|
|
120
163
|
hidden_output_tokens: hidden_output_tokens(usage)
|
|
121
164
|
)
|
|
122
165
|
end
|
|
123
166
|
|
|
124
|
-
def
|
|
125
|
-
|
|
167
|
+
def split_stream_image_output(raw_output:, image_output_details:, text_output_details:, audio_output:,
|
|
168
|
+
default_to_image: false)
|
|
169
|
+
if image_output_details.zero? && text_output_details.zero?
|
|
170
|
+
remainder = [raw_output - audio_output, 0].max
|
|
171
|
+
return default_to_image ? [remainder, 0] : [0, remainder]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
text_output = text_output_details
|
|
175
|
+
text_output = [raw_output - image_output_details - audio_output, 0].max if text_output.zero?
|
|
176
|
+
[image_output_details, text_output]
|
|
126
177
|
end
|
|
127
178
|
|
|
128
|
-
def
|
|
129
|
-
|
|
179
|
+
def regular_input_tokens(usage:, cache_read:, audio_input:, image_input:)
|
|
180
|
+
raw = (usage["prompt_tokens"] || usage["input_tokens"]).to_i
|
|
181
|
+
[raw - cache_read - audio_input - image_input, 0].max
|
|
130
182
|
end
|
|
131
183
|
|
|
132
184
|
def cache_read_input_tokens(usage)
|
|
@@ -149,6 +201,21 @@ module LlmCostTracker
|
|
|
149
201
|
details["audio_tokens"].to_i
|
|
150
202
|
end
|
|
151
203
|
|
|
204
|
+
def image_input_tokens(usage)
|
|
205
|
+
details = input_token_details(usage)
|
|
206
|
+
details["image_tokens"].to_i
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def image_output_tokens(usage)
|
|
210
|
+
details = output_token_details(usage)
|
|
211
|
+
details["image_tokens"].to_i
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def text_output_tokens(usage)
|
|
215
|
+
details = output_token_details(usage)
|
|
216
|
+
details["text_tokens"].to_i
|
|
217
|
+
end
|
|
218
|
+
|
|
152
219
|
def input_token_details(usage)
|
|
153
220
|
usage["prompt_tokens_details"] || usage["input_tokens_details"] || usage["input_token_details"] || {}
|
|
154
221
|
end
|
|
@@ -2,19 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Parsers
|
|
5
|
-
|
|
5
|
+
autoload :Base, "llm_cost_tracker/parsers/base"
|
|
6
|
+
autoload :OpenaiUsage, "llm_cost_tracker/parsers/openai_usage"
|
|
7
|
+
autoload :OpenaiServiceCharges, "llm_cost_tracker/parsers/openai_service_charges"
|
|
8
|
+
autoload :SSE, "llm_cost_tracker/parsers/sse"
|
|
9
|
+
autoload :Openai, "llm_cost_tracker/parsers/openai"
|
|
10
|
+
autoload :Azure, "llm_cost_tracker/parsers/azure"
|
|
11
|
+
autoload :OpenaiCompatible, "llm_cost_tracker/parsers/openai_compatible"
|
|
12
|
+
autoload :Anthropic, "llm_cost_tracker/parsers/anthropic"
|
|
13
|
+
autoload :Gemini, "llm_cost_tracker/parsers/gemini"
|
|
14
|
+
|
|
15
|
+
MUTEX = Mutex.new
|
|
16
|
+
PARSER_CONSTANTS = %i[Openai Azure OpenaiCompatible Anthropic Gemini].freeze
|
|
6
17
|
|
|
7
18
|
module_function
|
|
8
19
|
|
|
9
20
|
def find_for(url)
|
|
10
|
-
|
|
21
|
+
PARSER_CONSTANTS.each do |name|
|
|
22
|
+
klass = const_get(name)
|
|
23
|
+
return instance_for(klass) if klass.match?(url)
|
|
24
|
+
end
|
|
25
|
+
nil
|
|
11
26
|
end
|
|
12
27
|
|
|
13
28
|
def find_for_provider(provider)
|
|
14
29
|
provider_name = provider.to_s.downcase
|
|
15
|
-
|
|
16
|
-
|
|
30
|
+
PARSER_CONSTANTS.each do |name|
|
|
31
|
+
klass = const_get(name)
|
|
32
|
+
return instance_for(klass) if klass.provider_names.include?(provider_name)
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def instance_for(klass)
|
|
38
|
+
cached = (@instances ||= {})[klass]
|
|
39
|
+
return cached if cached
|
|
40
|
+
|
|
41
|
+
MUTEX.synchronize do
|
|
42
|
+
@instances[klass] ||= klass.new
|
|
17
43
|
end
|
|
18
44
|
end
|
|
45
|
+
private_class_method :instance_for
|
|
19
46
|
end
|
|
20
47
|
end
|