llm_cost_tracker 0.10.0 → 0.12.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 +82 -0
- data/README.md +11 -5
- data/app/assets/llm_cost_tracker/application.css +784 -802
- data/app/controllers/llm_cost_tracker/application_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/calls_controller.rb +28 -21
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -4
- 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/tags_controller.rb +3 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +19 -16
- data/app/helpers/llm_cost_tracker/chart_helper.rb +22 -6
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +41 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +4 -6
- data/app/models/llm_cost_tracker/call.rb +28 -63
- data/app/models/llm_cost_tracker/call_line_item.rb +2 -2
- data/app/models/llm_cost_tracker/call_rollup.rb +38 -0
- data/app/models/llm_cost_tracker/call_tag.rb +0 -2
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +2 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +64 -43
- data/app/services/llm_cost_tracker/dashboard/filter.rb +5 -0
- data/app/services/llm_cost_tracker/dashboard/masking.rb +31 -0
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +63 -0
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +5 -71
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +2 -5
- data/app/services/llm_cost_tracker/dashboard/pricing_overview.rb +81 -0
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +6 -68
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +20 -12
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +34 -19
- data/app/views/layouts/llm_cost_tracker/application.html.erb +74 -17
- data/app/views/llm_cost_tracker/calls/index.html.erb +69 -90
- data/app/views/llm_cost_tracker/calls/show.html.erb +132 -125
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +120 -159
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +140 -194
- 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/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 +85 -104
- data/config/routes.rb +3 -3
- data/lib/llm_cost_tracker/budget.rb +25 -28
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/{parsers → capture}/sse.rb +2 -1
- data/lib/llm_cost_tracker/capture/stream_collector.rb +30 -52
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +18 -33
- data/lib/llm_cost_tracker/capture_verifier.rb +59 -0
- data/lib/llm_cost_tracker/charges/cost.rb +27 -0
- data/lib/llm_cost_tracker/{billing → charges}/cost_status.rb +14 -4
- data/lib/llm_cost_tracker/{billing → charges}/line_item.rb +40 -44
- data/lib/llm_cost_tracker/check.rb +5 -0
- data/lib/llm_cost_tracker/configuration.rb +13 -61
- data/lib/llm_cost_tracker/currency.rb +5 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +15 -49
- data/lib/llm_cost_tracker/doctor/price_check.rb +1 -1
- data/lib/llm_cost_tracker/doctor/probe.rb +3 -4
- data/lib/llm_cost_tracker/doctor/schema_check.rb +3 -6
- data/lib/llm_cost_tracker/doctor.rb +66 -64
- data/lib/llm_cost_tracker/engine.rb +4 -4
- data/lib/llm_cost_tracker/event.rb +12 -20
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +5 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +3 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +4 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +8 -9
- data/lib/llm_cost_tracker/ingestion/pool.rb +3 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +7 -17
- data/lib/llm_cost_tracker/ingestion.rb +24 -36
- data/lib/llm_cost_tracker/integrations/anthropic.rb +94 -116
- data/lib/llm_cost_tracker/integrations/base.rb +39 -57
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +84 -0
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +81 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +72 -332
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +89 -145
- data/lib/llm_cost_tracker/integrations.rb +32 -25
- data/lib/llm_cost_tracker/ledger/period/totals.rb +27 -42
- data/lib/llm_cost_tracker/ledger/period.rb +5 -10
- data/lib/llm_cost_tracker/ledger/rollups.rb +67 -98
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +12 -13
- data/lib/llm_cost_tracker/ledger/schema/base.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +24 -79
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -35
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +4 -41
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +30 -99
- data/lib/llm_cost_tracker/ledger/schema/ingestion/inbox_entries.rb +26 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion/leases.rb +17 -0
- data/lib/llm_cost_tracker/ledger/schema.rb +26 -0
- data/lib/llm_cost_tracker/ledger/store.rb +18 -42
- data/lib/llm_cost_tracker/ledger/tags/{sql.rb → breakdown.rb} +1 -1
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +4 -6
- data/lib/llm_cost_tracker/ledger.rb +14 -11
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +63 -51
- data/lib/llm_cost_tracker/parsers.rb +140 -29
- data/lib/llm_cost_tracker/prices.json +1707 -1
- data/lib/llm_cost_tracker/pricing/backfill.rb +52 -80
- data/lib/llm_cost_tracker/pricing/calculation.rb +260 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +17 -18
- data/lib/llm_cost_tracker/pricing/estimator.rb +2 -2
- data/lib/llm_cost_tracker/pricing/matcher.rb +84 -0
- data/lib/llm_cost_tracker/pricing/mode.rb +53 -35
- data/lib/llm_cost_tracker/pricing/price_key.rb +56 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +18 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +189 -100
- data/lib/llm_cost_tracker/pricing/service_rates.rb +69 -0
- data/lib/llm_cost_tracker/pricing/source.rb +7 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +2 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +4 -10
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +10 -3
- data/lib/llm_cost_tracker/pricing/sync.rb +9 -11
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -5
- data/lib/llm_cost_tracker/pricing.rb +10 -295
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +93 -0
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +30 -0
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -4
- data/lib/llm_cost_tracker/providers/azure/parser.rb +44 -0
- data/lib/llm_cost_tracker/providers/gemini/model_families.rb +1 -4
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +177 -0
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +76 -0
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +1 -7
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +5 -8
- data/lib/llm_cost_tracker/providers/openai/parser.rb +39 -0
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +152 -0
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +181 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -0
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +36 -0
- data/lib/llm_cost_tracker/providers.rb +35 -0
- data/lib/llm_cost_tracker/railtie.rb +0 -7
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +33 -20
- data/lib/llm_cost_tracker/report.rb +1 -1
- data/lib/llm_cost_tracker/retention.rb +6 -19
- data/lib/llm_cost_tracker/tags/context.rb +9 -6
- data/lib/llm_cost_tracker/tags/sanitizer.rb +10 -0
- data/lib/llm_cost_tracker/timing.rb +2 -4
- data/lib/llm_cost_tracker/tracker.rb +24 -36
- data/lib/llm_cost_tracker/usage/catalog.rb +58 -0
- data/lib/llm_cost_tracker/usage/dimension.rb +21 -0
- data/lib/llm_cost_tracker/{billing/components.yml → usage/dimensions.yml} +24 -46
- data/lib/llm_cost_tracker/usage/source.rb +14 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +100 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +43 -52
- data/lib/tasks/llm_cost_tracker.rake +14 -73
- metadata +92 -58
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -106
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +0 -28
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice.rb +0 -13
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +0 -29
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +0 -183
- 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/billing/components.rb +0 -95
- data/lib/llm_cost_tracker/capture/stream.rb +0 -9
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +0 -61
- data/lib/llm_cost_tracker/doctor/check.rb +0 -7
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +0 -56
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +0 -164
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +0 -34
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +0 -20
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +0 -85
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +0 -34
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +0 -60
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoice_imports_provider.rb.erb +0 -32
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator.rb +0 -31
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator.rb +0 -31
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +0 -40
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +0 -57
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +0 -52
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +0 -56
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +0 -72
- data/lib/llm_cost_tracker/masking.rb +0 -39
- data/lib/llm_cost_tracker/parsers/anthropic.rb +0 -193
- data/lib/llm_cost_tracker/parsers/azure.rb +0 -46
- data/lib/llm_cost_tracker/parsers/base.rb +0 -131
- data/lib/llm_cost_tracker/parsers/gemini.rb +0 -232
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -51
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +0 -155
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +0 -228
- data/lib/llm_cost_tracker/pricing/explainer.rb +0 -74
- data/lib/llm_cost_tracker/pricing/lookup.rb +0 -236
- data/lib/llm_cost_tracker/pricing/service_charges.rb +0 -206
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -134
- data/lib/llm_cost_tracker/reconciliation/diff.rb +0 -409
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +0 -44
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +0 -19
- data/lib/llm_cost_tracker/reconciliation/importer.rb +0 -254
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -172
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -142
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model_families"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Providers
|
|
7
|
+
module Openai
|
|
8
|
+
module ServiceCharges
|
|
9
|
+
RESPONSE_OUTPUT_RENAMES = {
|
|
10
|
+
"web_search_call" => "web_search_request",
|
|
11
|
+
"code_interpreter_call" => "container_session"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def line_items_from_output(output_items, request: nil, model: nil)
|
|
17
|
+
deduped = {}
|
|
18
|
+
Array(output_items).each { |item| store_output_item(deduped, item) }
|
|
19
|
+
deduped.values
|
|
20
|
+
.select { |item| billable?(item) }
|
|
21
|
+
.filter_map { |item| build_line_item(item, request: request, model: model) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def service_line_items_for(response, request: nil, model: nil)
|
|
25
|
+
output_items = Array(response["output"])
|
|
26
|
+
output_items += chat_completions_web_search_items(response, model: model) if output_items.empty?
|
|
27
|
+
line_items_from_output(output_items, request: request, model: model)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD = "choices.message.annotations.url_citation"
|
|
31
|
+
CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD = "request.model"
|
|
32
|
+
|
|
33
|
+
def chat_completions_web_search_items(response, model: nil)
|
|
34
|
+
return [] unless response["choices"]
|
|
35
|
+
|
|
36
|
+
provider_field = chat_completions_search_provider_field(response["choices"], model)
|
|
37
|
+
return [] unless provider_field
|
|
38
|
+
|
|
39
|
+
[{ "type" => "web_search_call", "id" => response["id"], "action" => { "type" => "search" },
|
|
40
|
+
"provider_field" => provider_field }]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def chat_completions_search_provider_field(choices, model)
|
|
44
|
+
return CHAT_COMPLETIONS_ANNOTATION_PROVIDER_FIELD if chat_completions_used_web_search?(choices)
|
|
45
|
+
return CHAT_COMPLETIONS_SEARCH_MODEL_PROVIDER_FIELD if chat_completions_search_model?(model)
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def chat_completions_used_web_search?(choices)
|
|
51
|
+
Array(choices).any? do |choice|
|
|
52
|
+
Array(choice.dig("message", "annotations")).any? do |annotation|
|
|
53
|
+
annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def billable?(item)
|
|
59
|
+
return false unless item.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
dimension = output_dimension(item["type"])
|
|
62
|
+
return false unless dimension
|
|
63
|
+
return true unless dimension == "web_search_request"
|
|
64
|
+
|
|
65
|
+
action_type = item.dig("action", "type")
|
|
66
|
+
action_type.nil? || action_type == "search"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def store_output_item(output_items, item)
|
|
70
|
+
return unless item.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
dimension = output_dimension(item["type"])
|
|
73
|
+
return unless dimension
|
|
74
|
+
|
|
75
|
+
key = if dimension == "container_session" && item["container_id"]
|
|
76
|
+
"#{dimension}:#{item['container_id']}"
|
|
77
|
+
else
|
|
78
|
+
item["id"] || "#{item['type']}:#{output_items.length}"
|
|
79
|
+
end
|
|
80
|
+
output_items[key] = item
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_line_item(item, request: nil, model: nil)
|
|
84
|
+
return nil unless item.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
dimension_key = dimension_key_for(item, request: request, model: model)
|
|
87
|
+
return nil unless dimension_key
|
|
88
|
+
|
|
89
|
+
provider_item_id = if dimension_key == "container_session"
|
|
90
|
+
item["container_id"] || item["id"]
|
|
91
|
+
else
|
|
92
|
+
item["id"]
|
|
93
|
+
end
|
|
94
|
+
Charges::LineItem.build(
|
|
95
|
+
dimension_key: dimension_key,
|
|
96
|
+
quantity: 1,
|
|
97
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
98
|
+
pricing_basis: "provider_usage",
|
|
99
|
+
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
100
|
+
provider_item_id: provider_item_id,
|
|
101
|
+
details: line_item_details(item)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dimension_key_for(item, request:, model:)
|
|
106
|
+
dimension = output_dimension(item["type"])
|
|
107
|
+
return dimension unless dimension == "web_search_request"
|
|
108
|
+
return dimension 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 output_dimension(type)
|
|
114
|
+
key = RESPONSE_OUTPUT_RENAMES[type] || type
|
|
115
|
+
dimension = Usage::Catalog[key]
|
|
116
|
+
key if dimension && dimension.token_key.nil?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def web_search_preview_used?(request)
|
|
120
|
+
tools = request && (request[:tools] || request["tools"])
|
|
121
|
+
Array(tools).any? do |tool|
|
|
122
|
+
type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
|
|
123
|
+
type.to_s.include?("web_search_preview")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def chat_completions_search_model?(model)
|
|
128
|
+
name = local_model_name(model)
|
|
129
|
+
name && ModelFamilies.chat_completions_search?(name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def reasoning_model?(model)
|
|
133
|
+
name = local_model_name(model)
|
|
134
|
+
name && ModelFamilies.reasoning?(name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def local_model_name(model)
|
|
138
|
+
return nil unless model
|
|
139
|
+
|
|
140
|
+
model.to_s.split("/", 2).last
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def line_item_details(item)
|
|
144
|
+
{
|
|
145
|
+
status: item["status"],
|
|
146
|
+
action_type: item.dig("action", "type"),
|
|
147
|
+
container_id: item["container_id"]
|
|
148
|
+
}.compact
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def openai_stream_service_line_items(events, request: nil, model: nil)
|
|
152
|
+
output_items = []
|
|
153
|
+
each_event_data(events) do |data|
|
|
154
|
+
output_items.concat(Array(data.dig("response", "output")))
|
|
155
|
+
output_items << data["item"] if data["item"]
|
|
156
|
+
end
|
|
157
|
+
line_items_from_output(output_items, request: request, model: model)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def transcription_line_items(usage)
|
|
161
|
+
return [] unless usage
|
|
162
|
+
|
|
163
|
+
type = (usage[:type] || usage["type"]).to_s
|
|
164
|
+
return [] unless type == "duration"
|
|
165
|
+
|
|
166
|
+
seconds = (usage[:seconds] || usage["seconds"]).to_f
|
|
167
|
+
return [] unless seconds.positive?
|
|
168
|
+
|
|
169
|
+
[Charges::LineItem.build(
|
|
170
|
+
dimension_key: "transcription_minute",
|
|
171
|
+
quantity: (seconds / 60.0).ceil,
|
|
172
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
173
|
+
pricing_basis: "provider_usage",
|
|
174
|
+
provider_field: "usage.seconds",
|
|
175
|
+
details: { seconds: seconds }
|
|
176
|
+
)]
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model_families"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Providers
|
|
7
|
+
module Openai
|
|
8
|
+
module UsageExtractor
|
|
9
|
+
INPUT_DETAIL_KEYS = %i[input_tokens_details input_token_details prompt_tokens_details].freeze
|
|
10
|
+
OUTPUT_DETAIL_KEYS = %i[output_tokens_details output_token_details completion_tokens_details].freeze
|
|
11
|
+
def self.token_usage(usage, model: nil)
|
|
12
|
+
input_tokens = (usage[:input_tokens] || usage[:prompt_tokens]).to_i
|
|
13
|
+
output_tokens = (usage[:output_tokens] || usage[:completion_tokens]).to_i
|
|
14
|
+
cache_read = cache_read_input_tokens(usage)
|
|
15
|
+
audio_input = audio_input_tokens(usage)
|
|
16
|
+
audio_output = audio_output_tokens(usage)
|
|
17
|
+
image_input = image_input_tokens(usage)
|
|
18
|
+
image_output, regular_output = split_output(
|
|
19
|
+
output_tokens: output_tokens,
|
|
20
|
+
image_output_details: image_output_tokens(usage),
|
|
21
|
+
text_output_details: text_output_tokens(usage),
|
|
22
|
+
audio_output: audio_output,
|
|
23
|
+
default_to_image: ModelFamilies.image_output?(model)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
Usage::TokenUsage.build(
|
|
27
|
+
input_tokens: [input_tokens - cache_read - audio_input - image_input, 0].max,
|
|
28
|
+
output_tokens: regular_output,
|
|
29
|
+
total_tokens: usage[:total_tokens],
|
|
30
|
+
cache_read_input_tokens: cache_read,
|
|
31
|
+
audio_input_tokens: audio_input,
|
|
32
|
+
audio_output_tokens: audio_output,
|
|
33
|
+
image_input_tokens: image_input,
|
|
34
|
+
image_output_tokens: image_output,
|
|
35
|
+
hidden_output_tokens: hidden_output_tokens(usage)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.split_output(output_tokens:,
|
|
40
|
+
image_output_details:,
|
|
41
|
+
text_output_details:,
|
|
42
|
+
audio_output:,
|
|
43
|
+
default_to_image: false)
|
|
44
|
+
if image_output_details.zero? && text_output_details.zero?
|
|
45
|
+
remainder = [output_tokens - audio_output, 0].max
|
|
46
|
+
return default_to_image ? [remainder, 0] : [0, remainder]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
text_output = text_output_details
|
|
50
|
+
text_output = [output_tokens - image_output_details - audio_output, 0].max if text_output.zero?
|
|
51
|
+
[image_output_details, text_output]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.cache_read_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :cached_tokens)
|
|
55
|
+
def self.hidden_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :reasoning_tokens)
|
|
56
|
+
def self.audio_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :audio_tokens)
|
|
57
|
+
def self.audio_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :audio_tokens)
|
|
58
|
+
def self.image_input_tokens(usage) = detail(usage, INPUT_DETAIL_KEYS, :image_tokens)
|
|
59
|
+
def self.image_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :image_tokens)
|
|
60
|
+
def self.text_output_tokens(usage) = detail(usage, OUTPUT_DETAIL_KEYS, :text_tokens)
|
|
61
|
+
|
|
62
|
+
def self.detail(usage, containers, key)
|
|
63
|
+
containers.each do |container|
|
|
64
|
+
value = usage.dig(container, key)
|
|
65
|
+
return value.to_i if value
|
|
66
|
+
end
|
|
67
|
+
0
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module OpenaiCompatible
|
|
6
|
+
class Parser < LlmCostTracker::Parsers::Base
|
|
7
|
+
include Openai::ResponseParser
|
|
8
|
+
|
|
9
|
+
TRACKED_PATH_SUFFIXES = %w[/chat/completions /completions /embeddings /responses].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def match?(url)
|
|
13
|
+
match_uri?(url, path_suffixes: TRACKED_PATH_SUFFIXES) { |uri| provider_for_uri(uri) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def provider_names
|
|
17
|
+
custom = LlmCostTracker.configuration.openai_compatible_providers.each_value.map do |provider|
|
|
18
|
+
provider.to_s.downcase
|
|
19
|
+
end
|
|
20
|
+
["openai_compatible", *custom].uniq
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def provider_for_uri(uri)
|
|
24
|
+
return nil unless uri
|
|
25
|
+
|
|
26
|
+
LlmCostTracker.configuration.openai_compatible_providers[uri.host.to_s.downcase]&.to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def provider_for(request_url)
|
|
31
|
+
self.class.provider_for_uri(parsed_uri(request_url)) || "openai_compatible"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Anthropic
|
|
6
|
+
autoload :Parser, "llm_cost_tracker/providers/anthropic/parser"
|
|
7
|
+
autoload :UsageExtractor, "llm_cost_tracker/providers/anthropic/usage_extractor"
|
|
8
|
+
autoload :ResponseParser, "llm_cost_tracker/providers/anthropic/response_parser"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module Azure
|
|
12
|
+
autoload :Hosts, "llm_cost_tracker/providers/azure/hosts"
|
|
13
|
+
autoload :Parser, "llm_cost_tracker/providers/azure/parser"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Gemini
|
|
17
|
+
autoload :ModelFamilies, "llm_cost_tracker/providers/gemini/model_families"
|
|
18
|
+
autoload :Parser, "llm_cost_tracker/providers/gemini/parser"
|
|
19
|
+
autoload :UsageExtractor, "llm_cost_tracker/providers/gemini/usage_extractor"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Openai
|
|
23
|
+
autoload :Hosts, "llm_cost_tracker/providers/openai/hosts"
|
|
24
|
+
autoload :ModelFamilies, "llm_cost_tracker/providers/openai/model_families"
|
|
25
|
+
autoload :Parser, "llm_cost_tracker/providers/openai/parser"
|
|
26
|
+
autoload :ServiceCharges, "llm_cost_tracker/providers/openai/service_charges"
|
|
27
|
+
autoload :UsageExtractor, "llm_cost_tracker/providers/openai/usage_extractor"
|
|
28
|
+
autoload :ResponseParser, "llm_cost_tracker/providers/openai/response_parser"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module OpenaiCompatible
|
|
32
|
+
autoload :Parser, "llm_cost_tracker/providers/openai_compatible/parser"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -13,16 +13,9 @@ module LlmCostTracker
|
|
|
13
13
|
require_relative "generators/llm_cost_tracker/prices_generator"
|
|
14
14
|
require_relative "generators/llm_cost_tracker/call_rollups_generator"
|
|
15
15
|
require_relative "generators/llm_cost_tracker/async_ingestion_generator"
|
|
16
|
-
require_relative "generators/llm_cost_tracker/reconciliation_generator"
|
|
17
16
|
require_relative "generators/llm_cost_tracker/upgrade_call_rollups_provider_generator"
|
|
18
17
|
require_relative "generators/llm_cost_tracker/upgrade_image_tokens_generator"
|
|
19
18
|
require_relative "generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator"
|
|
20
|
-
require_relative "generators/llm_cost_tracker/upgrade_provider_invoice_imports_provider_generator"
|
|
21
|
-
require_relative "generators/llm_cost_tracker/upgrade_provider_invoices_metadata_index_generator"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
rake_tasks do
|
|
25
|
-
load File.expand_path("../tasks/llm_cost_tracker.rake", __dir__)
|
|
26
19
|
end
|
|
27
20
|
end
|
|
28
21
|
end
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/integer/time"
|
|
4
4
|
|
|
5
|
-
require_relative "../
|
|
5
|
+
require_relative "../charges/cost_status"
|
|
6
6
|
require_relative "../ledger"
|
|
7
7
|
|
|
8
8
|
module LlmCostTracker
|
|
9
|
-
|
|
9
|
+
module Report
|
|
10
10
|
Data = ::Data.define(
|
|
11
11
|
:days,
|
|
12
12
|
:from_time,
|
|
@@ -58,8 +58,7 @@ module LlmCostTracker
|
|
|
58
58
|
"COALESCE(SUM(total_cost), 0) AS total_cost, " \
|
|
59
59
|
"COUNT(*) AS requests_count, " \
|
|
60
60
|
"AVG(latency_ms) AS average_latency_ms, " \
|
|
61
|
-
"COALESCE(SUM(CASE WHEN
|
|
62
|
-
"OR cost_status IN ('#{Billing::CostStatus::UNKNOWN}', '#{Billing::CostStatus::PARTIAL}') " \
|
|
61
|
+
"COALESCE(SUM(CASE WHEN #{Charges::CostStatus.unknown_pricing_sql} " \
|
|
63
62
|
"THEN 1 ELSE 0 END), 0) AS unknown_pricing_count"
|
|
64
63
|
)
|
|
65
64
|
.take
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
|
-
|
|
4
|
+
module 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
|
|
@@ -21,17 +21,6 @@ module LlmCostTracker
|
|
|
21
21
|
deleted
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def prune_invoice_imports(older_than:, now: Time.now.utc)
|
|
25
|
-
cutoff = resolve_cutoff(older_than, now)
|
|
26
|
-
require_relative "ledger"
|
|
27
|
-
return 0 unless LlmCostTracker::ProviderInvoiceImport.table_exists?
|
|
28
|
-
|
|
29
|
-
LlmCostTracker::ProviderInvoiceImport
|
|
30
|
-
.where(state: %w[completed failed])
|
|
31
|
-
.where(finished_at: ...cutoff)
|
|
32
|
-
.delete_all
|
|
33
|
-
end
|
|
34
|
-
|
|
35
24
|
def prune_inbox(older_than:, now: Time.now.utc)
|
|
36
25
|
cutoff = resolve_cutoff(older_than, now)
|
|
37
26
|
require_relative "ingestion"
|
|
@@ -70,23 +59,21 @@ module LlmCostTracker
|
|
|
70
59
|
def prune_batch(cutoff, batch_size)
|
|
71
60
|
LlmCostTracker::Call.transaction do
|
|
72
61
|
cache_rollups = LlmCostTracker.configuration.cache_rollups
|
|
73
|
-
rows =
|
|
62
|
+
rows = prunable_rows(cutoff, batch_size, with_rollup_columns: cache_rollups)
|
|
74
63
|
next 0 if rows.empty?
|
|
75
64
|
|
|
76
|
-
ids = cache_rollups ? rows.map(&:
|
|
65
|
+
ids = cache_rollups ? rows.map(&:id) : rows
|
|
77
66
|
deleted = LlmCostTracker::Call.where(id: ids).delete_all
|
|
78
67
|
LlmCostTracker::Ledger::Rollups.decrement!(rows) if cache_rollups && deleted.positive?
|
|
79
68
|
deleted
|
|
80
69
|
end
|
|
81
70
|
end
|
|
82
71
|
|
|
83
|
-
def
|
|
72
|
+
def prunable_rows(cutoff, batch_size, with_rollup_columns:)
|
|
84
73
|
relation = LlmCostTracker::Call.where(tracked_at: ...cutoff).order(:id).limit(batch_size).lock
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
relation.pluck(:id)
|
|
89
|
-
end
|
|
74
|
+
return relation.pluck(:id) unless with_rollup_columns
|
|
75
|
+
|
|
76
|
+
relation.select(:id, :tracked_at, :total_cost, :pricing_snapshot, :provider).to_a
|
|
90
77
|
end
|
|
91
78
|
end
|
|
92
79
|
end
|
|
@@ -17,14 +17,17 @@ module LlmCostTracker
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def tags
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
config = LlmCostTracker.configuration
|
|
21
|
+
base = config.static_sanitized_default_tags ||
|
|
22
|
+
Sanitizer.call(call_default_tags(config.default_tags).to_h)
|
|
23
|
+
base.merge(*Array(ActiveSupport::IsolatedExecutionState[KEY]))
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
|
|
26
|
+
def call_default_tags(proc_or_lambda)
|
|
27
|
+
proc_or_lambda.call
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Logging.warn("LlmCostTracker default_tags proc raised: #{e.class}: #{e.message}; using empty default tags")
|
|
30
|
+
{}
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
end
|
|
@@ -27,10 +27,20 @@ module LlmCostTracker
|
|
|
27
27
|
limit = [config.max_tag_value_bytesize.to_i, 0].max
|
|
28
28
|
max_count = [config.max_tag_count.to_i, 0].max
|
|
29
29
|
tags.to_a.last(max_count).each_with_object({}) do |(key, value), sanitized|
|
|
30
|
+
next unless valid_key?(key)
|
|
31
|
+
|
|
30
32
|
sanitized[key] = sanitized_value(key, value, redacted, limit)
|
|
31
33
|
end
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
def valid_key?(key)
|
|
37
|
+
Tags::Key.validate!(key)
|
|
38
|
+
true
|
|
39
|
+
rescue ArgumentError => e
|
|
40
|
+
Logging.warn("LlmCostTracker tag key invalid: #{e.message}; skipping")
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
34
44
|
def cap(tags, config: LlmCostTracker.configuration)
|
|
35
45
|
tags = (tags || {}).to_h
|
|
36
46
|
max_count = [config.max_tag_count.to_i, 0].max
|
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module Timing
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def now_monotonic
|
|
5
|
+
def self.now_monotonic
|
|
8
6
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
9
7
|
end
|
|
10
8
|
|
|
11
|
-
def elapsed_ms(started_at)
|
|
9
|
+
def self.elapsed_ms(started_at)
|
|
12
10
|
((now_monotonic - started_at) * 1000).round
|
|
13
11
|
end
|
|
14
12
|
end
|