llm_cost_tracker 0.8.0 → 0.9.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 +108 -0
- data/README.md +12 -5
- data/app/assets/llm_cost_tracker/application.css +65 -5
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -33
- data/app/controllers/llm_cost_tracker/assets_controller.rb +1 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +5 -7
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +4 -0
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +10 -0
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +5 -1
- data/app/models/llm_cost_tracker/call.rb +0 -3
- data/app/models/llm_cost_tracker/call_line_item.rb +1 -5
- data/app/models/llm_cost_tracker/call_rollup.rb +0 -3
- data/app/models/llm_cost_tracker/call_tag.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +0 -4
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +7 -3
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +33 -4
- data/app/services/llm_cost_tracker/dashboard/filter.rb +6 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +25 -40
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +9 -9
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +91 -52
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +3 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +60 -0
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +45 -3
- data/lib/llm_cost_tracker/billing/components.yml +71 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +1 -1
- data/lib/llm_cost_tracker/budget.rb +4 -2
- data/lib/llm_cost_tracker/capture/stream_collector.rb +93 -20
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +53 -1
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +2 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +26 -0
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +5 -2
- data/lib/llm_cost_tracker/doctor.rb +72 -3
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/event.rb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +13 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +5 -58
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_tags_key_value_index_generator.rb +30 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_image_tokens_generator.rb +29 -0
- data/lib/llm_cost_tracker/ingestion/inbox.rb +0 -1
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +10 -2
- data/lib/llm_cost_tracker/ingestion.rb +48 -10
- data/lib/llm_cost_tracker/integrations/anthropic.rb +24 -5
- data/lib/llm_cost_tracker/integrations/base.rb +22 -5
- data/lib/llm_cost_tracker/integrations/openai.rb +300 -66
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +105 -6
- data/lib/llm_cost_tracker/integrations.rb +19 -1
- data/lib/llm_cost_tracker/ledger/period/totals.rb +21 -5
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -10
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +30 -1
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +3 -3
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +17 -2
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +2 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +2 -2
- data/lib/llm_cost_tracker/ledger/store.rb +14 -14
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +2 -1
- data/lib/llm_cost_tracker/ledger.rb +2 -1
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +88 -29
- data/lib/llm_cost_tracker/parsers/anthropic.rb +22 -7
- data/lib/llm_cost_tracker/parsers/base.rb +5 -1
- data/lib/llm_cost_tracker/parsers/gemini.rb +4 -0
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +5 -1
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +49 -10
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +124 -53
- data/lib/llm_cost_tracker/prices.json +110 -19
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +5 -36
- data/lib/llm_cost_tracker/pricing/lookup.rb +36 -3
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +3 -1
- data/lib/llm_cost_tracker/pricing/service_charges.rb +9 -3
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +3 -1
- data/lib/llm_cost_tracker/pricing.rb +47 -19
- data/lib/llm_cost_tracker/railtie.rb +6 -0
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +4 -1
- data/lib/llm_cost_tracker/retention.rb +15 -2
- data/lib/llm_cost_tracker/tags/context.rb +3 -4
- data/lib/llm_cost_tracker/tags/sanitizer.rb +60 -4
- data/lib/llm_cost_tracker/token_usage.rb +10 -2
- data/lib/llm_cost_tracker/tracker.rb +45 -18
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +9 -0
- data/lib/tasks/llm_cost_tracker.rake +25 -2
- metadata +36 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"metadata": {
|
|
3
|
-
"updated_at": "2026-05-
|
|
3
|
+
"updated_at": "2026-05-08",
|
|
4
4
|
"currency": "USD",
|
|
5
5
|
"unit": "1M tokens",
|
|
6
6
|
"source_urls": [
|
|
@@ -18,10 +18,13 @@
|
|
|
18
18
|
"service_charges": {
|
|
19
19
|
"anthropic": {
|
|
20
20
|
"web_search_request": 10.0,
|
|
21
|
+
"web_fetch_request": 0.0,
|
|
21
22
|
"code_execution_hour": 0.05
|
|
22
23
|
},
|
|
23
24
|
"openai": {
|
|
24
25
|
"web_search_request": 10.0,
|
|
26
|
+
"web_search_preview_request_reasoning": 10.0,
|
|
27
|
+
"web_search_preview_request_non_reasoning": 25.0,
|
|
25
28
|
"file_search_call": 2.5
|
|
26
29
|
}
|
|
27
30
|
},
|
|
@@ -140,13 +143,13 @@
|
|
|
140
143
|
},
|
|
141
144
|
"gemini/gemini-2.0-flash": {
|
|
142
145
|
"input": 0.1,
|
|
143
|
-
"cache_read_input": 0.025,
|
|
144
146
|
"output": 0.4,
|
|
147
|
+
"audio_input": 0.7,
|
|
148
|
+
"cache_read_input": 0.025,
|
|
145
149
|
"batch_input": 0.05,
|
|
146
150
|
"batch_output": 0.2,
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"batch_audio_input": 0.35
|
|
151
|
+
"batch_audio_input": 0.35,
|
|
152
|
+
"batch_cache_read_input": 0.025
|
|
150
153
|
},
|
|
151
154
|
"gemini/gemini-2.0-flash-lite": {
|
|
152
155
|
"input": 0.075,
|
|
@@ -157,52 +160,52 @@
|
|
|
157
160
|
"gemini/gemini-2.5-flash": {
|
|
158
161
|
"input": 0.3,
|
|
159
162
|
"output": 2.5,
|
|
163
|
+
"audio_input": 1.0,
|
|
160
164
|
"cache_read_input": 0.03,
|
|
161
165
|
"batch_input": 0.15,
|
|
162
166
|
"batch_output": 1.25,
|
|
167
|
+
"batch_audio_input": 0.5,
|
|
163
168
|
"batch_cache_read_input": 0.03,
|
|
164
169
|
"flex_input": 0.15,
|
|
165
170
|
"flex_output": 1.25,
|
|
171
|
+
"flex_audio_input": 0.5,
|
|
166
172
|
"flex_cache_read_input": 0.03,
|
|
167
173
|
"priority_input": 0.54,
|
|
168
174
|
"priority_output": 4.5,
|
|
169
|
-
"
|
|
170
|
-
"
|
|
171
|
-
"batch_audio_input": 0.5,
|
|
172
|
-
"flex_audio_input": 0.5,
|
|
173
|
-
"priority_audio_input": 1.8
|
|
175
|
+
"priority_audio_input": 1.8,
|
|
176
|
+
"priority_cache_read_input": 0.054
|
|
174
177
|
},
|
|
175
178
|
"gemini/gemini-2.5-flash-lite": {
|
|
176
179
|
"input": 0.1,
|
|
177
180
|
"output": 0.4,
|
|
181
|
+
"audio_input": 0.3,
|
|
178
182
|
"cache_read_input": 0.01,
|
|
179
183
|
"batch_input": 0.05,
|
|
180
184
|
"batch_output": 0.2,
|
|
185
|
+
"batch_audio_input": 0.15,
|
|
181
186
|
"batch_cache_read_input": 0.01,
|
|
182
187
|
"flex_input": 0.05,
|
|
183
188
|
"flex_output": 0.2,
|
|
189
|
+
"flex_audio_input": 0.15,
|
|
184
190
|
"flex_cache_read_input": 0.01,
|
|
185
191
|
"priority_input": 0.18,
|
|
186
192
|
"priority_output": 0.72,
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
"batch_audio_input": 0.15,
|
|
190
|
-
"flex_audio_input": 0.15,
|
|
191
|
-
"priority_audio_input": 0.54
|
|
193
|
+
"priority_audio_input": 0.54,
|
|
194
|
+
"priority_cache_read_input": 0.018
|
|
192
195
|
},
|
|
193
196
|
"gemini/gemini-2.5-pro": {
|
|
194
197
|
"input": 1.25,
|
|
195
198
|
"output": 10.0,
|
|
196
|
-
"cache_read_input": 0.125,
|
|
197
|
-
"batch_input": 0.625,
|
|
198
|
-
"batch_output": 5.0,
|
|
199
|
-
"batch_cache_read_input": 0.125,
|
|
200
199
|
"_context_price_threshold_tokens": 200000,
|
|
201
200
|
"above_context_input": 2.5,
|
|
202
201
|
"above_context_output": 15.0,
|
|
202
|
+
"cache_read_input": 0.125,
|
|
203
203
|
"above_context_cache_read_input": 0.25,
|
|
204
|
+
"batch_input": 0.625,
|
|
205
|
+
"batch_output": 5.0,
|
|
204
206
|
"above_context_batch_input": 1.25,
|
|
205
207
|
"above_context_batch_output": 7.5,
|
|
208
|
+
"batch_cache_read_input": 0.125,
|
|
206
209
|
"above_context_batch_cache_read_input": 0.25,
|
|
207
210
|
"flex_input": 0.625,
|
|
208
211
|
"flex_output": 5.0,
|
|
@@ -394,6 +397,76 @@
|
|
|
394
397
|
"output": 0.6,
|
|
395
398
|
"audio_output": 20.0
|
|
396
399
|
},
|
|
400
|
+
"openai/text-embedding-3-small": {
|
|
401
|
+
"input": 0.02,
|
|
402
|
+
"batch_input": 0.01
|
|
403
|
+
},
|
|
404
|
+
"openai/text-embedding-3-large": {
|
|
405
|
+
"input": 0.13,
|
|
406
|
+
"batch_input": 0.065
|
|
407
|
+
},
|
|
408
|
+
"openai/text-embedding-ada-002": {
|
|
409
|
+
"input": 0.10,
|
|
410
|
+
"batch_input": 0.05
|
|
411
|
+
},
|
|
412
|
+
"openai/gpt-4o-transcribe": {
|
|
413
|
+
"input": 2.5,
|
|
414
|
+
"audio_input": 6.0,
|
|
415
|
+
"output": 10.0
|
|
416
|
+
},
|
|
417
|
+
"openai/gpt-4o-mini-transcribe": {
|
|
418
|
+
"input": 1.25,
|
|
419
|
+
"audio_input": 3.0,
|
|
420
|
+
"output": 5.0
|
|
421
|
+
},
|
|
422
|
+
"openai/tts-1": {
|
|
423
|
+
"text_to_speech_character": 15.0
|
|
424
|
+
},
|
|
425
|
+
"openai/tts-1-hd": {
|
|
426
|
+
"text_to_speech_character": 30.0
|
|
427
|
+
},
|
|
428
|
+
"openai/gpt-image-1": {
|
|
429
|
+
"input": 5.0,
|
|
430
|
+
"cache_read_input": 1.25,
|
|
431
|
+
"image_input": 10.0,
|
|
432
|
+
"image_output": 40.0,
|
|
433
|
+
"batch_input": 2.5,
|
|
434
|
+
"batch_cache_read_input": 0.63,
|
|
435
|
+
"batch_image_input": 5.0,
|
|
436
|
+
"batch_image_output": 20.0
|
|
437
|
+
},
|
|
438
|
+
"openai/gpt-image-1-mini": {
|
|
439
|
+
"input": 2.0,
|
|
440
|
+
"cache_read_input": 0.2,
|
|
441
|
+
"image_input": 2.5,
|
|
442
|
+
"image_output": 8.0,
|
|
443
|
+
"batch_input": 1.0,
|
|
444
|
+
"batch_cache_read_input": 0.1,
|
|
445
|
+
"batch_image_input": 1.25,
|
|
446
|
+
"batch_image_output": 4.0
|
|
447
|
+
},
|
|
448
|
+
"openai/gpt-image-1.5": {
|
|
449
|
+
"input": 5.0,
|
|
450
|
+
"cache_read_input": 1.25,
|
|
451
|
+
"output": 10.0,
|
|
452
|
+
"image_input": 8.0,
|
|
453
|
+
"image_output": 32.0,
|
|
454
|
+
"batch_input": 2.5,
|
|
455
|
+
"batch_cache_read_input": 0.63,
|
|
456
|
+
"batch_output": 5.0,
|
|
457
|
+
"batch_image_input": 4.0,
|
|
458
|
+
"batch_image_output": 16.0
|
|
459
|
+
},
|
|
460
|
+
"openai/gpt-image-2": {
|
|
461
|
+
"input": 5.0,
|
|
462
|
+
"cache_read_input": 1.25,
|
|
463
|
+
"image_input": 8.0,
|
|
464
|
+
"image_output": 30.0,
|
|
465
|
+
"batch_input": 2.5,
|
|
466
|
+
"batch_cache_read_input": 0.625,
|
|
467
|
+
"batch_image_input": 4.0,
|
|
468
|
+
"batch_image_output": 15.0
|
|
469
|
+
},
|
|
397
470
|
"openai/gpt-5": {
|
|
398
471
|
"input": 1.25,
|
|
399
472
|
"output": 10.0,
|
|
@@ -805,6 +878,24 @@
|
|
|
805
878
|
"input": 1.5,
|
|
806
879
|
"output": 6.0,
|
|
807
880
|
"cache_read_input": 0.375
|
|
881
|
+
},
|
|
882
|
+
"gemini/gemini-3.1-flash-lite": {
|
|
883
|
+
"input": 0.25,
|
|
884
|
+
"output": 1.5,
|
|
885
|
+
"audio_input": 0.5,
|
|
886
|
+
"cache_read_input": 0.025,
|
|
887
|
+
"batch_input": 0.125,
|
|
888
|
+
"batch_output": 0.75,
|
|
889
|
+
"batch_audio_input": 0.25,
|
|
890
|
+
"batch_cache_read_input": 0.0125,
|
|
891
|
+
"flex_input": 0.125,
|
|
892
|
+
"flex_output": 0.75,
|
|
893
|
+
"flex_audio_input": 0.25,
|
|
894
|
+
"flex_cache_read_input": 0.0125,
|
|
895
|
+
"priority_input": 0.45,
|
|
896
|
+
"priority_output": 2.7,
|
|
897
|
+
"priority_audio_input": 0.9,
|
|
898
|
+
"priority_cache_read_input": 0.045
|
|
808
899
|
}
|
|
809
900
|
}
|
|
810
901
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../billing/components"
|
|
4
|
+
require_relative "mode"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
module Pricing
|
|
@@ -8,6 +9,7 @@ module LlmCostTracker
|
|
|
8
9
|
class << self
|
|
9
10
|
def call(usage:, prices:, pricing_mode:)
|
|
10
11
|
context_tier = context_tier?(usage: usage, prices: prices)
|
|
12
|
+
orderings = pricing_mode && Mode.parse(pricing_mode).permutations
|
|
11
13
|
|
|
12
14
|
Billing::Components::TOKEN_PRICED.to_h do |component|
|
|
13
15
|
price_key = component.key
|
|
@@ -16,7 +18,7 @@ module LlmCostTracker
|
|
|
16
18
|
price_for(
|
|
17
19
|
prices: prices,
|
|
18
20
|
key: price_key,
|
|
19
|
-
|
|
21
|
+
orderings: orderings,
|
|
20
22
|
context_tier: context_tier
|
|
21
23
|
)
|
|
22
24
|
else
|
|
@@ -28,10 +30,9 @@ module LlmCostTracker
|
|
|
28
30
|
|
|
29
31
|
private
|
|
30
32
|
|
|
31
|
-
def price_for(prices:, key:,
|
|
32
|
-
return contextual_price(prices: prices, key: key, context_tier: context_tier) unless
|
|
33
|
+
def price_for(prices:, key:, orderings:, context_tier:)
|
|
34
|
+
return contextual_price(prices: prices, key: key, context_tier: context_tier) unless orderings
|
|
33
35
|
|
|
34
|
-
orderings = mode_orderings_for(pricing_mode)
|
|
35
36
|
orderings.each do |mode|
|
|
36
37
|
direct = contextual_price(prices: prices, key: :"#{mode}_#{key}", context_tier: context_tier)
|
|
37
38
|
return direct if direct
|
|
@@ -41,38 +42,6 @@ module LlmCostTracker
|
|
|
41
42
|
derived_mode_price(prices: prices, key: key, modes: orderings, context_tier: context_tier)
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
def mode_orderings_for(pricing_mode)
|
|
45
|
-
mode_string = pricing_mode.to_s
|
|
46
|
-
return [mode_string] unless mode_string.include?("_")
|
|
47
|
-
|
|
48
|
-
tokens = tokenize_mode(mode_string)
|
|
49
|
-
return [mode_string] if tokens.size <= 1
|
|
50
|
-
|
|
51
|
-
[mode_string, *tokens.permutation.map { |permutation| permutation.join("_") }].uniq
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def tokenize_mode(mode_string)
|
|
55
|
-
remaining = mode_string.dup
|
|
56
|
-
tokens = []
|
|
57
|
-
loop do
|
|
58
|
-
break if remaining.empty?
|
|
59
|
-
|
|
60
|
-
compound = COMPOUND_MODE_TOKENS.find { |token| remaining == token || remaining.start_with?("#{token}_") }
|
|
61
|
-
if compound
|
|
62
|
-
tokens << compound
|
|
63
|
-
remaining = remaining.delete_prefix(compound).delete_prefix("_")
|
|
64
|
-
else
|
|
65
|
-
first, _, rest = remaining.partition("_")
|
|
66
|
-
tokens << first
|
|
67
|
-
remaining = rest
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
tokens
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
COMPOUND_MODE_TOKENS = %w[data_residency].freeze
|
|
74
|
-
private_constant :COMPOUND_MODE_TOKENS
|
|
75
|
-
|
|
76
45
|
def contextual_price(prices:, key:, context_tier:)
|
|
77
46
|
return prices[key] unless context_tier
|
|
78
47
|
|
|
@@ -7,6 +7,9 @@ module LlmCostTracker
|
|
|
7
7
|
MUTEX = Mutex.new
|
|
8
8
|
CACHE_MISS = Object.new.freeze
|
|
9
9
|
NO_MATCH = Object.new.freeze
|
|
10
|
+
LOOKUP_CACHE_LIMIT = 2_048
|
|
11
|
+
PRICE_FILE_RECHECK_INTERVAL = 1.0
|
|
12
|
+
private_constant :PRICE_FILE_RECHECK_INTERVAL
|
|
10
13
|
|
|
11
14
|
class << self
|
|
12
15
|
def call(provider:, model:)
|
|
@@ -14,6 +17,8 @@ module LlmCostTracker
|
|
|
14
17
|
model_name = model.to_s
|
|
15
18
|
return nil if model_name.empty?
|
|
16
19
|
|
|
20
|
+
invalidate_cache_if_prices_file_changed!
|
|
21
|
+
|
|
17
22
|
cache_key = [provider_name, model_name]
|
|
18
23
|
cached = cached_lookup(cache_key)
|
|
19
24
|
return cached unless cached.equal?(CACHE_MISS)
|
|
@@ -25,14 +30,41 @@ module LlmCostTracker
|
|
|
25
30
|
|
|
26
31
|
def reset!
|
|
27
32
|
MUTEX.synchronize do
|
|
28
|
-
|
|
29
|
-
@
|
|
30
|
-
@sorted_price_keys_cache = nil
|
|
33
|
+
reset_prices_caches!(signature: nil)
|
|
34
|
+
@prices_file_last_check_at = nil
|
|
31
35
|
end
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
private
|
|
35
39
|
|
|
40
|
+
def invalidate_cache_if_prices_file_changed!
|
|
41
|
+
path = LlmCostTracker.configuration.prices_file
|
|
42
|
+
|
|
43
|
+
unless path
|
|
44
|
+
return if @prices_file_signature.nil?
|
|
45
|
+
|
|
46
|
+
MUTEX.synchronize { reset_prices_caches!(signature: nil) unless @prices_file_signature.nil? }
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
51
|
+
last_check = @prices_file_last_check_at
|
|
52
|
+
return if last_check && (now - last_check) < PRICE_FILE_RECHECK_INTERVAL
|
|
53
|
+
|
|
54
|
+
signature = File.exist?(path) ? File.mtime(path) : nil
|
|
55
|
+
MUTEX.synchronize do
|
|
56
|
+
@prices_file_last_check_at = now
|
|
57
|
+
reset_prices_caches!(signature: signature) if @prices_file_signature != signature
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reset_prices_caches!(signature:)
|
|
62
|
+
@prices_cache = nil
|
|
63
|
+
@lookup_cache = nil
|
|
64
|
+
@sorted_price_keys_cache = nil
|
|
65
|
+
@prices_file_signature = signature
|
|
66
|
+
end
|
|
67
|
+
|
|
36
68
|
def lookup_match(provider_name:, model_name:)
|
|
37
69
|
provider_model = provider_name ? "#{provider_name}/#{model_name}" : model_name
|
|
38
70
|
normalized_model = normalize_model_name(model_name)
|
|
@@ -86,6 +118,7 @@ module LlmCostTracker
|
|
|
86
118
|
def cache_lookup(cache_key, match)
|
|
87
119
|
MUTEX.synchronize do
|
|
88
120
|
values = (@lookup_cache || {}).dup
|
|
121
|
+
values.shift while values.size >= LOOKUP_CACHE_LIMIT
|
|
89
122
|
values[cache_key] = match || NO_MATCH
|
|
90
123
|
@lookup_cache = values.freeze
|
|
91
124
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Pricing
|
|
5
|
+
class Mode
|
|
6
|
+
COMPOUND_MODIFIERS = %w[data_residency].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :modifiers
|
|
9
|
+
|
|
10
|
+
def self.parse(value)
|
|
11
|
+
return value if value.is_a?(self)
|
|
12
|
+
return new([]) if value.nil?
|
|
13
|
+
|
|
14
|
+
new(tokenize(value.to_s))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.tokenize(value)
|
|
18
|
+
remaining = value.to_s.downcase.tr("-", "_")
|
|
19
|
+
tokens = []
|
|
20
|
+
loop do
|
|
21
|
+
break if remaining.empty?
|
|
22
|
+
|
|
23
|
+
compound = COMPOUND_MODIFIERS.find do |token|
|
|
24
|
+
remaining == token || remaining.start_with?("#{token}_")
|
|
25
|
+
end
|
|
26
|
+
if compound
|
|
27
|
+
tokens << compound.to_sym
|
|
28
|
+
remaining = remaining.delete_prefix(compound).delete_prefix("_")
|
|
29
|
+
else
|
|
30
|
+
first, _, rest = remaining.partition("_")
|
|
31
|
+
tokens << first.to_sym unless first.empty?
|
|
32
|
+
remaining = rest
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
tokens
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(modifiers)
|
|
39
|
+
@modifiers = Array(modifiers).map(&:to_sym).uniq.sort
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def empty?
|
|
44
|
+
modifiers.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def include?(modifier)
|
|
48
|
+
modifiers.include?(modifier.to_sym)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def canonical
|
|
52
|
+
modifiers.join("_")
|
|
53
|
+
end
|
|
54
|
+
alias to_s canonical
|
|
55
|
+
|
|
56
|
+
def to_sym
|
|
57
|
+
empty? ? nil : canonical.to_sym
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def permutations
|
|
61
|
+
return [canonical] if modifiers.size <= 1
|
|
62
|
+
|
|
63
|
+
modifiers.permutation.map { |permutation| permutation.join("_") }.uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(self.class) && modifiers == other.modifiers
|
|
68
|
+
end
|
|
69
|
+
alias eql? ==
|
|
70
|
+
|
|
71
|
+
def hash
|
|
72
|
+
modifiers.hash
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -115,6 +115,7 @@ module LlmCostTracker
|
|
|
115
115
|
|
|
116
116
|
def non_negative_float(key, value)
|
|
117
117
|
rate = Float(value)
|
|
118
|
+
raise ArgumentError, "price for #{key.inspect} must be finite (got #{rate})" unless rate.finite?
|
|
118
119
|
raise ArgumentError, "price for #{key.inspect} must be non-negative (got #{rate})" if rate.negative?
|
|
119
120
|
|
|
120
121
|
rate
|
|
@@ -145,8 +146,9 @@ module LlmCostTracker
|
|
|
145
146
|
|
|
146
147
|
def price_key_for(key)
|
|
147
148
|
name = key.is_a?(Symbol) ? key.name : key
|
|
148
|
-
Billing::Components::
|
|
149
|
+
Billing::Components::REGISTRY.each do |candidate|
|
|
149
150
|
return candidate.key if candidate.key.name == name
|
|
151
|
+
next unless candidate.token_key
|
|
150
152
|
|
|
151
153
|
suffix = "_#{candidate.key.name}"
|
|
152
154
|
next unless name.end_with?(suffix)
|
|
@@ -121,14 +121,20 @@ module LlmCostTracker
|
|
|
121
121
|
|
|
122
122
|
def amount_for(key, amount, context:)
|
|
123
123
|
value = BigDecimal(amount.to_s)
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
if value.infinite? || value.nan?
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"service charge price amount for #{key.inspect} in #{context} must be finite"
|
|
127
|
+
end
|
|
128
|
+
if value.negative?
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"service charge price amount for #{key.inspect} in #{context} must be non-negative"
|
|
131
|
+
end
|
|
126
132
|
|
|
127
133
|
value
|
|
128
134
|
end
|
|
129
135
|
|
|
130
136
|
def rate_quantity(component)
|
|
131
|
-
component.
|
|
137
|
+
BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis, 1).to_s)
|
|
132
138
|
end
|
|
133
139
|
|
|
134
140
|
def charge_rate_match(provider:, component:, pricing_mode:)
|
|
@@ -9,10 +9,12 @@ module LlmCostTracker
|
|
|
9
9
|
module Sync
|
|
10
10
|
class RegistryWriter
|
|
11
11
|
YAML_EXTENSIONS = %w[.yml .yaml].freeze
|
|
12
|
+
MANUAL_SOURCE = "manual"
|
|
12
13
|
|
|
13
14
|
def call(path:, registry:)
|
|
14
15
|
FileUtils.mkdir_p(File.dirname(path))
|
|
15
|
-
|
|
16
|
+
merged = merge_with_existing(path: path, registry: registry)
|
|
17
|
+
payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
|
|
16
18
|
temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
17
19
|
File.write(temp_path, payload)
|
|
18
20
|
File.rename(temp_path, path)
|
|
@@ -22,6 +24,53 @@ module LlmCostTracker
|
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
27
|
+
def merge_with_existing(path:, registry:)
|
|
28
|
+
existing = read_existing(path)
|
|
29
|
+
return registry unless existing.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
merged = registry.dup
|
|
32
|
+
merged["models"] = merged_models(registry, existing) if existing["models"].is_a?(Hash)
|
|
33
|
+
if existing["service_charges"].is_a?(Hash)
|
|
34
|
+
merged["service_charges"] = merged_service_charges(registry, existing)
|
|
35
|
+
end
|
|
36
|
+
merged
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def merged_models(registry, existing)
|
|
40
|
+
merged = registry.fetch("models", {}).dup
|
|
41
|
+
existing.fetch("models", {}).each do |model, attrs|
|
|
42
|
+
next unless attrs.is_a?(Hash) && attrs["_source"].to_s == MANUAL_SOURCE
|
|
43
|
+
next if merged.key?(model)
|
|
44
|
+
|
|
45
|
+
merged[model] = attrs
|
|
46
|
+
end
|
|
47
|
+
merged
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def merged_service_charges(registry, existing)
|
|
51
|
+
remote = registry.fetch("service_charges", {})
|
|
52
|
+
existing.fetch("service_charges", {}).each_with_object(remote.dup) do |(provider, charges), merged|
|
|
53
|
+
next unless charges.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
merged[provider] = charges.merge(merged.fetch(provider, {}))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def read_existing(path)
|
|
60
|
+
return nil unless File.exist?(path)
|
|
61
|
+
|
|
62
|
+
contents = File.read(path)
|
|
63
|
+
return nil if contents.strip.empty?
|
|
64
|
+
|
|
65
|
+
if yaml_file?(path)
|
|
66
|
+
YAML.safe_load(contents, permitted_classes: [Symbol, Date, Time])
|
|
67
|
+
else
|
|
68
|
+
JSON.parse(contents)
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
25
74
|
def yaml_file?(path)
|
|
26
75
|
YAML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
27
76
|
end
|
|
@@ -150,8 +150,10 @@ module LlmCostTracker
|
|
|
150
150
|
end
|
|
151
151
|
|
|
152
152
|
def load_registry(path)
|
|
153
|
+
return {} unless File.exist?(path)
|
|
154
|
+
|
|
153
155
|
YAML.safe_load_file(path, aliases: false) || {}
|
|
154
|
-
rescue
|
|
156
|
+
rescue Psych::Exception, ArgumentError, TypeError => e
|
|
155
157
|
raise Error, "Unable to load pricing registry #{path.inspect}: #{e.message}"
|
|
156
158
|
end
|
|
157
159
|
|