llm_cost_tracker 0.7.3 → 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/.ruby-version +1 -0
- data/CHANGELOG.md +173 -0
- data/README.md +60 -220
- data/app/assets/llm_cost_tracker/application.css +282 -45
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
- 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 +18 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- 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 +24 -7
- data/app/models/llm_cost_tracker/call.rb +166 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
- data/app/models/llm_cost_tracker/call_tag.rb +12 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- 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 +66 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +95 -0
- data/lib/llm_cost_tracker/billing/components.yml +188 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +26 -36
- data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +86 -17
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
- data/lib/llm_cost_tracker/doctor.rb +111 -44
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +11 -3
- 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 +17 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- 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 +104 -0
- 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/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
- data/lib/llm_cost_tracker/ingestion.rb +66 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
- data/lib/llm_cost_tracker/integrations/base.rb +56 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
- data/lib/llm_cost_tracker/integrations.rb +21 -3
- data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
- 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 +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +103 -20
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +5 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
- data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
- data/lib/llm_cost_tracker/parsers/base.rb +13 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +198 -22
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +220 -28
- data/lib/llm_cost_tracker/railtie.rb +6 -8
- 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 +19 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +22 -9
- data/lib/llm_cost_tracker/tags/context.rb +2 -5
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +64 -42
- data/lib/llm_cost_tracker/tracker.rb +97 -27
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +45 -35
- data/lib/tasks/llm_cost_tracker.rake +45 -17
- metadata +71 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -25,7 +25,7 @@ module LlmCostTracker
|
|
|
25
25
|
patch_target(
|
|
26
26
|
"RubyLLM::Provider",
|
|
27
27
|
with: ProviderPatch,
|
|
28
|
-
methods: %i[slug complete embed transcribe]
|
|
28
|
+
methods: %i[slug complete embed transcribe paint moderate]
|
|
29
29
|
)
|
|
30
30
|
]
|
|
31
31
|
end
|
|
@@ -65,6 +65,69 @@ module LlmCostTracker
|
|
|
65
65
|
)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
def record_image(provider, response, request:, latency_ms:)
|
|
69
|
+
usage = object_value(response, :usage)
|
|
70
|
+
usage = {} unless usage.is_a?(Hash)
|
|
71
|
+
raw_input = (usage[:input_tokens] || usage["input_tokens"]).to_i
|
|
72
|
+
raw_output = (usage[:output_tokens] || usage["output_tokens"]).to_i
|
|
73
|
+
image_input = image_token_detail(usage, :input)
|
|
74
|
+
image_output = image_token_detail(usage, :output)
|
|
75
|
+
text_input = [raw_input - image_input, 0].max
|
|
76
|
+
text_output = [raw_output - image_output, 0].max
|
|
77
|
+
record_passthrough(
|
|
78
|
+
provider: provider_slug(provider),
|
|
79
|
+
model: response_model_id(response) || model_id(request[:model]),
|
|
80
|
+
response: response,
|
|
81
|
+
latency_ms: latency_ms,
|
|
82
|
+
input_tokens: text_input,
|
|
83
|
+
image_input_tokens: image_input,
|
|
84
|
+
output_tokens: text_output,
|
|
85
|
+
image_output_tokens: image_output
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def record_moderation(provider, response, request:, latency_ms:)
|
|
90
|
+
record_passthrough(
|
|
91
|
+
provider: provider_slug(provider),
|
|
92
|
+
model: response_model_id(response) || model_id(request[:model]),
|
|
93
|
+
response: response,
|
|
94
|
+
latency_ms: latency_ms,
|
|
95
|
+
input_tokens: 0,
|
|
96
|
+
output_tokens: 0
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def image_token_detail(usage, direction)
|
|
101
|
+
container_key = direction == :input ? :input_tokens_details : :output_tokens_details
|
|
102
|
+
details = usage[container_key] || usage[container_key.to_s] || {}
|
|
103
|
+
return 0 unless details.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
(details[:image_tokens] || details["image_tokens"]).to_i
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def record_passthrough(provider:, model:, response:, latency_ms:, input_tokens:, output_tokens:,
|
|
109
|
+
image_input_tokens: 0, image_output_tokens: 0)
|
|
110
|
+
return unless active?
|
|
111
|
+
|
|
112
|
+
record_safely do
|
|
113
|
+
LlmCostTracker::Tracker.record(
|
|
114
|
+
capture: UsageCapture.build(
|
|
115
|
+
provider: provider,
|
|
116
|
+
model: model,
|
|
117
|
+
token_usage: TokenUsage.build(
|
|
118
|
+
input_tokens: input_tokens,
|
|
119
|
+
output_tokens: output_tokens,
|
|
120
|
+
image_input_tokens: image_input_tokens,
|
|
121
|
+
image_output_tokens: image_output_tokens
|
|
122
|
+
),
|
|
123
|
+
usage_source: :sdk_response,
|
|
124
|
+
provider_response_id: provider_response_id(response)
|
|
125
|
+
),
|
|
126
|
+
latency_ms: latency_ms
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
68
131
|
def record_usage(provider:, model:, response:, latency_ms:, stream:, output_tokens: nil)
|
|
69
132
|
return unless active?
|
|
70
133
|
|
|
@@ -80,7 +143,7 @@ module LlmCostTracker
|
|
|
80
143
|
capture: UsageCapture.build(
|
|
81
144
|
provider: provider,
|
|
82
145
|
model: model,
|
|
83
|
-
pricing_mode: pricing_mode(response),
|
|
146
|
+
pricing_mode: pricing_mode(provider: provider, response: response),
|
|
84
147
|
token_usage: TokenUsage.build(
|
|
85
148
|
input_tokens: regular_input_tokens(input_tokens, cache_read),
|
|
86
149
|
output_tokens: output_tokens.to_i,
|
|
@@ -89,7 +152,7 @@ module LlmCostTracker
|
|
|
89
152
|
hidden_output_tokens: hidden_output
|
|
90
153
|
),
|
|
91
154
|
stream: stream,
|
|
92
|
-
usage_source: :
|
|
155
|
+
usage_source: :sdk_response,
|
|
93
156
|
provider_response_id: provider_response_id(response)
|
|
94
157
|
),
|
|
95
158
|
latency_ms: latency_ms
|
|
@@ -98,7 +161,7 @@ module LlmCostTracker
|
|
|
98
161
|
end
|
|
99
162
|
|
|
100
163
|
def regular_input_tokens(input_tokens, cache_read)
|
|
101
|
-
[input_tokens.to_i - cache_read
|
|
164
|
+
[input_tokens.to_i - cache_read, 0].max
|
|
102
165
|
end
|
|
103
166
|
|
|
104
167
|
def provider_slug(provider)
|
|
@@ -122,10 +185,16 @@ module LlmCostTracker
|
|
|
122
185
|
object_value(response, :id, :provider_response_id) || object_dig(response, :raw, :id)
|
|
123
186
|
end
|
|
124
187
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
188
|
+
ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS = %w[standard standard_only priority].freeze
|
|
189
|
+
private_constant :ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS
|
|
190
|
+
|
|
191
|
+
def pricing_mode(provider:, response:)
|
|
192
|
+
raw = object_value(response, :pricing_mode, :service_tier) ||
|
|
193
|
+
object_dig(response, :raw, :pricing_mode) ||
|
|
194
|
+
object_dig(response, :raw, :service_tier)
|
|
195
|
+
return nil if provider == "anthropic" && ANTHROPIC_STANDARD_EQUIVALENT_SERVICE_TIERS.include?(raw.to_s)
|
|
196
|
+
|
|
197
|
+
raw
|
|
129
198
|
end
|
|
130
199
|
end
|
|
131
200
|
|
|
@@ -133,8 +202,8 @@ module LlmCostTracker
|
|
|
133
202
|
def complete(*args, **kwargs, &)
|
|
134
203
|
integration = LlmCostTracker::Integrations::RubyLlm
|
|
135
204
|
request = integration.request_params(args, kwargs)
|
|
136
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
137
205
|
integration.enforce_budget!
|
|
206
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
138
207
|
response = super
|
|
139
208
|
integration.record_completion(
|
|
140
209
|
self,
|
|
@@ -149,8 +218,8 @@ module LlmCostTracker
|
|
|
149
218
|
def embed(*args, **kwargs)
|
|
150
219
|
integration = LlmCostTracker::Integrations::RubyLlm
|
|
151
220
|
request = integration.request_params(args, kwargs)
|
|
152
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
153
221
|
integration.enforce_budget!
|
|
222
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
154
223
|
response = super
|
|
155
224
|
integration.record_embedding(
|
|
156
225
|
self,
|
|
@@ -164,8 +233,8 @@ module LlmCostTracker
|
|
|
164
233
|
def transcribe(*args, **kwargs)
|
|
165
234
|
integration = LlmCostTracker::Integrations::RubyLlm
|
|
166
235
|
request = integration.request_params(args, kwargs)
|
|
167
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
168
236
|
integration.enforce_budget!
|
|
237
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
169
238
|
response = super
|
|
170
239
|
integration.record_transcription(
|
|
171
240
|
self,
|
|
@@ -175,6 +244,36 @@ module LlmCostTracker
|
|
|
175
244
|
)
|
|
176
245
|
response
|
|
177
246
|
end
|
|
247
|
+
|
|
248
|
+
def paint(*args, **kwargs)
|
|
249
|
+
integration = LlmCostTracker::Integrations::RubyLlm
|
|
250
|
+
request = integration.request_params(args, kwargs)
|
|
251
|
+
integration.enforce_budget!
|
|
252
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
253
|
+
response = super
|
|
254
|
+
integration.record_image(
|
|
255
|
+
self,
|
|
256
|
+
response,
|
|
257
|
+
request: request,
|
|
258
|
+
latency_ms: integration.elapsed_ms(started_at)
|
|
259
|
+
)
|
|
260
|
+
response
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def moderate(*args, **kwargs)
|
|
264
|
+
integration = LlmCostTracker::Integrations::RubyLlm
|
|
265
|
+
request = integration.request_params(args, kwargs)
|
|
266
|
+
integration.enforce_budget!
|
|
267
|
+
started_at = LlmCostTracker::Timing.now_monotonic
|
|
268
|
+
response = super
|
|
269
|
+
integration.record_moderation(
|
|
270
|
+
self,
|
|
271
|
+
response,
|
|
272
|
+
request: request,
|
|
273
|
+
latency_ms: integration.elapsed_ms(started_at)
|
|
274
|
+
)
|
|
275
|
+
response
|
|
276
|
+
end
|
|
178
277
|
end
|
|
179
278
|
end
|
|
180
279
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "errors"
|
|
4
|
+
require_relative "logging"
|
|
4
5
|
require_relative "integrations/openai"
|
|
5
6
|
require_relative "integrations/anthropic"
|
|
6
7
|
require_relative "integrations/ruby_llm"
|
|
@@ -13,10 +14,14 @@ module LlmCostTracker
|
|
|
13
14
|
ruby_llm: RubyLlm
|
|
14
15
|
}.freeze
|
|
15
16
|
|
|
17
|
+
DOUBLE_INSTRUMENTATION_OVERLAPS = %i[openai anthropic].freeze
|
|
18
|
+
|
|
16
19
|
module_function
|
|
17
20
|
|
|
18
21
|
def install!(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
19
|
-
normalize(names)
|
|
22
|
+
normalized = normalize(names)
|
|
23
|
+
warn_double_instrumentation(normalized)
|
|
24
|
+
normalized.each { |name| fetch(name).install }
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def checks(names = LlmCostTracker.configuration.instrumented_integrations)
|
|
@@ -26,11 +31,24 @@ module LlmCostTracker
|
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def normalize(names)
|
|
29
|
-
Array(names).flatten.
|
|
34
|
+
Array(names).flatten.uniq
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def warn_double_instrumentation(names)
|
|
38
|
+
return unless names.include?(:ruby_llm)
|
|
39
|
+
|
|
40
|
+
overlapping = names & DOUBLE_INSTRUMENTATION_OVERLAPS
|
|
41
|
+
return if overlapping.empty?
|
|
42
|
+
|
|
43
|
+
Logging.warn(
|
|
44
|
+
":ruby_llm is enabled together with #{overlapping.map(&:inspect).join(', ')}. " \
|
|
45
|
+
"RubyLLM uses HTTP underneath, so calls routed to those providers may be recorded twice " \
|
|
46
|
+
"(once via the SDK patch, once via the Faraday parser). Pick one path per provider."
|
|
47
|
+
)
|
|
30
48
|
end
|
|
31
49
|
|
|
32
50
|
def fetch(name)
|
|
33
|
-
AVAILABLE.fetch(name
|
|
51
|
+
AVAILABLE.fetch(name) do
|
|
34
52
|
message = "Unknown integration: #{name.inspect}. Use one of: #{names.join(', ')}"
|
|
35
53
|
raise LlmCostTracker::Error, message
|
|
36
54
|
end
|
|
@@ -27,38 +27,57 @@ module LlmCostTracker
|
|
|
27
27
|
|
|
28
28
|
def snapshot_totals
|
|
29
29
|
values = periods.to_h { |period| [period, 0.0] }
|
|
30
|
+
period_by_name = periods.to_h { |period| [period.name, period] }
|
|
30
31
|
sql = periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
|
|
31
|
-
LlmCostTracker::
|
|
32
|
-
|
|
32
|
+
LlmCostTracker::Call.find_by_sql(sql).each do |row|
|
|
33
|
+
period = period_by_name.fetch(row.period_key)
|
|
34
|
+
values[period] = row.total_cost.to_f
|
|
33
35
|
end
|
|
34
36
|
values
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def snapshot_select(period)
|
|
38
40
|
start = Period.range_start(period, time)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
components = [period_total_sql(period, start)]
|
|
42
|
+
components << pending_total_sql(start) if Ingestion.durable?
|
|
43
|
+
"SELECT #{connection.quote(period.name)} AS period_key, " \
|
|
44
|
+
"(#{components.join(') + (')}) AS total_cost"
|
|
41
45
|
end
|
|
42
46
|
|
|
43
|
-
def
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
def period_total_sql(period, start)
|
|
48
|
+
if LlmCostTracker.configuration.cache_rollups
|
|
49
|
+
"GREATEST(COALESCE(#{rollup_sum_sql(period)}, 0), COALESCE(#{calls_sum_sql(start)}, 0))"
|
|
50
|
+
else
|
|
51
|
+
"COALESCE(#{calls_sum_sql(start)}, 0)"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def rollup_sum_sql(period)
|
|
56
|
+
table = connection.quote_table_name("llm_cost_tracker_call_rollups")
|
|
57
|
+
"(SELECT SUM(total_cost) FROM #{table} " \
|
|
46
58
|
"WHERE period = #{connection.quote(Period::PERIODS.fetch(period))} " \
|
|
47
|
-
"AND period_start = #{connection.quote(Period.bucket(period, time))}
|
|
59
|
+
"AND period_start = #{connection.quote(Period.bucket(period, time))})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def calls_sum_sql(start)
|
|
63
|
+
table = connection.quote_table_name("llm_cost_tracker_calls")
|
|
64
|
+
tracked_at = connection.quote_column_name("tracked_at")
|
|
65
|
+
"(SELECT SUM(total_cost) FROM #{table} " \
|
|
66
|
+
"WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)})"
|
|
48
67
|
end
|
|
49
68
|
|
|
50
69
|
def pending_total_sql(start)
|
|
51
|
-
table = connection.quote_table_name(Ingestion::
|
|
70
|
+
table = connection.quote_table_name(Ingestion::InboxEntry.table_name)
|
|
52
71
|
total_cost = connection.quote_column_name("total_cost")
|
|
53
72
|
tracked_at = connection.quote_column_name("tracked_at")
|
|
54
73
|
attempts = connection.quote_column_name("attempts")
|
|
55
74
|
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
56
|
-
"WHERE #{attempts} < #{Ingestion::
|
|
75
|
+
"WHERE #{attempts} < #{Ingestion::InboxEntry::MAX_ATTEMPTS_BEFORE_QUARANTINE} " \
|
|
57
76
|
"AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
58
77
|
end
|
|
59
78
|
|
|
60
79
|
def connection
|
|
61
|
-
LlmCostTracker::
|
|
80
|
+
LlmCostTracker::Call.connection
|
|
62
81
|
end
|
|
63
82
|
end
|
|
64
83
|
end
|
|
@@ -4,22 +4,22 @@ module LlmCostTracker
|
|
|
4
4
|
module Ledger
|
|
5
5
|
module Period
|
|
6
6
|
PERIODS = {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
month: "month",
|
|
8
|
+
day: "day"
|
|
9
9
|
}.freeze
|
|
10
10
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
13
|
def valid_keys(periods)
|
|
14
|
-
periods.
|
|
14
|
+
periods.select { |period| PERIODS.key?(period) }
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def range_start(period, time)
|
|
18
18
|
utc_time = time.to_time.utc
|
|
19
19
|
|
|
20
20
|
case period
|
|
21
|
-
when :
|
|
22
|
-
when :
|
|
21
|
+
when :month then utc_time.beginning_of_month
|
|
22
|
+
when :day then utc_time.beginning_of_day
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -23,7 +23,7 @@ module LlmCostTracker
|
|
|
23
23
|
total_cost = connection.quote_column_name("total_cost")
|
|
24
24
|
updated_at = connection.quote_column_name("updated_at")
|
|
25
25
|
|
|
26
|
-
"#{total_cost} = #{
|
|
26
|
+
"#{total_cost} = #{LlmCostTracker::CallRollup.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
27
27
|
"#{updated_at} = excluded.#{updated_at}"
|
|
28
28
|
end
|
|
29
29
|
|
|
@@ -32,7 +32,7 @@ module LlmCostTracker
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def connection
|
|
35
|
-
|
|
35
|
+
LlmCostTracker::CallRollup.connection
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
end
|
|
@@ -3,34 +3,25 @@
|
|
|
3
3
|
require "bigdecimal"
|
|
4
4
|
|
|
5
5
|
require_relative "period"
|
|
6
|
-
require_relative "rollups/batch"
|
|
7
6
|
require_relative "rollups/upsert_sql"
|
|
8
7
|
|
|
9
8
|
module LlmCostTracker
|
|
10
9
|
module Ledger
|
|
11
10
|
class Rollups
|
|
11
|
+
DEFAULT_CURRENCY = "USD"
|
|
12
|
+
|
|
12
13
|
class << self
|
|
13
14
|
def increment!(event)
|
|
14
15
|
return unless event.total_cost
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
period_rows(event),
|
|
18
|
-
on_duplicate: Ledger::Rollups::UpsertSql.call,
|
|
19
|
-
record_timestamps: true,
|
|
20
|
-
unique_by: period_totals_unique_by
|
|
21
|
-
)
|
|
17
|
+
upsert_call_rollups(period_rows(event))
|
|
22
18
|
end
|
|
23
19
|
|
|
24
20
|
def increment_many!(events)
|
|
25
21
|
events = Array(events).select(&:total_cost)
|
|
26
22
|
return if events.empty?
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
Ledger::Rollups::Batch.rows(events),
|
|
30
|
-
on_duplicate: Ledger::Rollups::UpsertSql.call,
|
|
31
|
-
record_timestamps: true,
|
|
32
|
-
unique_by: period_totals_unique_by
|
|
33
|
-
)
|
|
24
|
+
upsert_call_rollups(period_rows_for_events(events))
|
|
34
25
|
end
|
|
35
26
|
|
|
36
27
|
def decrement!(call_rows)
|
|
@@ -43,43 +34,117 @@ module LlmCostTracker
|
|
|
43
34
|
private
|
|
44
35
|
|
|
45
36
|
def period_rows(event)
|
|
37
|
+
currency = currency_for(event)
|
|
38
|
+
provider = provider_for(event)
|
|
46
39
|
Period::PERIODS.map do |period, name|
|
|
47
40
|
{
|
|
48
41
|
period: name,
|
|
49
42
|
period_start: Period.bucket(period, event.tracked_at),
|
|
43
|
+
currency: currency,
|
|
44
|
+
provider: provider,
|
|
50
45
|
total_cost: event.total_cost
|
|
51
46
|
}
|
|
52
47
|
end
|
|
53
48
|
end
|
|
54
49
|
|
|
50
|
+
def period_rows_for_events(events)
|
|
51
|
+
call_rollups(events).map do |(period, period_start, currency, provider), total_cost|
|
|
52
|
+
{
|
|
53
|
+
period: period,
|
|
54
|
+
period_start: period_start,
|
|
55
|
+
currency: currency,
|
|
56
|
+
provider: provider,
|
|
57
|
+
total_cost: total_cost
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call_rollups(events)
|
|
63
|
+
events.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |event, totals|
|
|
64
|
+
currency = currency_for(event)
|
|
65
|
+
provider = provider_for(event)
|
|
66
|
+
Period::PERIODS.each do |period, name|
|
|
67
|
+
key = [name, Period.bucket(period, event.tracked_at), currency, provider]
|
|
68
|
+
totals[key] += BigDecimal(event.total_cost.to_s)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
55
73
|
def period_decrement_totals(call_rows)
|
|
56
74
|
call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
|
|
57
|
-
_id, tracked_at, total_cost = row
|
|
75
|
+
_id, tracked_at, total_cost, pricing_snapshot, provider = row
|
|
58
76
|
next unless total_cost
|
|
59
77
|
|
|
78
|
+
currency = currency_from_snapshot(pricing_snapshot)
|
|
79
|
+
provider_key = provider.to_s
|
|
60
80
|
Period::PERIODS.each_key do |period|
|
|
61
|
-
totals[[period, Period.bucket(period, tracked_at)]] +=
|
|
81
|
+
totals[[period, Period.bucket(period, tracked_at), currency, provider_key]] += total_cost
|
|
62
82
|
end
|
|
63
83
|
end
|
|
64
84
|
end
|
|
65
85
|
|
|
66
86
|
def apply_decrements(totals)
|
|
67
87
|
now = Time.now.utc
|
|
88
|
+
buckets_by_period = totals.each_with_object({}) do |(key, amount), grouped|
|
|
89
|
+
period, period_start, currency, provider = key
|
|
90
|
+
grouped[[period, currency, provider]] ||= {}
|
|
91
|
+
grouped[[period, currency, provider]][period_start] = amount
|
|
92
|
+
end
|
|
68
93
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
conn = LlmCostTracker::CallRollup.connection
|
|
95
|
+
table = LlmCostTracker::CallRollup.quoted_table_name
|
|
96
|
+
period_col = conn.quote_column_name("period")
|
|
97
|
+
start_col = conn.quote_column_name("period_start")
|
|
98
|
+
currency_col = conn.quote_column_name("currency")
|
|
99
|
+
provider_col = conn.quote_column_name("provider")
|
|
100
|
+
total_col = conn.quote_column_name("total_cost")
|
|
101
|
+
updated_col = conn.quote_column_name("updated_at")
|
|
102
|
+
|
|
103
|
+
buckets_by_period.each do |(period, currency, provider), by_start|
|
|
104
|
+
case_clauses = by_start.map do |period_start, amount|
|
|
105
|
+
"WHEN #{start_col} = #{conn.quote(period_start)} THEN #{conn.quote(amount)}"
|
|
106
|
+
end.join(" ")
|
|
107
|
+
starts = by_start.keys.map { |period_start| conn.quote(period_start) }.join(", ")
|
|
108
|
+
|
|
109
|
+
conn.execute(
|
|
110
|
+
"UPDATE #{table} " \
|
|
111
|
+
"SET #{total_col} = GREATEST(0, #{total_col} - CASE #{case_clauses} ELSE 0 END), " \
|
|
112
|
+
"#{updated_col} = #{conn.quote(now)} " \
|
|
113
|
+
"WHERE #{period_col} = #{conn.quote(Period::PERIODS.fetch(period))} " \
|
|
114
|
+
"AND #{currency_col} = #{conn.quote(currency)} " \
|
|
115
|
+
"AND #{provider_col} = #{conn.quote(provider)} " \
|
|
116
|
+
"AND #{start_col} IN (#{starts})"
|
|
117
|
+
)
|
|
76
118
|
end
|
|
77
119
|
end
|
|
78
120
|
|
|
79
|
-
def
|
|
80
|
-
|
|
121
|
+
def currency_for(event)
|
|
122
|
+
snapshot = event.respond_to?(:pricing_snapshot) ? event.pricing_snapshot : nil
|
|
123
|
+
currency_from_snapshot(snapshot)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def currency_from_snapshot(snapshot)
|
|
127
|
+
value = (snapshot.is_a?(Hash) && (snapshot["currency"] || snapshot[:currency])) || DEFAULT_CURRENCY
|
|
128
|
+
value.to_s.upcase
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def provider_for(event)
|
|
132
|
+
(event.respond_to?(:provider) ? event.provider : nil).to_s
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def upsert_call_rollups(rows)
|
|
136
|
+
LlmCostTracker::CallRollup.upsert_all(
|
|
137
|
+
rows,
|
|
138
|
+
on_duplicate: Ledger::Rollups::UpsertSql.call,
|
|
139
|
+
record_timestamps: true,
|
|
140
|
+
unique_by: call_rollups_unique_by
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def call_rollups_unique_by
|
|
145
|
+
return unless LlmCostTracker::CallRollup.connection.supports_insert_conflict_target?
|
|
81
146
|
|
|
82
|
-
%i[period period_start]
|
|
147
|
+
%i[period period_start currency provider]
|
|
83
148
|
end
|
|
84
149
|
end
|
|
85
150
|
end
|
|
@@ -32,6 +32,24 @@ module LlmCostTracker
|
|
|
32
32
|
raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def json_column_errors(column, adapter_value, column_name)
|
|
36
|
+
return [] unless column
|
|
37
|
+
|
|
38
|
+
expected_type = postgresql?(adapter_value) ? "jsonb" : "json"
|
|
39
|
+
return [] if json_column_type?(column, adapter_value)
|
|
40
|
+
|
|
41
|
+
["#{column_name} column must use #{expected_type} (got #{column.sql_type})"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def json_column_type?(column, adapter_value)
|
|
45
|
+
sql_type = column.sql_type.to_s.downcase
|
|
46
|
+
if postgresql?(adapter_value)
|
|
47
|
+
column.type == :jsonb || sql_type == "jsonb"
|
|
48
|
+
else
|
|
49
|
+
column.type == :json || sql_type == "json" || sql_type == "longtext"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
35
53
|
private
|
|
36
54
|
|
|
37
55
|
def adapter_instance?(value, class_names)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module CallLineItems
|
|
9
|
+
REQUIRED_COLUMNS = %w[
|
|
10
|
+
llm_cost_tracker_call_id
|
|
11
|
+
position
|
|
12
|
+
kind
|
|
13
|
+
direction
|
|
14
|
+
modality
|
|
15
|
+
cache_state
|
|
16
|
+
quantity
|
|
17
|
+
unit
|
|
18
|
+
rate_amount
|
|
19
|
+
rate_quantity
|
|
20
|
+
cost
|
|
21
|
+
currency
|
|
22
|
+
cost_status
|
|
23
|
+
pricing_basis
|
|
24
|
+
price_key
|
|
25
|
+
price_source
|
|
26
|
+
price_source_version
|
|
27
|
+
provider_field
|
|
28
|
+
provider_item_id
|
|
29
|
+
details
|
|
30
|
+
created_at
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
REQUIRED_INDEX_COLUMNS = [
|
|
34
|
+
%w[llm_cost_tracker_call_id position]
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def current_schema_errors
|
|
39
|
+
connection = LlmCostTracker::Call.connection
|
|
40
|
+
Adapter.ensure_supported!(connection)
|
|
41
|
+
table_name = LlmCostTracker::CallLineItem.table_name
|
|
42
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
43
|
+
|
|
44
|
+
columns = LlmCostTracker::CallLineItem.columns_hash
|
|
45
|
+
errors = []
|
|
46
|
+
missing = REQUIRED_COLUMNS - columns.keys
|
|
47
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
48
|
+
errors.concat(Adapter.json_column_errors(columns["details"], connection, "details"))
|
|
49
|
+
errors.concat(missing_index_errors(connection, table_name))
|
|
50
|
+
errors << missing_fk_error(connection, table_name) if missing_fk?(connection, table_name)
|
|
51
|
+
errors.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def missing_index_errors(connection, table_name)
|
|
55
|
+
existing = connection.indexes(table_name).map { |index| Array(index.columns).map(&:to_s) }
|
|
56
|
+
REQUIRED_INDEX_COLUMNS.filter_map do |required|
|
|
57
|
+
next if existing.any? { |columns| columns == required }
|
|
58
|
+
|
|
59
|
+
"missing index on (#{required.join(', ')})"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def missing_fk?(connection, table_name)
|
|
64
|
+
connection.foreign_keys(table_name).none? do |fk|
|
|
65
|
+
fk.column.to_s == "llm_cost_tracker_call_id" &&
|
|
66
|
+
fk.to_table.to_s == "llm_cost_tracker_calls"
|
|
67
|
+
end
|
|
68
|
+
rescue NotImplementedError, NoMethodError
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def missing_fk_error(_connection, _table_name)
|
|
73
|
+
"missing foreign key on llm_cost_tracker_call_id referencing llm_cost_tracker_calls"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Schema
|
|
8
|
+
module CallRollups
|
|
9
|
+
REQUIRED_COLUMNS = %w[period period_start currency provider total_cost created_at updated_at].freeze
|
|
10
|
+
UNIQUE_COLUMNS = %i[period period_start currency provider].freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def current_schema_errors
|
|
14
|
+
connection = LlmCostTracker::CallRollup.connection
|
|
15
|
+
Adapter.ensure_supported!(connection)
|
|
16
|
+
table_name = LlmCostTracker::CallRollup.table_name
|
|
17
|
+
return ["#{table_name} table is missing"] unless connection.data_source_exists?(table_name)
|
|
18
|
+
|
|
19
|
+
errors = []
|
|
20
|
+
missing = REQUIRED_COLUMNS - LlmCostTracker::CallRollup.columns_hash.keys
|
|
21
|
+
errors << "missing columns: #{missing.join(', ')}" if missing.any?
|
|
22
|
+
unless unique_period_index?(connection, table_name)
|
|
23
|
+
errors << "missing unique index: period, period_start, currency, provider"
|
|
24
|
+
end
|
|
25
|
+
errors
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def unique_period_index?(connection, table_name)
|
|
31
|
+
connection.index_exists?(table_name, UNIQUE_COLUMNS, unique: true)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|