llm_cost_tracker 0.10.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 +27 -0
- data/README.md +4 -1
- data/app/assets/llm_cost_tracker/application.css +782 -801
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +23 -16
- 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 +15 -3
- 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/services/llm_cost_tracker/dashboard/pricing_overview.rb +95 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +27 -33
- 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 +118 -119
- 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/budget.rb +1 -2
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +1 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +2 -16
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +1 -5
- data/lib/llm_cost_tracker/configuration.rb +3 -20
- data/lib/llm_cost_tracker/doctor.rb +74 -8
- data/lib/llm_cost_tracker/event.rb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +2 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +1 -1
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +39 -93
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +19 -63
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -1
- data/lib/llm_cost_tracker/parsers/anthropic.rb +14 -31
- data/lib/llm_cost_tracker/parsers/gemini.rb +3 -5
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +1 -7
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +3 -3
- data/lib/llm_cost_tracker/parsers.rb +0 -2
- data/lib/llm_cost_tracker/pricing/explainer.rb +1 -1
- data/lib/llm_cost_tracker/pricing/mode.rb +34 -4
- data/lib/llm_cost_tracker/pricing/service_charges.rb +1 -1
- data/lib/llm_cost_tracker/pricing.rb +1 -18
- data/lib/llm_cost_tracker/providers/anthropic/server_tools.rb +15 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +157 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -4
- data/lib/llm_cost_tracker/reconcile_tasks.rb +18 -21
- data/lib/llm_cost_tracker/reconciliation/importer.rb +2 -7
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +6 -30
- 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/tracker.rb +1 -1
- data/lib/llm_cost_tracker/version.rb +1 -1
- metadata +14 -6
- 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/parsers/openai_service_charges.rb +0 -155
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../billing/line_item"
|
|
4
|
+
require_relative "model_families"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Providers
|
|
8
|
+
module Openai
|
|
9
|
+
module ServiceCharges
|
|
10
|
+
RESPONSE_OUTPUT_COMPONENTS = {
|
|
11
|
+
"web_search_call" => :web_search_request,
|
|
12
|
+
"file_search_call" => :file_search_call,
|
|
13
|
+
"code_interpreter_call" => :container_session,
|
|
14
|
+
"mcp_call" => :mcp_call
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def line_items_from_output(output_items, request: nil, model: nil)
|
|
20
|
+
deduped = {}
|
|
21
|
+
Array(output_items).each { |item| store_output_item(deduped, item) }
|
|
22
|
+
deduped.values
|
|
23
|
+
.select { |item| billable?(item) }
|
|
24
|
+
.filter_map { |item| build_line_item(item, request: request, model: model) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def service_line_items_for(response, request: nil, model: nil)
|
|
28
|
+
output_items = Array(response["output"])
|
|
29
|
+
output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
|
|
30
|
+
line_items_from_output(output_items, request: request, model: model)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
|
|
34
|
+
CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
|
|
35
|
+
|
|
36
|
+
def chat_completions_web_search_items(response, model: nil)
|
|
37
|
+
return [] unless response["choices"]
|
|
38
|
+
|
|
39
|
+
provider_field = chat_completions_search_provider_field(response["choices"], model)
|
|
40
|
+
return [] unless provider_field
|
|
41
|
+
|
|
42
|
+
[{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
|
|
43
|
+
"provider_field" => provider_field }]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def chat_completions_search_provider_field(choices, model)
|
|
47
|
+
return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
|
|
48
|
+
return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def chat_completions_used_web_search?(choices)
|
|
54
|
+
Array(choices).any? do |choice|
|
|
55
|
+
Array(choice.dig("message", "annotations")).any? do |annotation|
|
|
56
|
+
annotation.is_a?(Hash) && annotation["type"] == "url_citation"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def billable?(item)
|
|
62
|
+
return false unless item.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
65
|
+
return false unless component
|
|
66
|
+
return true unless component == :web_search_request
|
|
67
|
+
|
|
68
|
+
action_type = item.dig("action", "type")
|
|
69
|
+
action_type.nil? || action_type == "search"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def store_output_item(output_items, item)
|
|
73
|
+
return unless item.is_a?(Hash) && RESPONSE_OUTPUT_COMPONENTS.key?(item["type"])
|
|
74
|
+
|
|
75
|
+
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
76
|
+
key = if component == :container_session && item["container_id"]
|
|
77
|
+
"#{component}:#{item['container_id']}"
|
|
78
|
+
else
|
|
79
|
+
item["id"] || "#{item['type']}:#{output_items.length}"
|
|
80
|
+
end
|
|
81
|
+
output_items[key] = item
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_line_item(item, request: nil, model: nil)
|
|
85
|
+
return nil unless item.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
component_key = component_key_for(item, request: request, model: model)
|
|
88
|
+
return nil unless component_key
|
|
89
|
+
|
|
90
|
+
provider_item_id = if component_key == :container_session
|
|
91
|
+
item["container_id"] || item["id"]
|
|
92
|
+
else
|
|
93
|
+
item["id"]
|
|
94
|
+
end
|
|
95
|
+
Billing::LineItem.build(
|
|
96
|
+
component_key: component_key,
|
|
97
|
+
quantity: 1,
|
|
98
|
+
cost_status: Billing::CostStatus::UNKNOWN,
|
|
99
|
+
pricing_basis: :provider_usage,
|
|
100
|
+
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
101
|
+
provider_item_id: provider_item_id,
|
|
102
|
+
details: line_item_details(item)
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def component_key_for(item, request:, model:)
|
|
107
|
+
component = RESPONSE_OUTPUT_COMPONENTS[item["type"]]
|
|
108
|
+
return component unless component == :web_search_request
|
|
109
|
+
return component unless web_search_preview_used?(request) || chat_completions_search_model?(model)
|
|
110
|
+
|
|
111
|
+
reasoning_model?(model) ? :web_search_preview_request_reasoning : :web_search_preview_request_non_reasoning
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def web_search_preview_used?(request)
|
|
115
|
+
tools = request && (request[:tools] || request["tools"])
|
|
116
|
+
return false unless tools.respond_to?(:each)
|
|
117
|
+
|
|
118
|
+
tools.any? do |tool|
|
|
119
|
+
type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
|
|
120
|
+
type.to_s.include?("web_search_preview")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def chat_completions_search_model?(model)
|
|
125
|
+
return false unless model
|
|
126
|
+
|
|
127
|
+
name = model.to_s.split("/", 2).last
|
|
128
|
+
ModelFamilies.chat_completions_search?(name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def reasoning_model?(model)
|
|
132
|
+
return false unless model
|
|
133
|
+
|
|
134
|
+
name = model.to_s.split("/", 2).last
|
|
135
|
+
ModelFamilies.reasoning?(name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def line_item_details(item)
|
|
139
|
+
{
|
|
140
|
+
"status" => item["status"],
|
|
141
|
+
"action_type" => item.dig("action", "type"),
|
|
142
|
+
"container_id" => item["container_id"]
|
|
143
|
+
}.compact
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
147
|
+
output_items = []
|
|
148
|
+
each_event_data(events) do |data|
|
|
149
|
+
output_items.concat(Array(data.dig("response", "output")))
|
|
150
|
+
output_items << data["item"] if data["item"]
|
|
151
|
+
end
|
|
152
|
+
line_items_from_output(output_items, request: request, model: model)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -20,9 +20,5 @@ module LlmCostTracker
|
|
|
20
20
|
require_relative "generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator"
|
|
21
21
|
require_relative "generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator"
|
|
22
22
|
end
|
|
23
|
-
|
|
24
|
-
rake_tasks do
|
|
25
|
-
load File.expand_path("../tasks/llm_cost_tracker.rake", __dir__)
|
|
26
|
-
end
|
|
27
23
|
end
|
|
28
24
|
end
|
|
@@ -85,38 +85,35 @@ module LlmCostTracker
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def print_unmatched_provider_rows(diff, output)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
diff.unmatched_provider_rows.each do |row|
|
|
93
|
-
output.puts " #{row[:external_id]} (#{row[:match_basis]}): " \
|
|
94
|
-
"#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
|
|
88
|
+
print_section(output, "unmatched provider rows",
|
|
89
|
+
diff.unmatched_provider_rows, diff.unmatched_provider_rows_total) do |row|
|
|
90
|
+
"#{row[:external_id]} (#{row[:match_basis]}): " \
|
|
91
|
+
"#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
|
|
95
92
|
end
|
|
96
93
|
end
|
|
97
94
|
|
|
98
95
|
def print_unmatched_local_calls(diff, output)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
diff.unmatched_local_calls_total)}:"
|
|
103
|
-
diff.unmatched_local_calls.each do |row|
|
|
104
|
-
output.puts " #{row[:count]} calls / #{row[:total_cost].to_s('F')} " \
|
|
105
|
-
"#{format_attribution(row[:attribution])}"
|
|
96
|
+
print_section(output, "unmatched local calls",
|
|
97
|
+
diff.unmatched_local_calls, diff.unmatched_local_calls_total) do |row|
|
|
98
|
+
"#{row[:count]} calls / #{row[:total_cost].to_s('F')} #{format_attribution(row[:attribution])}"
|
|
106
99
|
end
|
|
107
100
|
end
|
|
108
101
|
|
|
109
102
|
def print_non_cost_rows(diff, output)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
diff.non_cost_rows.each do |row|
|
|
115
|
-
output.puts " [#{row[:row_type]}/#{row[:meter]}] #{format_amount(row[:billed_amount])} " \
|
|
116
|
-
"#{format_attribution(row[:attribution])}"
|
|
103
|
+
print_section(output, "non-cost evidence",
|
|
104
|
+
diff.non_cost_rows, diff.non_cost_rows_total) do |row|
|
|
105
|
+
"[#{row[:row_type]}/#{row[:meter]}] " \
|
|
106
|
+
"#{format_amount(row[:billed_amount])} #{format_attribution(row[:attribution])}"
|
|
117
107
|
end
|
|
118
108
|
end
|
|
119
109
|
|
|
110
|
+
def print_section(output, label, rows, total)
|
|
111
|
+
return if rows.empty?
|
|
112
|
+
|
|
113
|
+
output.puts " #{label}#{truncation_suffix(rows.size, total)}:"
|
|
114
|
+
rows.each { |row| output.puts " #{yield(row)}" }
|
|
115
|
+
end
|
|
116
|
+
|
|
120
117
|
def truncation_suffix(shown, total)
|
|
121
118
|
return "" if shown >= total
|
|
122
119
|
|
|
@@ -5,6 +5,7 @@ require "date"
|
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
7
|
require_relative "import_result"
|
|
8
|
+
require_relative "sources/coercion"
|
|
8
9
|
require_relative "../ledger/rollups"
|
|
9
10
|
|
|
10
11
|
module LlmCostTracker
|
|
@@ -120,7 +121,7 @@ module LlmCostTracker
|
|
|
120
121
|
def normalize_rows(rows)
|
|
121
122
|
errors = []
|
|
122
123
|
normalized = rows.each_with_index.filter_map do |row, index|
|
|
123
|
-
attrs = symbolize(row)
|
|
124
|
+
attrs = Sources::Coercion.symbolize(row)
|
|
124
125
|
missing = REQUIRED_FIELDS - attrs.keys
|
|
125
126
|
if missing.any?
|
|
126
127
|
errors << "row #{index}: missing #{missing.join(', ')}"
|
|
@@ -213,12 +214,6 @@ module LlmCostTracker
|
|
|
213
214
|
raw.start_with?(prefix) ? raw : "#{prefix}#{raw}"
|
|
214
215
|
end
|
|
215
216
|
|
|
216
|
-
def symbolize(row)
|
|
217
|
-
return row if row.is_a?(Hash) && row.keys.all?(Symbol)
|
|
218
|
-
|
|
219
|
-
row.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
220
|
-
end
|
|
221
|
-
|
|
222
217
|
def parse_date(value)
|
|
223
218
|
return value if value.is_a?(Date)
|
|
224
219
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bigdecimal"
|
|
4
|
-
require "json"
|
|
5
4
|
require "time"
|
|
6
5
|
|
|
6
|
+
require_relative "coercion"
|
|
7
7
|
require_relative "fingerprint"
|
|
8
8
|
require_relative "../../providers/anthropic/tier_classification"
|
|
9
9
|
|
|
@@ -23,7 +23,7 @@ module LlmCostTracker
|
|
|
23
23
|
module_function
|
|
24
24
|
|
|
25
25
|
def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
|
|
26
|
-
payload = coerce_hash(response)
|
|
26
|
+
payload = Coercion.coerce_hash(response, label: "Anthropic Usage")
|
|
27
27
|
buckets = Array(payload[:data])
|
|
28
28
|
buckets.flat_map do |bucket|
|
|
29
29
|
rows_for_bucket(bucket, authority: authority, row_type: row_type)
|
|
@@ -31,7 +31,7 @@ module LlmCostTracker
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def rows_for_bucket(bucket, authority:, row_type:)
|
|
34
|
-
bucket = symbolize(bucket)
|
|
34
|
+
bucket = Coercion.symbolize(bucket)
|
|
35
35
|
starting_at = bucket[:starting_at]
|
|
36
36
|
ending_at = bucket[:ending_at]
|
|
37
37
|
return [] unless starting_at && ending_at
|
|
@@ -50,7 +50,7 @@ module LlmCostTracker
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def row_for_result(raw, period_start:, period_end:, starting_at:, ending_at:, authority:, row_type:)
|
|
53
|
-
result = symbolize(raw)
|
|
53
|
+
result = Coercion.symbolize(raw)
|
|
54
54
|
raw_amount = result[:amount]
|
|
55
55
|
return nil if raw_amount.nil?
|
|
56
56
|
|
|
@@ -122,19 +122,11 @@ module LlmCostTracker
|
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
def fingerprint_for(result, starting_at:, ending_at:)
|
|
125
|
-
attributes = result.merge(starting_at: normalized_epoch(starting_at),
|
|
126
|
-
ending_at: normalized_epoch(ending_at))
|
|
125
|
+
attributes = result.merge(starting_at: Coercion.normalized_epoch(starting_at),
|
|
126
|
+
ending_at: Coercion.normalized_epoch(ending_at))
|
|
127
127
|
Fingerprint.compute(FINGERPRINT_KEYS, attributes)
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
def normalized_epoch(value)
|
|
131
|
-
return value.to_i if value.is_a?(Numeric)
|
|
132
|
-
|
|
133
|
-
Time.parse(value.to_s).utc.to_i
|
|
134
|
-
rescue ArgumentError
|
|
135
|
-
value.to_s
|
|
136
|
-
end
|
|
137
|
-
|
|
138
130
|
def parse_date(value)
|
|
139
131
|
return value if value.is_a?(Date)
|
|
140
132
|
return Time.at(value).utc.to_date if value.is_a?(Numeric)
|
|
@@ -150,22 +142,6 @@ module LlmCostTracker
|
|
|
150
142
|
end
|
|
151
143
|
(time - 1).utc.to_date
|
|
152
144
|
end
|
|
153
|
-
|
|
154
|
-
def coerce_hash(response)
|
|
155
|
-
return {} if response.nil?
|
|
156
|
-
return symbolize(response) if response.is_a?(Hash)
|
|
157
|
-
|
|
158
|
-
parsed = JSON.parse(response.to_s)
|
|
159
|
-
raise ArgumentError, "Anthropic Usage payload must be a JSON object" unless parsed.is_a?(Hash)
|
|
160
|
-
|
|
161
|
-
symbolize(parsed)
|
|
162
|
-
rescue JSON::ParserError => e
|
|
163
|
-
raise ArgumentError, "Unable to parse Anthropic Usage payload: #{e.message}"
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def symbolize(hash)
|
|
167
|
-
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
168
|
-
end
|
|
169
145
|
end
|
|
170
146
|
end
|
|
171
147
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Reconciliation
|
|
8
|
+
module Sources
|
|
9
|
+
module Coercion
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def symbolize(hash)
|
|
13
|
+
return hash if hash.is_a?(Hash) && hash.keys.all?(Symbol)
|
|
14
|
+
|
|
15
|
+
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def normalized_epoch(value)
|
|
19
|
+
return value.to_i if value.is_a?(Numeric)
|
|
20
|
+
|
|
21
|
+
Time.parse(value.to_s).utc.to_i
|
|
22
|
+
rescue ArgumentError
|
|
23
|
+
value.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def coerce_hash(response, label:)
|
|
27
|
+
return {} if response.nil?
|
|
28
|
+
return symbolize(response) if response.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
parsed = JSON.parse(response.to_s)
|
|
31
|
+
raise ArgumentError, "#{label} payload must be a JSON object" unless parsed.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
symbolize(parsed)
|
|
34
|
+
rescue JSON::ParserError => e
|
|
35
|
+
raise ArgumentError, "Unable to parse #{label} payload: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "time"
|
|
5
4
|
|
|
5
|
+
require_relative "coercion"
|
|
6
6
|
require_relative "fingerprint"
|
|
7
7
|
|
|
8
8
|
module LlmCostTracker
|
|
@@ -17,7 +17,7 @@ module LlmCostTracker
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
19
19
|
def parse(response, authority: AUTHORITY_COST_API, row_type: ROW_TYPE_COST)
|
|
20
|
-
payload = coerce_hash(response)
|
|
20
|
+
payload = Coercion.coerce_hash(response, label: "OpenAI Costs")
|
|
21
21
|
buckets = Array(payload[:data])
|
|
22
22
|
buckets.flat_map do |bucket|
|
|
23
23
|
rows_for_bucket(bucket, authority: authority, row_type: row_type)
|
|
@@ -25,7 +25,7 @@ module LlmCostTracker
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def rows_for_bucket(bucket, authority:, row_type:)
|
|
28
|
-
bucket = symbolize(bucket)
|
|
28
|
+
bucket = Coercion.symbolize(bucket)
|
|
29
29
|
start_time = bucket[:start_time]
|
|
30
30
|
end_time = bucket[:end_time]
|
|
31
31
|
return [] unless start_time && end_time
|
|
@@ -44,8 +44,8 @@ module LlmCostTracker
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def row_for_result(raw, period_start:, period_end:, start_time:, end_time:, authority:, row_type:)
|
|
47
|
-
result = symbolize(raw)
|
|
48
|
-
amount = symbolize(result[:amount] || {})
|
|
47
|
+
result = Coercion.symbolize(raw)
|
|
48
|
+
amount = Coercion.symbolize(result[:amount] || {})
|
|
49
49
|
billed_amount = amount[:value]
|
|
50
50
|
return nil if billed_amount.nil?
|
|
51
51
|
|
|
@@ -93,19 +93,11 @@ module LlmCostTracker
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
def fingerprint_for(result, start_time:, end_time:)
|
|
96
|
-
attributes = result.merge(start_time: normalized_epoch(start_time),
|
|
97
|
-
end_time: normalized_epoch(end_time))
|
|
96
|
+
attributes = result.merge(start_time: Coercion.normalized_epoch(start_time),
|
|
97
|
+
end_time: Coercion.normalized_epoch(end_time))
|
|
98
98
|
Fingerprint.compute(FINGERPRINT_KEYS, attributes)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
def normalized_epoch(value)
|
|
102
|
-
return value.to_i if value.is_a?(Numeric)
|
|
103
|
-
|
|
104
|
-
Time.parse(value.to_s).utc.to_i
|
|
105
|
-
rescue ArgumentError
|
|
106
|
-
value.to_s
|
|
107
|
-
end
|
|
108
|
-
|
|
109
101
|
def epoch_to_date(value)
|
|
110
102
|
return Time.at(Integer(value)).utc.to_date if value.is_a?(Numeric) || value.to_s.match?(/\A\d+\z/)
|
|
111
103
|
|
|
@@ -120,22 +112,6 @@ module LlmCostTracker
|
|
|
120
112
|
end
|
|
121
113
|
(time - 1).utc.to_date
|
|
122
114
|
end
|
|
123
|
-
|
|
124
|
-
def coerce_hash(response)
|
|
125
|
-
return {} if response.nil?
|
|
126
|
-
return symbolize(response) if response.is_a?(Hash)
|
|
127
|
-
|
|
128
|
-
parsed = JSON.parse(response.to_s)
|
|
129
|
-
raise ArgumentError, "OpenAI Costs payload must be a JSON object" unless parsed.is_a?(Hash)
|
|
130
|
-
|
|
131
|
-
symbolize(parsed)
|
|
132
|
-
rescue JSON::ParserError => e
|
|
133
|
-
raise ArgumentError, "Unable to parse OpenAI Costs payload: #{e.message}"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def symbolize(hash)
|
|
137
|
-
hash.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
138
|
-
end
|
|
139
115
|
end
|
|
140
116
|
end
|
|
141
117
|
end
|
|
@@ -4,18 +4,18 @@ module LlmCostTracker
|
|
|
4
4
|
class Report
|
|
5
5
|
class Formatter
|
|
6
6
|
TOP_LIMIT = 5
|
|
7
|
-
|
|
8
|
-
TOP_CALL_COLUMN_WIDTH = 32
|
|
7
|
+
MIN_COLUMN_WIDTH = 28
|
|
9
8
|
|
|
10
|
-
def initialize(data)
|
|
9
|
+
def initialize(data, color: $stdout.tty?)
|
|
11
10
|
@data = data
|
|
11
|
+
@color = color
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def to_s
|
|
15
|
-
lines = ["LLM Cost Report (last #{@data.days} days)", ""]
|
|
15
|
+
lines = [bold("LLM Cost Report (last #{@data.days} days)"), ""]
|
|
16
16
|
append_summary(lines)
|
|
17
|
-
append_cost_section(lines, "By provider", @data.cost_by_provider)
|
|
18
|
-
append_cost_section(lines, "By model", @data.cost_by_model)
|
|
17
|
+
append_cost_section(lines, "By provider", @data.cost_by_provider) { |row| row.name.to_s }
|
|
18
|
+
append_cost_section(lines, "By model", @data.cost_by_model) { |row| row.name.to_s }
|
|
19
19
|
append_tag_sections(lines)
|
|
20
20
|
append_top_calls(lines)
|
|
21
21
|
lines.join("\n")
|
|
@@ -27,36 +27,37 @@ module LlmCostTracker
|
|
|
27
27
|
lines << "Total cost: #{money(@data.total_cost)}"
|
|
28
28
|
lines << "Requests: #{@data.requests_count}"
|
|
29
29
|
lines << "Avg latency: #{average_latency}"
|
|
30
|
-
lines << "Unknown pricing: #{@data.unknown_pricing_count}"
|
|
30
|
+
lines << "Unknown pricing: #{colored_unknown_pricing(@data.unknown_pricing_count)}"
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def append_cost_section(lines, title, rows)
|
|
33
|
+
def append_cost_section(lines, title, rows, &name_for)
|
|
34
34
|
lines << ""
|
|
35
|
-
lines << "#{title}:"
|
|
35
|
+
lines << bold("#{title}:")
|
|
36
36
|
return lines << " none" if rows.empty?
|
|
37
37
|
|
|
38
|
-
rows.first(TOP_LIMIT)
|
|
39
|
-
|
|
38
|
+
visible = rows.first(TOP_LIMIT)
|
|
39
|
+
width = column_width(visible, &name_for)
|
|
40
|
+
visible.each do |row|
|
|
41
|
+
lines << " #{name_for.call(row).ljust(width)} #{money(row.total_cost)}"
|
|
40
42
|
end
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def append_tag_sections(lines)
|
|
44
46
|
@data.cost_by_tags.each do |tag_key, rows|
|
|
45
|
-
append_cost_section(lines, "By tag (#{tag_key})", rows)
|
|
47
|
+
append_cost_section(lines, "By tag (#{tag_key})", rows) { |row| row.name.to_s }
|
|
46
48
|
end
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
def append_top_calls(lines)
|
|
50
|
-
lines
|
|
51
|
-
|
|
52
|
-
return lines << " none" if @data.top_calls.empty?
|
|
53
|
-
|
|
54
|
-
@data.top_calls.first(TOP_LIMIT).each do |call|
|
|
55
|
-
label = "#{call.provider}/#{call.model}"
|
|
56
|
-
lines << " #{label.ljust(TOP_CALL_COLUMN_WIDTH)} #{money(call.total_cost)}"
|
|
52
|
+
append_cost_section(lines, "Top expensive calls", @data.top_calls) do |call|
|
|
53
|
+
"#{call.provider}/#{call.model}"
|
|
57
54
|
end
|
|
58
55
|
end
|
|
59
56
|
|
|
57
|
+
def column_width(rows, &name_for)
|
|
58
|
+
[MIN_COLUMN_WIDTH, rows.map { |row| name_for.call(row).length }.max.to_i].max
|
|
59
|
+
end
|
|
60
|
+
|
|
60
61
|
def average_latency
|
|
61
62
|
@data.average_latency_ms ? "#{@data.average_latency_ms.round}ms" : "n/a"
|
|
62
63
|
end
|
|
@@ -64,6 +65,18 @@ module LlmCostTracker
|
|
|
64
65
|
def money(value)
|
|
65
66
|
"$#{format('%.6f', value.to_f)}"
|
|
66
67
|
end
|
|
68
|
+
|
|
69
|
+
def colored_unknown_pricing(count)
|
|
70
|
+
return count.to_s unless @color
|
|
71
|
+
|
|
72
|
+
count.to_i.positive? ? "\e[33m#{count}\e[0m" : "\e[32m#{count}\e[0m"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def bold(text)
|
|
76
|
+
return text unless @color
|
|
77
|
+
|
|
78
|
+
"\e[1m#{text}\e[0m"
|
|
79
|
+
end
|
|
67
80
|
end
|
|
68
81
|
end
|
|
69
82
|
end
|
|
@@ -23,7 +23,7 @@ module LlmCostTracker
|
|
|
23
23
|
def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
|
|
24
24
|
return unless LlmCostTracker.configuration.enabled
|
|
25
25
|
|
|
26
|
-
pricing_mode = Pricing.
|
|
26
|
+
pricing_mode = Pricing::Mode.normalize(pricing_mode) || event.pricing_mode
|
|
27
27
|
cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
|
|
28
28
|
provider: event.provider,
|
|
29
29
|
model: event.model,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: llm_cost_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergii Khomenko
|
|
@@ -239,6 +239,7 @@ files:
|
|
|
239
239
|
- app/controllers/llm_cost_tracker/dashboard_controller.rb
|
|
240
240
|
- app/controllers/llm_cost_tracker/data_quality_controller.rb
|
|
241
241
|
- app/controllers/llm_cost_tracker/models_controller.rb
|
|
242
|
+
- app/controllers/llm_cost_tracker/pricing_controller.rb
|
|
242
243
|
- app/controllers/llm_cost_tracker/reconciliation_controller.rb
|
|
243
244
|
- app/controllers/llm_cost_tracker/tags_controller.rb
|
|
244
245
|
- app/helpers/llm_cost_tracker/application_helper.rb
|
|
@@ -249,6 +250,7 @@ files:
|
|
|
249
250
|
- app/helpers/llm_cost_tracker/inline_style_helper.rb
|
|
250
251
|
- app/helpers/llm_cost_tracker/pagination_helper.rb
|
|
251
252
|
- app/helpers/llm_cost_tracker/reconciliation_helper.rb
|
|
253
|
+
- app/helpers/llm_cost_tracker/sortable_table_helper.rb
|
|
252
254
|
- app/helpers/llm_cost_tracker/token_usage_helper.rb
|
|
253
255
|
- app/models/llm_cost_tracker/call.rb
|
|
254
256
|
- app/models/llm_cost_tracker/call_line_item.rb
|
|
@@ -264,8 +266,10 @@ files:
|
|
|
264
266
|
- app/services/llm_cost_tracker/dashboard/overview_stats.rb
|
|
265
267
|
- app/services/llm_cost_tracker/dashboard/pagination.rb
|
|
266
268
|
- app/services/llm_cost_tracker/dashboard/params.rb
|
|
269
|
+
- app/services/llm_cost_tracker/dashboard/pricing_overview.rb
|
|
267
270
|
- app/services/llm_cost_tracker/dashboard/provider_breakdown.rb
|
|
268
271
|
- app/services/llm_cost_tracker/dashboard/setup_state.rb
|
|
272
|
+
- app/services/llm_cost_tracker/dashboard/sort.rb
|
|
269
273
|
- app/services/llm_cost_tracker/dashboard/spend_anomaly.rb
|
|
270
274
|
- app/services/llm_cost_tracker/dashboard/tag_breakdown.rb
|
|
271
275
|
- app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb
|
|
@@ -280,12 +284,14 @@ files:
|
|
|
280
284
|
- app/views/llm_cost_tracker/errors/invalid_filter.html.erb
|
|
281
285
|
- app/views/llm_cost_tracker/errors/not_found.html.erb
|
|
282
286
|
- app/views/llm_cost_tracker/models/index.html.erb
|
|
287
|
+
- app/views/llm_cost_tracker/pricing/index.html.erb
|
|
283
288
|
- app/views/llm_cost_tracker/reconciliation/index.html.erb
|
|
284
|
-
- app/views/llm_cost_tracker/shared/_active_filters.html.erb
|
|
285
289
|
- app/views/llm_cost_tracker/shared/_bar.html.erb
|
|
286
|
-
- app/views/llm_cost_tracker/shared/
|
|
290
|
+
- app/views/llm_cost_tracker/shared/_filter_pill_date.html.erb
|
|
291
|
+
- app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb
|
|
292
|
+
- app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb
|
|
293
|
+
- app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb
|
|
287
294
|
- app/views/llm_cost_tracker/shared/_metric_stack.html.erb
|
|
288
|
-
- app/views/llm_cost_tracker/shared/_sort.html.erb
|
|
289
295
|
- app/views/llm_cost_tracker/shared/_spend_chart.html.erb
|
|
290
296
|
- app/views/llm_cost_tracker/shared/_tag_chips.html.erb
|
|
291
297
|
- app/views/llm_cost_tracker/shared/setup_required.html.erb
|
|
@@ -299,6 +305,7 @@ files:
|
|
|
299
305
|
- lib/llm_cost_tracker/billing/cost_status.rb
|
|
300
306
|
- lib/llm_cost_tracker/billing/line_item.rb
|
|
301
307
|
- lib/llm_cost_tracker/budget.rb
|
|
308
|
+
- lib/llm_cost_tracker/capture/sse.rb
|
|
302
309
|
- lib/llm_cost_tracker/capture/stream.rb
|
|
303
310
|
- lib/llm_cost_tracker/capture/stream_collector.rb
|
|
304
311
|
- lib/llm_cost_tracker/capture/stream_tracker.rb
|
|
@@ -377,9 +384,7 @@ files:
|
|
|
377
384
|
- lib/llm_cost_tracker/parsers/gemini.rb
|
|
378
385
|
- lib/llm_cost_tracker/parsers/openai.rb
|
|
379
386
|
- lib/llm_cost_tracker/parsers/openai_compatible.rb
|
|
380
|
-
- lib/llm_cost_tracker/parsers/openai_service_charges.rb
|
|
381
387
|
- lib/llm_cost_tracker/parsers/openai_usage.rb
|
|
382
|
-
- lib/llm_cost_tracker/parsers/sse.rb
|
|
383
388
|
- lib/llm_cost_tracker/prices.json
|
|
384
389
|
- lib/llm_cost_tracker/pricing.rb
|
|
385
390
|
- lib/llm_cost_tracker/pricing/backfill.rb
|
|
@@ -396,11 +401,13 @@ files:
|
|
|
396
401
|
- lib/llm_cost_tracker/pricing/sync/registry_diff.rb
|
|
397
402
|
- lib/llm_cost_tracker/pricing/sync/registry_writer.rb
|
|
398
403
|
- lib/llm_cost_tracker/pricing/unknown.rb
|
|
404
|
+
- lib/llm_cost_tracker/providers/anthropic/server_tools.rb
|
|
399
405
|
- lib/llm_cost_tracker/providers/anthropic/tier_classification.rb
|
|
400
406
|
- lib/llm_cost_tracker/providers/azure/hosts.rb
|
|
401
407
|
- lib/llm_cost_tracker/providers/gemini/model_families.rb
|
|
402
408
|
- lib/llm_cost_tracker/providers/openai/hosts.rb
|
|
403
409
|
- lib/llm_cost_tracker/providers/openai/model_families.rb
|
|
410
|
+
- lib/llm_cost_tracker/providers/openai/service_charges.rb
|
|
404
411
|
- lib/llm_cost_tracker/railtie.rb
|
|
405
412
|
- lib/llm_cost_tracker/reconcile_tasks.rb
|
|
406
413
|
- lib/llm_cost_tracker/reconciliation.rb
|
|
@@ -409,6 +416,7 @@ files:
|
|
|
409
416
|
- lib/llm_cost_tracker/reconciliation/import_result.rb
|
|
410
417
|
- lib/llm_cost_tracker/reconciliation/importer.rb
|
|
411
418
|
- lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb
|
|
419
|
+
- lib/llm_cost_tracker/reconciliation/sources/coercion.rb
|
|
412
420
|
- lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb
|
|
413
421
|
- lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb
|
|
414
422
|
- lib/llm_cost_tracker/report.rb
|