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
|
@@ -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
|
|
@@ -12,13 +13,6 @@ module LlmCostTracker
|
|
|
12
13
|
"mcp_call" => :mcp_call
|
|
13
14
|
}.freeze
|
|
14
15
|
|
|
15
|
-
REASONING_MODEL_PATTERNS = [
|
|
16
|
-
/\Agpt-5(\b|[\d.-])/i,
|
|
17
|
-
/\Ao\d+(\b|[\d.-])/i
|
|
18
|
-
].freeze
|
|
19
|
-
NON_REASONING_GPT5_PATTERN = /\Agpt-5(?:\.\d+)?-chat\b/i
|
|
20
|
-
private_constant :NON_REASONING_GPT5_PATTERN
|
|
21
|
-
|
|
22
16
|
module_function
|
|
23
17
|
|
|
24
18
|
def line_items_from_output(output_items, request: nil, model: nil)
|
|
@@ -29,6 +23,40 @@ module LlmCostTracker
|
|
|
29
23
|
.filter_map { |item| build_line_item(item, request: request, model: model) }
|
|
30
24
|
end
|
|
31
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
|
|
58
|
+
end
|
|
59
|
+
|
|
32
60
|
def billable?(item)
|
|
33
61
|
return false unless item.is_a?(Hash)
|
|
34
62
|
|
|
@@ -68,7 +96,7 @@ module LlmCostTracker
|
|
|
68
96
|
quantity: 1,
|
|
69
97
|
cost_status: Billing::CostStatus::UNKNOWN,
|
|
70
98
|
pricing_basis: :provider_usage,
|
|
71
|
-
provider_field: "response.output.#{item['type']}",
|
|
99
|
+
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
72
100
|
provider_item_id: provider_item_id,
|
|
73
101
|
details: line_item_details(item)
|
|
74
102
|
)
|
|
@@ -77,7 +105,7 @@ module LlmCostTracker
|
|
|
77
105
|
def component_key_for(item, request:, model:)
|
|
78
106
|
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
79
107
|
return component unless component == :web_search_request
|
|
80
|
-
return component unless web_search_preview_used?(request)
|
|
108
|
+
return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
|
|
81
109
|
|
|
82
110
|
reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
|
|
83
111
|
end
|
|
@@ -92,13 +120,18 @@ module LlmCostTracker
|
|
|
92
120
|
end
|
|
93
121
|
end
|
|
94
122
|
|
|
95
|
-
def
|
|
123
|
+
def chat_completions_search_model?(model)
|
|
96
124
|
return false unless model
|
|
97
125
|
|
|
98
126
|
name = model.to_s.split("/", 2).last
|
|
99
|
-
|
|
127
|
+
LlmCostTracker::Providers::Openai::ModelFamilies.chat_completions_search?(name)
|
|
128
|
+
end
|
|
100
129
|
|
|
101
|
-
|
|
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)
|
|
102
135
|
end
|
|
103
136
|
|
|
104
137
|
def line_item_details(item)
|
|
@@ -109,10 +142,6 @@ module LlmCostTracker
|
|
|
109
142
|
}.compact
|
|
110
143
|
end
|
|
111
144
|
|
|
112
|
-
def openai_service_line_items(response, request: nil)
|
|
113
|
-
line_items_from_output(response["output"], request: request, model: response["model"])
|
|
114
|
-
end
|
|
115
|
-
|
|
116
145
|
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
117
146
|
output_items = []
|
|
118
147
|
each_event_data(events) do |data|
|
|
@@ -1,14 +1,14 @@
|
|
|
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
|
-
OPENAI_DATA_RESIDENCY_HOST_PATTERN = /\A[a-z]{2,3}\.api\.openai\.com\z/
|
|
11
|
-
|
|
12
12
|
class << self
|
|
13
13
|
def combined_pricing_mode(host:, model:, service_tier:)
|
|
14
14
|
modes = [Pricing.normalize_mode(service_tier)]
|
|
@@ -18,19 +18,12 @@ module LlmCostTracker
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def regional_processing?(host:, model:)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def data_residency_model?(model)
|
|
25
|
-
model.to_s.match?(
|
|
26
|
-
/\Agpt-5\.(?:4|5)(?:-(?:mini|nano|pro|codex(?:-mini|-max)?))?(?:-\d{4}-\d{2}-\d{2})?\z/
|
|
27
|
-
)
|
|
21
|
+
LlmCostTracker::Providers::Openai::Hosts.data_residency?(host) &&
|
|
22
|
+
LlmCostTracker::Providers::Openai::ModelFamilies.data_residency?(model)
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def parse_openai_usage(request_url:, request_body:, response_status:, response_body:)
|
|
26
|
+
def parse(request_url:, request_body:, response_status:, response_body:, **)
|
|
34
27
|
return nil unless response_status == 200
|
|
35
28
|
|
|
36
29
|
response = safe_json_parse(response_body)
|
|
@@ -42,7 +35,7 @@ module LlmCostTracker
|
|
|
42
35
|
|
|
43
36
|
model = response["model"] || request["model"]
|
|
44
37
|
|
|
45
|
-
|
|
38
|
+
Event.build(
|
|
46
39
|
provider: provider_for(request_url),
|
|
47
40
|
provider_response_id: response["id"],
|
|
48
41
|
pricing_mode: pricing_mode(
|
|
@@ -53,11 +46,11 @@ module LlmCostTracker
|
|
|
53
46
|
model: model,
|
|
54
47
|
token_usage: token_usage(usage: usage, cache_read: cache_read, model: model),
|
|
55
48
|
usage_source: :response,
|
|
56
|
-
service_line_items:
|
|
49
|
+
service_line_items: service_line_items_for(response, request: request, model: response["model"])
|
|
57
50
|
)
|
|
58
51
|
end
|
|
59
52
|
|
|
60
|
-
def
|
|
53
|
+
def parse_stream(response_status:, request_url: nil, request_body: nil, events: [], **)
|
|
61
54
|
return nil unless response_status == 200
|
|
62
55
|
|
|
63
56
|
request = safe_json_parse(request_body)
|
|
@@ -70,6 +63,12 @@ module LlmCostTracker
|
|
|
70
63
|
build_unknown_stream_usage(**context)
|
|
71
64
|
end
|
|
72
65
|
|
|
66
|
+
def auto_enable_stream_usage?(request_url)
|
|
67
|
+
openai_chat_completions_url?(request_url)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
73
72
|
def stream_capture_context(events:, request:, request_url:)
|
|
74
73
|
model = find_event_value(events) do |data|
|
|
75
74
|
data["model"] || data.dig("response", "model") || data.dig("chunk", "model")
|
|
@@ -91,7 +90,7 @@ module LlmCostTracker
|
|
|
91
90
|
|
|
92
91
|
def build_known_stream_usage(usage:, provider:, model:, provider_response_id:, pricing_mode:, service_line_items:)
|
|
93
92
|
cache_read = cache_read_input_tokens(usage)
|
|
94
|
-
|
|
93
|
+
Event.build(
|
|
95
94
|
provider: provider,
|
|
96
95
|
provider_response_id: provider_response_id,
|
|
97
96
|
pricing_mode: pricing_mode,
|
|
@@ -137,9 +136,6 @@ module LlmCostTracker
|
|
|
137
136
|
OpenaiUsage.combined_pricing_mode(host: parsed_uri(request_url)&.host, model: model, service_tier: service_tier)
|
|
138
137
|
end
|
|
139
138
|
|
|
140
|
-
IMAGE_OUTPUT_MODEL_PATTERN = /\Agpt-image-/i
|
|
141
|
-
private_constant :IMAGE_OUTPUT_MODEL_PATTERN
|
|
142
|
-
|
|
143
139
|
def token_usage(usage:, cache_read:, model: nil)
|
|
144
140
|
audio_input = audio_input_tokens(usage)
|
|
145
141
|
audio_output = audio_output_tokens(usage)
|
|
@@ -150,7 +146,7 @@ module LlmCostTracker
|
|
|
150
146
|
image_output, regular_output_remainder = split_stream_image_output(
|
|
151
147
|
raw_output: raw_output, image_output_details: image_output_details,
|
|
152
148
|
text_output_details: text_output_details, audio_output: audio_output,
|
|
153
|
-
default_to_image:
|
|
149
|
+
default_to_image: LlmCostTracker::Providers::Openai::ModelFamilies.image_output?(model)
|
|
154
150
|
)
|
|
155
151
|
|
|
156
152
|
TokenUsage.build(
|
|
@@ -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
|