llm_cost_tracker 0.11.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 +55 -0
- data/README.md +7 -4
- data/app/assets/llm_cost_tracker/application.css +8 -7
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -5
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +1 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +6 -15
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -11
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +4 -4
- 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 +30 -44
- data/app/services/llm_cost_tracker/dashboard/setup_state.rb +4 -60
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +1 -7
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +0 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/calls/show.html.erb +31 -23
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +8 -8
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +62 -117
- data/app/views/llm_cost_tracker/models/index.html.erb +5 -5
- data/app/views/llm_cost_tracker/pricing/index.html.erb +2 -2
- data/app/views/llm_cost_tracker/shared/_filter_pill_model.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_provider.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -3
- data/app/views/llm_cost_tracker/tags/show.html.erb +10 -10
- data/config/routes.rb +2 -3
- data/lib/llm_cost_tracker/budget.rb +24 -26
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +34 -0
- data/lib/llm_cost_tracker/capture/sse.rb +1 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +28 -36
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +17 -28
- 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 -44
- 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 +5 -69
- 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/ingestion/batch.rb +39 -8
- data/lib/llm_cost_tracker/ingestion/inbox.rb +7 -8
- 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 +92 -106
- 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 +70 -276
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +87 -99
- 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 +8 -18
- data/lib/llm_cost_tracker/logging.rb +4 -21
- data/lib/llm_cost_tracker/middleware/faraday.rb +61 -50
- data/lib/llm_cost_tracker/parsers.rb +139 -26
- 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 +40 -52
- 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 -278
- 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 +63 -39
- 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 -3
- data/lib/llm_cost_tracker/report/data.rb +3 -4
- data/lib/llm_cost_tracker/report/formatter.rb +1 -1
- 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 +81 -55
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +0 -100
- 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 -174
- 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 -36
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_provider_invoices_metadata_index.rb.erb +0 -27
- 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 -176
- 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 -230
- data/lib/llm_cost_tracker/parsers/openai.rb +0 -41
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +0 -45
- 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/server_tools.rb +0 -15
- data/lib/llm_cost_tracker/providers/anthropic/tier_classification.rb +0 -22
- data/lib/llm_cost_tracker/reconcile_tasks.rb +0 -131
- 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 -249
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +0 -148
- data/lib/llm_cost_tracker/reconciliation/sources/coercion.rb +0 -40
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +0 -20
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +0 -118
- data/lib/llm_cost_tracker/reconciliation.rb +0 -118
- data/lib/llm_cost_tracker/token_usage.rb +0 -93
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../../billing/line_item"
|
|
4
3
|
require_relative "model_families"
|
|
5
4
|
|
|
6
5
|
module LlmCostTracker
|
|
7
6
|
module Providers
|
|
8
7
|
module Openai
|
|
9
8
|
module ServiceCharges
|
|
10
|
-
|
|
11
|
-
"web_search_call" =>
|
|
12
|
-
"
|
|
13
|
-
"code_interpreter_call" => :container_session,
|
|
14
|
-
"mcp_call" => :mcp_call
|
|
9
|
+
RESPONSE_OUTPUT_RENAMES = {
|
|
10
|
+
"web_search_call" => "web_search_request",
|
|
11
|
+
"code_interpreter_call" => "container_session"
|
|
15
12
|
}.freeze
|
|
16
13
|
|
|
17
14
|
module_function
|
|
@@ -53,7 +50,7 @@ module LlmCostTracker
|
|
|
53
50
|
def chat_completions_used_web_search?(choices)
|
|
54
51
|
Array(choices).any? do |choice|
|
|
55
52
|
Array(choice.dig("message", "annotations")).any? do |annotation|
|
|
56
|
-
annotation.is_a?(Hash) && annotation["type"] == "url_citation"
|
|
53
|
+
annotation.is_a?(Hash) && annotation["type"].to_s == "url_citation"
|
|
57
54
|
end
|
|
58
55
|
end
|
|
59
56
|
end
|
|
@@ -61,20 +58,22 @@ module LlmCostTracker
|
|
|
61
58
|
def billable?(item)
|
|
62
59
|
return false unless item.is_a?(Hash)
|
|
63
60
|
|
|
64
|
-
|
|
65
|
-
return false unless
|
|
66
|
-
return true unless
|
|
61
|
+
dimension = output_dimension(item["type"])
|
|
62
|
+
return false unless dimension
|
|
63
|
+
return true unless dimension == "web_search_request"
|
|
67
64
|
|
|
68
65
|
action_type = item.dig("action", "type")
|
|
69
66
|
action_type.nil? || action_type == "search"
|
|
70
67
|
end
|
|
71
68
|
|
|
72
69
|
def store_output_item(output_items, item)
|
|
73
|
-
return unless item.is_a?(Hash)
|
|
70
|
+
return unless item.is_a?(Hash)
|
|
74
71
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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']}"
|
|
78
77
|
else
|
|
79
78
|
item["id"] || "#{item['type']}:#{output_items.length}"
|
|
80
79
|
end
|
|
@@ -84,62 +83,68 @@ module LlmCostTracker
|
|
|
84
83
|
def build_line_item(item, request: nil, model: nil)
|
|
85
84
|
return nil unless item.is_a?(Hash)
|
|
86
85
|
|
|
87
|
-
|
|
88
|
-
return nil unless
|
|
86
|
+
dimension_key = dimension_key_for(item, request: request, model: model)
|
|
87
|
+
return nil unless dimension_key
|
|
89
88
|
|
|
90
|
-
provider_item_id = if
|
|
89
|
+
provider_item_id = if dimension_key == "container_session"
|
|
91
90
|
item["container_id"] || item["id"]
|
|
92
91
|
else
|
|
93
92
|
item["id"]
|
|
94
93
|
end
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
Charges::LineItem.build(
|
|
95
|
+
dimension_key: dimension_key,
|
|
97
96
|
quantity: 1,
|
|
98
|
-
cost_status:
|
|
99
|
-
pricing_basis:
|
|
97
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
98
|
+
pricing_basis: "provider_usage",
|
|
100
99
|
provider_field: item["provider_field"] || "response.output.#{item['type']}",
|
|
101
100
|
provider_item_id: provider_item_id,
|
|
102
101
|
details: line_item_details(item)
|
|
103
102
|
)
|
|
104
103
|
end
|
|
105
104
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
-
return
|
|
109
|
-
return
|
|
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
|
|
110
112
|
|
|
111
|
-
|
|
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?
|
|
112
117
|
end
|
|
113
118
|
|
|
114
119
|
def web_search_preview_used?(request)
|
|
115
120
|
tools = request && (request[:tools] || request["tools"])
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
tools.any? do |tool|
|
|
121
|
+
Array(tools).any? do |tool|
|
|
119
122
|
type = tool.is_a?(Hash) ? (tool[:type] || tool["type"]) : tool
|
|
120
123
|
type.to_s.include?("web_search_preview")
|
|
121
124
|
end
|
|
122
125
|
end
|
|
123
126
|
|
|
124
127
|
def chat_completions_search_model?(model)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
name = model.to_s.split("/", 2).last
|
|
128
|
-
ModelFamilies.chat_completions_search?(name)
|
|
128
|
+
name = local_model_name(model)
|
|
129
|
+
name && ModelFamilies.chat_completions_search?(name)
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
def reasoning_model?(model)
|
|
132
|
-
|
|
133
|
+
name = local_model_name(model)
|
|
134
|
+
name && ModelFamilies.reasoning?(name)
|
|
135
|
+
end
|
|
133
136
|
|
|
134
|
-
|
|
135
|
-
|
|
137
|
+
def local_model_name(model)
|
|
138
|
+
return nil unless model
|
|
139
|
+
|
|
140
|
+
model.to_s.split("/", 2).last
|
|
136
141
|
end
|
|
137
142
|
|
|
138
143
|
def line_item_details(item)
|
|
139
144
|
{
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
status: item["status"],
|
|
146
|
+
action_type: item.dig("action", "type"),
|
|
147
|
+
container_id: item["container_id"]
|
|
143
148
|
}.compact
|
|
144
149
|
end
|
|
145
150
|
|
|
@@ -151,6 +156,25 @@ module LlmCostTracker
|
|
|
151
156
|
end
|
|
152
157
|
line_items_from_output(output_items, request: request, model: model)
|
|
153
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
|
|
154
178
|
end
|
|
155
179
|
end
|
|
156
180
|
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,12 +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
19
|
end
|
|
23
20
|
end
|
|
24
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
|
|
@@ -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
|
|
@@ -1,47 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/object/blank"
|
|
4
|
+
require "active_support/notifications"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
|
|
6
7
|
require_relative "ingestion"
|
|
7
8
|
require_relative "ledger"
|
|
8
|
-
require_relative "logging"
|
|
9
9
|
require_relative "pricing"
|
|
10
|
-
require_relative "billing/cost_status"
|
|
11
10
|
|
|
12
11
|
module LlmCostTracker
|
|
13
|
-
|
|
12
|
+
module Tracker
|
|
14
13
|
EVENT_NAME = "llm_request.llm_cost_tracker"
|
|
15
14
|
|
|
16
15
|
class << self
|
|
17
|
-
def
|
|
16
|
+
def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil, enforce_budget: false)
|
|
18
17
|
return unless LlmCostTracker.configuration.enabled
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def record(event:, latency_ms: nil, pricing_mode: nil, metadata: {}, context_tags: nil)
|
|
24
|
-
return unless LlmCostTracker.configuration.enabled
|
|
25
|
-
|
|
26
|
-
pricing_mode = Pricing::Mode.normalize(pricing_mode) || event.pricing_mode
|
|
27
|
-
cost_data, pricing_snapshot, priced_line_items = Pricing.calculate(
|
|
19
|
+
pricing_mode ||= event.pricing_mode
|
|
20
|
+
calculation = Pricing::Calculation.for(
|
|
28
21
|
provider: event.provider,
|
|
29
22
|
model: event.model,
|
|
30
23
|
tokens: event.token_usage,
|
|
31
24
|
line_items: event.line_items,
|
|
32
|
-
pricing_mode: pricing_mode
|
|
25
|
+
pricing_mode: pricing_mode,
|
|
26
|
+
usage_source: event.usage_source
|
|
33
27
|
)
|
|
34
28
|
|
|
35
|
-
if
|
|
29
|
+
if enforce_budget
|
|
30
|
+
Budget.enforce!(provider: event.provider,
|
|
31
|
+
model: event.model,
|
|
32
|
+
estimate: calculation.cost&.total,
|
|
33
|
+
force: true)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if calculation.token_cost.nil? && event.token_usage.total_tokens.positive? &&
|
|
37
|
+
calculation.priced_line_items.none?(&:priced?)
|
|
36
38
|
Pricing::Unknown.process(event.model)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
event = build_event(
|
|
40
42
|
event: event,
|
|
41
|
-
|
|
42
|
-
cost_data: cost_data,
|
|
43
|
-
pricing_snapshot: pricing_snapshot,
|
|
44
|
-
line_items: priced_line_items,
|
|
43
|
+
calculation: calculation,
|
|
45
44
|
metadata: metadata,
|
|
46
45
|
latency_ms: latency_ms,
|
|
47
46
|
context_tags: context_tags
|
|
@@ -51,7 +50,7 @@ module LlmCostTracker
|
|
|
51
50
|
Ingestion::Inbox.save(event)
|
|
52
51
|
Ingestion::Worker.ensure_started
|
|
53
52
|
else
|
|
54
|
-
Ledger::Store.insert(event
|
|
53
|
+
Ledger::Store.insert(event)
|
|
55
54
|
end
|
|
56
55
|
|
|
57
56
|
yield if block_given?
|
|
@@ -71,29 +70,18 @@ module LlmCostTracker
|
|
|
71
70
|
Logging.warn("Subscriber raised on #{EVENT_NAME}: #{e.class}: #{e.message}")
|
|
72
71
|
end
|
|
73
72
|
|
|
74
|
-
def build_event(event:,
|
|
75
|
-
metadata:, latency_ms:, context_tags:)
|
|
73
|
+
def build_event(event:, calculation:, metadata:, latency_ms:, context_tags:)
|
|
76
74
|
context_tags = (context_tags || LlmCostTracker::Tags::Context.tags).to_h
|
|
77
|
-
cost = Pricing.combine_with_service_lines(cost_data, line_items)
|
|
78
|
-
cost_status = Billing::CostStatus.call(
|
|
79
|
-
token_usage: event.token_usage,
|
|
80
|
-
usage_source: event.usage_source,
|
|
81
|
-
token_cost: cost_data,
|
|
82
|
-
token_pricing_partial: Pricing.token_pricing_partial?(event.token_usage, cost_data),
|
|
83
|
-
service_line_items: line_items.reject(&:token?),
|
|
84
|
-
total_cost: cost&.fetch(:total_cost, nil)
|
|
85
|
-
)
|
|
86
|
-
|
|
87
75
|
event.with(
|
|
88
76
|
event_id: SecureRandom.uuid,
|
|
89
|
-
pricing_mode:
|
|
90
|
-
cost: cost,
|
|
77
|
+
pricing_mode: calculation.mode,
|
|
78
|
+
cost: calculation.cost,
|
|
91
79
|
tags: build_tags(context_tags: context_tags, metadata: metadata),
|
|
92
80
|
latency_ms: finite_latency_ms(latency_ms),
|
|
93
81
|
tracked_at: Time.now.utc,
|
|
94
|
-
cost_status: cost_status,
|
|
95
|
-
pricing_snapshot:
|
|
96
|
-
line_items:
|
|
82
|
+
cost_status: calculation.cost_status,
|
|
83
|
+
pricing_snapshot: calculation.snapshot,
|
|
84
|
+
line_items: calculation.priced_line_items
|
|
97
85
|
)
|
|
98
86
|
end
|
|
99
87
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/module/delegation"
|
|
4
|
+
require "psych"
|
|
5
|
+
|
|
6
|
+
require_relative "dimension"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Usage
|
|
10
|
+
module Catalog
|
|
11
|
+
DEFINITIONS_PATH = File.expand_path("dimensions.yml", __dir__)
|
|
12
|
+
|
|
13
|
+
DEFAULT_RATE_BASIS_BY_UNIT = {
|
|
14
|
+
"token" => "per_million_tokens",
|
|
15
|
+
"character" => "per_million_characters",
|
|
16
|
+
"request" => "per_request",
|
|
17
|
+
"session" => "per_session",
|
|
18
|
+
"hour" => "per_hour",
|
|
19
|
+
"minute" => "per_minute",
|
|
20
|
+
"image" => "per_image"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
delegate :[], :fetch, to: :index
|
|
25
|
+
|
|
26
|
+
def all
|
|
27
|
+
@all ||= load_definitions.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def token_priced
|
|
31
|
+
@token_priced ||= all.select(&:token_key).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def token_priced_for(kind:, direction:, cache_state:)
|
|
35
|
+
token_priced.find do |dimension|
|
|
36
|
+
dimension.kind == kind && dimension.direction == direction && dimension.cache_state == cache_state
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def index
|
|
43
|
+
@index ||= all.to_h { |dimension| [dimension.key, dimension] }.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def load_definitions
|
|
47
|
+
Psych.safe_load_file(DEFINITIONS_PATH, permitted_classes: [], symbolize_names: true)
|
|
48
|
+
.map { |attributes| build(attributes) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build(attributes)
|
|
52
|
+
rate_basis = attributes[:rate_basis] || DEFAULT_RATE_BASIS_BY_UNIT.fetch(attributes.fetch(:unit))
|
|
53
|
+
Dimension.new(**attributes, rate_basis: rate_basis)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|