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
|
@@ -6,13 +6,15 @@ module LlmCostTracker
|
|
|
6
6
|
module RegistryDiff
|
|
7
7
|
class << self
|
|
8
8
|
def call(current_models, updated_models)
|
|
9
|
-
current_models =
|
|
10
|
-
updated_models =
|
|
9
|
+
current_models = Registry.normalize_price_entries(current_models, context: "current price table")
|
|
10
|
+
updated_models = Registry.normalize_price_entries(updated_models, context: "updated price table")
|
|
11
11
|
|
|
12
12
|
(current_models.keys | updated_models.keys).sort.each_with_object({}) do |model, changes|
|
|
13
13
|
fields = price_field_changes(current_models[model], updated_models[model])
|
|
14
14
|
changes[model] = fields if fields.any?
|
|
15
15
|
end
|
|
16
|
+
rescue ArgumentError, TypeError => e
|
|
17
|
+
raise Error, e.message
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
private
|
|
@@ -29,14 +31,6 @@ module LlmCostTracker
|
|
|
29
31
|
changes[field] = { "from" => from, "to" => to }
|
|
30
32
|
end
|
|
31
33
|
end
|
|
32
|
-
|
|
33
|
-
def normalize_models(models)
|
|
34
|
-
Registry.normalize_price_table(models).transform_values do |price|
|
|
35
|
-
price.to_h { |key, value| [key.name, value] }
|
|
36
|
-
end
|
|
37
|
-
rescue ArgumentError, TypeError => e
|
|
38
|
-
raise Error, e.message
|
|
39
|
-
end
|
|
40
34
|
end
|
|
41
35
|
end
|
|
42
36
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "bigdecimal"
|
|
3
4
|
require "fileutils"
|
|
4
5
|
require "json"
|
|
5
6
|
require "yaml"
|
|
@@ -12,9 +13,8 @@ module LlmCostTracker
|
|
|
12
13
|
MANUAL_SOURCE = "manual"
|
|
13
14
|
|
|
14
15
|
def call(path:, registry:)
|
|
16
|
+
payload = render(path: path, registry: registry)
|
|
15
17
|
FileUtils.mkdir_p(File.dirname(path))
|
|
16
|
-
merged = canonicalize(merge_with_existing(path: path, registry: registry))
|
|
17
|
-
payload = yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
|
|
18
18
|
temp_path = "#{path}.tmp-#{Process.pid}-#{Thread.current.object_id}"
|
|
19
19
|
File.write(temp_path, payload)
|
|
20
20
|
File.rename(temp_path, path)
|
|
@@ -22,6 +22,11 @@ module LlmCostTracker
|
|
|
22
22
|
FileUtils.rm_f(temp_path) if temp_path && File.exist?(temp_path)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def render(path:, registry:)
|
|
26
|
+
merged = canonicalize(merge_with_existing(path: path, registry: registry))
|
|
27
|
+
yaml_file?(path) ? YAML.dump(merged) : "#{JSON.pretty_generate(merged)}\n"
|
|
28
|
+
end
|
|
29
|
+
|
|
25
30
|
private
|
|
26
31
|
|
|
27
32
|
def canonicalize(value)
|
|
@@ -30,6 +35,8 @@ module LlmCostTracker
|
|
|
30
35
|
value.sort_by { |key, _| key.to_s }.to_h { |key, nested| [key, canonicalize(nested)] }
|
|
31
36
|
when Array
|
|
32
37
|
value.map { |element| canonicalize(element) }
|
|
38
|
+
when BigDecimal
|
|
39
|
+
value.to_f
|
|
33
40
|
else
|
|
34
41
|
value
|
|
35
42
|
end
|
|
@@ -79,7 +86,7 @@ module LlmCostTracker
|
|
|
79
86
|
else
|
|
80
87
|
JSON.parse(contents)
|
|
81
88
|
end
|
|
82
|
-
rescue
|
|
89
|
+
rescue Errno::ENOENT, Psych::Exception, JSON::ParserError
|
|
83
90
|
nil
|
|
84
91
|
end
|
|
85
92
|
|
|
@@ -36,7 +36,10 @@ module LlmCostTracker
|
|
|
36
36
|
env["URL"].to_s.strip.presence || DEFAULT_REMOTE_URL
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def refresh(path: DEFAULT_OUTPUT_PATH,
|
|
39
|
+
def refresh(path: DEFAULT_OUTPUT_PATH,
|
|
40
|
+
url: DEFAULT_REMOTE_URL,
|
|
41
|
+
preview: false,
|
|
42
|
+
fetcher: Fetcher.new,
|
|
40
43
|
today: Date.today)
|
|
41
44
|
current = load_registry(path)
|
|
42
45
|
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
@@ -56,7 +59,7 @@ module LlmCostTracker
|
|
|
56
59
|
remote = normalize_remote_registry(response.body, url: url, response: response, today: today)
|
|
57
60
|
unless preview
|
|
58
61
|
RegistryWriter.new.call(path: path, registry: remote)
|
|
59
|
-
|
|
62
|
+
Pricing::Registry.reset!
|
|
60
63
|
end
|
|
61
64
|
refresh_result(
|
|
62
65
|
path: path,
|
|
@@ -69,12 +72,6 @@ module LlmCostTracker
|
|
|
69
72
|
)
|
|
70
73
|
end
|
|
71
74
|
|
|
72
|
-
def invalidate_pricing_caches!
|
|
73
|
-
Pricing::Lookup.reset!
|
|
74
|
-
Pricing::Registry.reset!
|
|
75
|
-
Pricing::ServiceCharges.reset!
|
|
76
|
-
end
|
|
77
|
-
|
|
78
75
|
def check(path: DEFAULT_OUTPUT_PATH, url: DEFAULT_REMOTE_URL, fetcher: Fetcher.new, today: Date.today)
|
|
79
76
|
current = load_registry(path)
|
|
80
77
|
response = fetcher.get(url, etag: current.dig("metadata", "source_version"))
|
|
@@ -119,12 +116,13 @@ module LlmCostTracker
|
|
|
119
116
|
end
|
|
120
117
|
|
|
121
118
|
raw_models = registry.fetch("models", {})
|
|
122
|
-
models = Registry.
|
|
119
|
+
models = Registry.normalize_price_entries(raw_models, context: "remote pricing snapshot")
|
|
120
|
+
.each_with_object({}) do |(model, prices), normalized|
|
|
123
121
|
model_metadata = (raw_models[model] || {}).slice(*Registry::METADATA_KEYS)
|
|
124
|
-
normalized[model] = model_metadata.merge(prices
|
|
122
|
+
normalized[model] = model_metadata.merge(prices)
|
|
125
123
|
end
|
|
126
124
|
service_charges = registry["service_charges"]
|
|
127
|
-
|
|
125
|
+
Registry.rates_from_registry(registry, context: "remote pricing snapshot") if service_charges
|
|
128
126
|
|
|
129
127
|
normalized = {
|
|
130
128
|
"metadata" => metadata.merge(
|
|
@@ -4,7 +4,7 @@ require_relative "../logging"
|
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
module Pricing
|
|
7
|
-
|
|
7
|
+
module Unknown
|
|
8
8
|
MUTEX = Mutex.new
|
|
9
9
|
WARN_CACHE_LIMIT = 1024
|
|
10
10
|
|
|
@@ -22,10 +22,6 @@ module LlmCostTracker
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def reset!
|
|
26
|
-
MUTEX.synchronize { @warned_models = Set.new }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
25
|
private
|
|
30
26
|
|
|
31
27
|
def warn_missing(model)
|
|
@@ -5,308 +5,23 @@ require "bigdecimal"
|
|
|
5
5
|
require "time"
|
|
6
6
|
|
|
7
7
|
require_relative "version"
|
|
8
|
-
require_relative "
|
|
9
|
-
require_relative "
|
|
10
|
-
require_relative "
|
|
11
|
-
require_relative "
|
|
12
|
-
require_relative "pricing/mode"
|
|
8
|
+
require_relative "usage/token_usage"
|
|
9
|
+
require_relative "charges/cost"
|
|
10
|
+
require_relative "charges/cost_status"
|
|
11
|
+
require_relative "pricing/price_key"
|
|
13
12
|
require_relative "pricing/registry"
|
|
14
|
-
require_relative "pricing/
|
|
13
|
+
require_relative "pricing/source"
|
|
14
|
+
require_relative "pricing/matcher"
|
|
15
|
+
require_relative "pricing/service_rates"
|
|
15
16
|
require_relative "pricing/effective_prices"
|
|
16
|
-
require_relative "pricing/explainer"
|
|
17
|
-
require_relative "pricing/service_charges"
|
|
18
17
|
require_relative "pricing/estimator"
|
|
18
|
+
require_relative "pricing/calculation"
|
|
19
19
|
|
|
20
20
|
module LlmCostTracker
|
|
21
|
-
module Pricing
|
|
22
|
-
extend ServiceCharges
|
|
23
|
-
|
|
24
|
-
STANDARD_MODE_VALUES = %i[auto default standard standard_only].freeze
|
|
25
|
-
RATE_DENOMINATOR_TOKENS = 1_000_000
|
|
26
|
-
private_constant :RATE_DENOMINATOR_TOKENS
|
|
27
|
-
|
|
21
|
+
module Pricing
|
|
28
22
|
class << self
|
|
29
|
-
def normalize_mode(value)
|
|
30
|
-
return nil if value.nil?
|
|
31
|
-
|
|
32
|
-
mode = normalize_string_mode(value.to_s)
|
|
33
|
-
return nil unless mode
|
|
34
|
-
|
|
35
|
-
STANDARD_MODE_VALUES.include?(mode) ? nil : mode
|
|
36
|
-
end
|
|
37
|
-
|
|
38
23
|
def cost_for(provider:, model:, tokens:, pricing_mode: nil)
|
|
39
|
-
|
|
40
|
-
provider: provider,
|
|
41
|
-
model: model,
|
|
42
|
-
tokens: tokens,
|
|
43
|
-
pricing_mode: pricing_mode
|
|
44
|
-
)
|
|
45
|
-
return nil unless calculation
|
|
46
|
-
|
|
47
|
-
cost_from(calculation)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def calculate(provider:, model:, tokens:, line_items:, pricing_mode: nil)
|
|
51
|
-
calculation = calculation_for(
|
|
52
|
-
provider: provider,
|
|
53
|
-
model: model,
|
|
54
|
-
tokens: tokens,
|
|
55
|
-
pricing_mode: pricing_mode
|
|
56
|
-
)
|
|
57
|
-
cost_data = calculation && cost_from(calculation)
|
|
58
|
-
snapshot = calculation && snapshot_from(calculation)
|
|
59
|
-
priced = apply_calculation_to_line_items(line_items, calculation,
|
|
60
|
-
provider: provider, pricing_mode: pricing_mode)
|
|
61
|
-
[cost_data, snapshot, priced]
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def price_line_items(provider:, model:, line_items:, pricing_mode: nil)
|
|
65
|
-
token_usage = TokenUsage.build_from_tokens(token_attributes_from(line_items))
|
|
66
|
-
calculation = calculation_for(provider: provider, model: model, tokens: token_usage, pricing_mode: pricing_mode)
|
|
67
|
-
snapshot = calculation && snapshot_from(calculation)
|
|
68
|
-
priced = apply_calculation_to_line_items(line_items, calculation,
|
|
69
|
-
provider: provider, pricing_mode: pricing_mode)
|
|
70
|
-
[priced, snapshot]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def snapshot_for(provider:, model:, tokens:, pricing_mode: nil)
|
|
74
|
-
calculation = calculation_for(
|
|
75
|
-
provider: provider,
|
|
76
|
-
model: model,
|
|
77
|
-
tokens: tokens,
|
|
78
|
-
pricing_mode: pricing_mode
|
|
79
|
-
)
|
|
80
|
-
return nil unless calculation
|
|
81
|
-
|
|
82
|
-
snapshot_from(calculation)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def explain(provider:, model:, tokens:, pricing_mode: nil)
|
|
86
|
-
Explainer.call(
|
|
87
|
-
provider: provider,
|
|
88
|
-
model: model,
|
|
89
|
-
tokens: tokens,
|
|
90
|
-
pricing_mode: pricing_mode
|
|
91
|
-
)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def stored_cost_attributes(attributes)
|
|
95
|
-
value = attributes.to_h[:total_cost]
|
|
96
|
-
value ? { total_cost: value } : {}
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def combine_with_service_lines(cost_data, line_items)
|
|
100
|
-
priced_services = line_items.reject(&:token?).select(&:priced?)
|
|
101
|
-
return cost_data if priced_services.empty?
|
|
102
|
-
|
|
103
|
-
base_currency = base_currency_for(cost_data, priced_services)
|
|
104
|
-
matching, mismatched = priced_services.partition { |line| line.currency.to_s == base_currency.to_s }
|
|
105
|
-
warn_currency_mismatch(mismatched, base_currency) if mismatched.any?
|
|
106
|
-
|
|
107
|
-
cost = cost_data ? cost_data.dup : {}
|
|
108
|
-
cost[:currency] ||= base_currency.to_s
|
|
109
|
-
return cost if matching.empty?
|
|
110
|
-
|
|
111
|
-
service_total = matching.sum(BigDecimal("0"), &:cost_value)
|
|
112
|
-
base_total = BigDecimal(cost.fetch(:total_cost, 0).to_s)
|
|
113
|
-
cost[:total_cost] = (base_total + service_total).round(8)
|
|
114
|
-
cost
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def token_pricing_partial?(token_usage, cost_data)
|
|
118
|
-
return false unless cost_data
|
|
119
|
-
|
|
120
|
-
token_usage.priced_quantities.any? do |key, quantity|
|
|
121
|
-
next false unless quantity.positive?
|
|
122
|
-
|
|
123
|
-
cost_data[Billing::Components::BY_KEY.fetch(key).cost_key].nil?
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
private
|
|
128
|
-
|
|
129
|
-
def base_currency_for(cost_data, priced_services)
|
|
130
|
-
(cost_data && cost_data[:currency]) || priced_services.first.currency || Billing::LineItem::USD
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def warn_currency_mismatch(lines, base_currency)
|
|
134
|
-
currencies = lines.map { |line| line.currency.to_s }.uniq.sort
|
|
135
|
-
Logging.warn(
|
|
136
|
-
"Service line currency mismatch: header is #{base_currency}, dropping " \
|
|
137
|
-
"#{lines.size} priced line(s) in #{currencies.join(', ')} from header total. " \
|
|
138
|
-
"Per-line costs are still recorded; header total reflects #{base_currency} only."
|
|
139
|
-
)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def normalize_string_mode(value)
|
|
143
|
-
normalized = value.strip
|
|
144
|
-
return nil if normalized.empty?
|
|
145
|
-
|
|
146
|
-
normalized.downcase.tr("-", "_").to_sym
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def cost_from(calculation)
|
|
150
|
-
costs = calculation[:costs]
|
|
151
|
-
values = Billing::Components::TOKEN_PRICED.each_with_object({}) do |component, result|
|
|
152
|
-
cost = costs[component.key]
|
|
153
|
-
result[component.cost_key] = cost.round(8) unless cost.nil?
|
|
154
|
-
end
|
|
155
|
-
values[:total_cost] = costs.values.compact.sum(BigDecimal("0")).round(8)
|
|
156
|
-
values[:currency] = calculation[:match].currency
|
|
157
|
-
values
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def snapshot_from(calculation)
|
|
161
|
-
match = calculation[:match]
|
|
162
|
-
effective = calculation[:effective]
|
|
163
|
-
rates = calculation[:quantities].each_with_object({}) do |(key, quantity), values|
|
|
164
|
-
price = effective[key]
|
|
165
|
-
next if quantity.zero? || price.nil?
|
|
166
|
-
|
|
167
|
-
values[key] = { amount: price, quantity: RATE_DENOMINATOR_TOKENS }
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
{
|
|
171
|
-
schema_version: 1,
|
|
172
|
-
source: match.source,
|
|
173
|
-
source_key: match.key,
|
|
174
|
-
source_version: source_version_for(match.source),
|
|
175
|
-
matched_by: match.matched_by,
|
|
176
|
-
currency: match.currency,
|
|
177
|
-
rates: rates
|
|
178
|
-
}
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def calculation_for(provider:, model:, tokens:, pricing_mode:)
|
|
182
|
-
match = Lookup.call(provider: provider, model: model)
|
|
183
|
-
return nil unless match
|
|
184
|
-
|
|
185
|
-
token_usage = TokenUsage.build_from_tokens(tokens)
|
|
186
|
-
quantities = token_usage.priced_quantities
|
|
187
|
-
mode = normalize_mode(pricing_mode)
|
|
188
|
-
effective = EffectivePrices.call(usage: token_usage, quantities: quantities, prices: match.prices,
|
|
189
|
-
pricing_mode: mode)
|
|
190
|
-
return nil unless any_billable_priced?(quantities, effective)
|
|
191
|
-
|
|
192
|
-
{ match: match, effective: effective, token_usage: token_usage, quantities: quantities,
|
|
193
|
-
costs: costs_for(quantities, effective) }
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def any_billable_priced?(quantities, effective)
|
|
197
|
-
any_billable = false
|
|
198
|
-
quantities.each_pair do |key, quantity|
|
|
199
|
-
next unless quantity.positive?
|
|
200
|
-
return true if effective[key]
|
|
201
|
-
|
|
202
|
-
any_billable = true
|
|
203
|
-
end
|
|
204
|
-
!any_billable
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def costs_for(quantities, effective)
|
|
208
|
-
quantities.to_h { |key, tokens| [key, token_cost(tokens, effective[key])] }
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def apply_calculation_to_line_items(line_items, calculation, provider:, pricing_mode:)
|
|
212
|
-
line_items.map do |line_item|
|
|
213
|
-
next price_token_line_item(line_item, calculation) if line_item.unit == :token
|
|
214
|
-
|
|
215
|
-
price_service_charge_line_item(line_item,
|
|
216
|
-
provider: provider,
|
|
217
|
-
calculation: calculation,
|
|
218
|
-
pricing_mode: pricing_mode)
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def token_attributes_from(line_items)
|
|
223
|
-
line_items.each_with_object({}) do |line_item, totals|
|
|
224
|
-
next unless line_item.unit == :token
|
|
225
|
-
|
|
226
|
-
component = component_for_line_item(line_item)
|
|
227
|
-
next unless component
|
|
228
|
-
|
|
229
|
-
totals[component.key] = (totals[component.key] || 0) + line_item.quantity.to_i
|
|
230
|
-
end
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def price_token_line_item(line_item, calculation)
|
|
234
|
-
component = component_for_line_item(line_item)
|
|
235
|
-
return line_item unless component
|
|
236
|
-
return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) unless calculation
|
|
237
|
-
|
|
238
|
-
effective_price = calculation[:effective][component.key]
|
|
239
|
-
return line_item.with(cost_status: Billing::CostStatus::UNKNOWN) if effective_price.nil?
|
|
240
|
-
|
|
241
|
-
cost = (line_item.quantity * BigDecimal(effective_price.to_s)) / RATE_DENOMINATOR_TOKENS
|
|
242
|
-
match = calculation[:match]
|
|
243
|
-
line_item.with(
|
|
244
|
-
rate_amount: BigDecimal(effective_price.to_s),
|
|
245
|
-
rate_quantity: BigDecimal(RATE_DENOMINATOR_TOKENS),
|
|
246
|
-
cost: cost,
|
|
247
|
-
currency: match.currency,
|
|
248
|
-
cost_status: cost.zero? ? Billing::CostStatus::FREE : Billing::CostStatus::COMPLETE,
|
|
249
|
-
price_key: component.key,
|
|
250
|
-
price_source: match.source,
|
|
251
|
-
price_source_version: source_version_for(match.source)
|
|
252
|
-
)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def price_service_charge_line_item(line_item, provider:, calculation:, pricing_mode:)
|
|
256
|
-
return line_item if line_item.priced?
|
|
257
|
-
return line_item unless line_item.billable?
|
|
258
|
-
|
|
259
|
-
rate = model_rate_for(line_item, calculation) ||
|
|
260
|
-
charge_rate(provider: provider, component: line_item.kind, pricing_mode: pricing_mode)
|
|
261
|
-
return line_item unless rate
|
|
262
|
-
|
|
263
|
-
line_item.with_rate(rate)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def model_rate_for(line_item, calculation)
|
|
267
|
-
return nil unless calculation
|
|
268
|
-
|
|
269
|
-
match = calculation[:match]
|
|
270
|
-
amount = match.prices[line_item.kind] || match.prices[line_item.kind.to_s]
|
|
271
|
-
return nil unless amount.is_a?(Numeric)
|
|
272
|
-
|
|
273
|
-
component = Billing::Components::BY_KEY[line_item.kind]
|
|
274
|
-
{
|
|
275
|
-
amount: BigDecimal(amount.to_s),
|
|
276
|
-
quantity: BigDecimal(Billing::RATE_BASIS_QUANTITIES.fetch(component.rate_basis).to_s),
|
|
277
|
-
currency: match.currency,
|
|
278
|
-
source: match.source,
|
|
279
|
-
source_key: "#{match.key}.#{line_item.kind}",
|
|
280
|
-
source_version: source_version_for(match.source)
|
|
281
|
-
}
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
def component_for_line_item(line_item)
|
|
285
|
-
Billing::Components::REGISTRY.find do |component|
|
|
286
|
-
component.kind == line_item.kind &&
|
|
287
|
-
component.direction == line_item.direction &&
|
|
288
|
-
component.modality == line_item.modality &&
|
|
289
|
-
component.cache_state == line_item.cache_state &&
|
|
290
|
-
component.unit == line_item.unit
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def source_version_for(source)
|
|
295
|
-
case source
|
|
296
|
-
when :bundled
|
|
297
|
-
LlmCostTracker::VERSION
|
|
298
|
-
when :prices_file
|
|
299
|
-
Lookup.prices_file_mtime_iso
|
|
300
|
-
when :pricing_overrides
|
|
301
|
-
"configuration"
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
def token_cost(tokens, per_million_price)
|
|
306
|
-
return BigDecimal("0") if tokens.zero?
|
|
307
|
-
return nil if per_million_price.nil?
|
|
308
|
-
|
|
309
|
-
(BigDecimal(tokens.to_s) * BigDecimal(per_million_price.to_s)) / RATE_DENOMINATOR_TOKENS
|
|
24
|
+
Calculation.for(provider: provider, model: model, tokens: tokens, pricing_mode: pricing_mode).token_cost
|
|
310
25
|
end
|
|
311
26
|
end
|
|
312
27
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/hash/keys"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Providers
|
|
7
|
+
module Anthropic
|
|
8
|
+
class Parser < LlmCostTracker::Parsers::Base
|
|
9
|
+
HOSTS = %w[api.anthropic.com].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def match?(url)
|
|
13
|
+
match_uri?(url, hosts: HOSTS, path_includes: "/v1/messages")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def provider_names
|
|
17
|
+
%w[anthropic]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse(request_body:, response_status:, response_body:, **)
|
|
22
|
+
return nil unless response_status == 200
|
|
23
|
+
|
|
24
|
+
response = safe_json_parse(response_body)
|
|
25
|
+
usage = response["usage"]&.deep_symbolize_keys
|
|
26
|
+
return nil unless usage
|
|
27
|
+
|
|
28
|
+
request = symbolize_request(request_body)
|
|
29
|
+
|
|
30
|
+
ResponseParser.event_from_usage(
|
|
31
|
+
usage: usage,
|
|
32
|
+
model: response["model"] || request[:model],
|
|
33
|
+
provider_response_id: response["id"],
|
|
34
|
+
usage_source: Usage::Source::RESPONSE,
|
|
35
|
+
request: request
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_stream(response_status:, request_body: nil, events: [], **)
|
|
40
|
+
return nil unless response_status == 200
|
|
41
|
+
|
|
42
|
+
request = symbolize_request(request_body)
|
|
43
|
+
model = find_event_value(events) { |data| data.dig("message", "model") } || request[:model]
|
|
44
|
+
usage = stream_usage(events)&.deep_symbolize_keys
|
|
45
|
+
response_id = find_event_value(events) { |data| data.dig("message", "id") || data["id"] }
|
|
46
|
+
|
|
47
|
+
if usage
|
|
48
|
+
ResponseParser.event_from_usage(
|
|
49
|
+
usage: usage,
|
|
50
|
+
model: model,
|
|
51
|
+
provider_response_id: response_id,
|
|
52
|
+
usage_source: Usage::Source::STREAM_FINAL,
|
|
53
|
+
request: request,
|
|
54
|
+
stream: true
|
|
55
|
+
)
|
|
56
|
+
else
|
|
57
|
+
build_unknown_stream_usage(
|
|
58
|
+
provider: "anthropic",
|
|
59
|
+
model: model,
|
|
60
|
+
provider_response_id: response_id,
|
|
61
|
+
pricing_mode: UsageExtractor.pricing_mode(request: request, usage: usage)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def provider_for(_request_url)
|
|
67
|
+
"anthropic"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def symbolize_request(request_body)
|
|
73
|
+
safe_json_parse(request_body).deep_symbolize_keys
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stream_usage(events)
|
|
77
|
+
latest_delta = find_event_value(events, reverse: true) do |data|
|
|
78
|
+
data["usage"] if data["type"] == "message_delta" && data["usage"].is_a?(Hash)
|
|
79
|
+
end
|
|
80
|
+
return nil unless latest_delta
|
|
81
|
+
|
|
82
|
+
start_usage = find_event_value(events, reverse: true) do |data|
|
|
83
|
+
data.dig("message", "usage") if data["type"] == "message_start"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
(start_usage || {}).merge(latest_delta) do |_key, start_val, delta_val|
|
|
87
|
+
delta_val || start_val
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "usage_extractor"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Providers
|
|
7
|
+
module Anthropic
|
|
8
|
+
module ResponseParser
|
|
9
|
+
def self.event_from_usage(usage:,
|
|
10
|
+
model:,
|
|
11
|
+
provider_response_id:,
|
|
12
|
+
usage_source:,
|
|
13
|
+
request: nil,
|
|
14
|
+
pricing_mode: nil,
|
|
15
|
+
stream: false)
|
|
16
|
+
Event.build(
|
|
17
|
+
provider: "anthropic",
|
|
18
|
+
provider_response_id: provider_response_id,
|
|
19
|
+
pricing_mode: pricing_mode || UsageExtractor.pricing_mode(request: request, usage: usage),
|
|
20
|
+
model: model,
|
|
21
|
+
token_usage: UsageExtractor.token_usage(usage),
|
|
22
|
+
stream: stream,
|
|
23
|
+
usage_source: usage_source,
|
|
24
|
+
service_line_items: UsageExtractor.service_line_items(usage)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Providers
|
|
5
|
+
module Anthropic
|
|
6
|
+
module UsageExtractor
|
|
7
|
+
SERVER_TOOL_LINE_ITEMS = {
|
|
8
|
+
"web_search_request" => :web_search_requests,
|
|
9
|
+
"web_fetch_request" => :web_fetch_requests
|
|
10
|
+
}.freeze
|
|
11
|
+
DATA_RESIDENCY_GEOS = %w[us].freeze
|
|
12
|
+
private_constant :SERVER_TOOL_LINE_ITEMS, :DATA_RESIDENCY_GEOS
|
|
13
|
+
|
|
14
|
+
def self.token_usage(usage)
|
|
15
|
+
input = usage[:input_tokens].to_i
|
|
16
|
+
output = usage[:output_tokens].to_i
|
|
17
|
+
cache_read = usage[:cache_read_input_tokens].to_i
|
|
18
|
+
cache_write, cache_write_extended = cache_writes(usage)
|
|
19
|
+
|
|
20
|
+
Usage::TokenUsage.build(
|
|
21
|
+
input_tokens: input,
|
|
22
|
+
output_tokens: output,
|
|
23
|
+
cache_read_input_tokens: cache_read,
|
|
24
|
+
cache_write_input_tokens: cache_write,
|
|
25
|
+
cache_write_extended_input_tokens: cache_write_extended
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.pricing_mode(request:, usage:)
|
|
30
|
+
speed = request&.dig(:speed)
|
|
31
|
+
service_tier = usage&.dig(:service_tier) || request&.dig(:service_tier)
|
|
32
|
+
geo = (usage&.dig(:inference_geo) || request&.dig(:inference_geo)).to_s.downcase
|
|
33
|
+
|
|
34
|
+
modes = [Pricing::Mode.normalize(speed), Pricing::Mode.normalize(service_tier)]
|
|
35
|
+
modes << "data_residency" if DATA_RESIDENCY_GEOS.include?(geo)
|
|
36
|
+
Pricing::Mode.compose(modes)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.service_line_items(usage)
|
|
40
|
+
server_tool_use = usage[:server_tool_use]
|
|
41
|
+
return [] unless server_tool_use.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
SERVER_TOOL_LINE_ITEMS.filter_map do |dimension_key, count_key|
|
|
44
|
+
quantity = server_tool_use[count_key].to_i
|
|
45
|
+
next if quantity.zero?
|
|
46
|
+
|
|
47
|
+
Charges::LineItem.build(
|
|
48
|
+
dimension_key: dimension_key,
|
|
49
|
+
quantity: quantity,
|
|
50
|
+
cost_status: Charges::CostStatus::UNKNOWN,
|
|
51
|
+
pricing_basis: "provider_usage",
|
|
52
|
+
provider_field: "usage.server_tool_use.#{count_key}"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.cache_writes(usage)
|
|
58
|
+
cache_creation = usage[:cache_creation]
|
|
59
|
+
if cache_creation.is_a?(Hash)
|
|
60
|
+
[cache_creation[:ephemeral_5m_input_tokens].to_i, cache_creation[:ephemeral_1h_input_tokens].to_i]
|
|
61
|
+
else
|
|
62
|
+
warn_unexpected_cache_creation(cache_creation, usage)
|
|
63
|
+
[usage[:cache_creation_input_tokens].to_i, 0]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.warn_unexpected_cache_creation(cache_creation, usage)
|
|
68
|
+
return if cache_creation.nil?
|
|
69
|
+
return if usage.key?(:cache_creation_input_tokens)
|
|
70
|
+
|
|
71
|
+
Logging.warn("Anthropic usage.cache_creation has unexpected shape: #{cache_creation.class}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -5,10 +5,7 @@ module LlmCostTracker
|
|
|
5
5
|
module Azure
|
|
6
6
|
module Hosts
|
|
7
7
|
OPENAI_HOST_PATTERN = /\A[a-z0-9][a-z0-9-]*\.(?:openai\.azure\.com|services\.ai\.azure\.com)\z/i
|
|
8
|
-
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
|
-
def openai?(host)
|
|
8
|
+
def self.openai?(host)
|
|
12
9
|
host.to_s.match?(OPENAI_HOST_PATTERN)
|
|
13
10
|
end
|
|
14
11
|
end
|