llm_cost_tracker 0.5.2 → 0.6.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 +46 -0
- data/README.md +8 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
- data/docs/architecture.md +28 -0
- data/docs/budgets.md +45 -0
- data/docs/configuration.md +65 -0
- data/docs/cookbook.md +185 -0
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +38 -0
- data/docs/extending.md +32 -0
- data/docs/operations.md +44 -0
- data/docs/pricing.md +94 -0
- data/docs/querying.md +36 -0
- data/docs/streaming.md +70 -0
- data/docs/technical/README.md +10 -0
- data/docs/technical/data-flow.md +70 -0
- data/docs/technical/extension-points.md +111 -0
- data/docs/technical/module-map.md +197 -0
- data/docs/technical/operational-notes.md +97 -0
- data/docs/upgrading.md +47 -0
- data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
- data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
- data/lib/llm_cost_tracker/configuration.rb +2 -1
- data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
- data/lib/llm_cost_tracker/doctor.rb +8 -1
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
- data/lib/llm_cost_tracker/inbox_event.rb +9 -0
- data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
- data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
- data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
- data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
- data/lib/llm_cost_tracker/period_grouping.rb +4 -3
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
- data/lib/llm_cost_tracker/pricing/lookup.rb +143 -0
- data/lib/llm_cost_tracker/pricing.rb +25 -108
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/retention.rb +3 -9
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
- data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +59 -55
- data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
- data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
- data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
- data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
- data/lib/llm_cost_tracker/storage/registry.rb +63 -0
- data/lib/llm_cost_tracker/stream_collector.rb +18 -7
- data/lib/llm_cost_tracker/tag_sql.rb +34 -0
- data/lib/llm_cost_tracker/tags_column.rb +7 -1
- data/lib/llm_cost_tracker/tracker.rb +3 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +39 -1
- data/lib/tasks/llm_cost_tracker.rake +49 -0
- metadata +47 -2
data/docs/upgrading.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Upgrading
|
|
2
|
+
|
|
3
|
+
LLM Cost Tracker is still moving quickly, so upgrades should be explicit:
|
|
4
|
+
inspect the changelog, run doctor, and apply only the generators your schema is
|
|
5
|
+
missing.
|
|
6
|
+
|
|
7
|
+
The version-by-version upgrade guide is moving here from the README.
|
|
8
|
+
|
|
9
|
+
## Canonical Sources
|
|
10
|
+
|
|
11
|
+
Until this page is expanded, use:
|
|
12
|
+
|
|
13
|
+
- [Changelog](../CHANGELOG.md)
|
|
14
|
+
- [Quickstart](../README.md#quickstart)
|
|
15
|
+
- [Operations](operations.md)
|
|
16
|
+
|
|
17
|
+
## Schema Generators
|
|
18
|
+
|
|
19
|
+
Existing installs can add newer optional columns through focused generators:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bin/rails generate llm_cost_tracker:add_period_totals
|
|
23
|
+
bin/rails generate llm_cost_tracker:add_ingestion
|
|
24
|
+
bin/rails generate llm_cost_tracker:add_streaming
|
|
25
|
+
bin/rails generate llm_cost_tracker:add_provider_response_id
|
|
26
|
+
bin/rails generate llm_cost_tracker:add_usage_breakdown
|
|
27
|
+
bin/rails generate llm_cost_tracker:upgrade_tags_to_jsonb
|
|
28
|
+
bin/rails generate llm_cost_tracker:upgrade_cost_precision
|
|
29
|
+
bin/rails generate llm_cost_tracker:add_latency_ms
|
|
30
|
+
bin/rails db:migrate
|
|
31
|
+
bin/rails llm_cost_tracker:doctor
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
On PostgreSQL, `upgrade_tags_to_jsonb` rewrites `llm_api_calls`. For large
|
|
35
|
+
tables, run it during a maintenance window or replace it with a two-phase
|
|
36
|
+
backfill.
|
|
37
|
+
|
|
38
|
+
## Upgrade Habit
|
|
39
|
+
|
|
40
|
+
Run:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bin/rails llm_cost_tracker:doctor
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Doctor tells you which optional columns and production-hardening pieces are still
|
|
47
|
+
missing.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ActiveRecordAdapter
|
|
5
|
+
MYSQL_ADAPTERS = %w[
|
|
6
|
+
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
|
|
7
|
+
ActiveRecord::ConnectionAdapters::Mysql2Adapter
|
|
8
|
+
ActiveRecord::ConnectionAdapters::TrilogyAdapter
|
|
9
|
+
].freeze
|
|
10
|
+
POSTGRESQL_ADAPTERS = %w[
|
|
11
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
12
|
+
].freeze
|
|
13
|
+
SQLITE_ADAPTERS = %w[
|
|
14
|
+
ActiveRecord::ConnectionAdapters::SQLite3Adapter
|
|
15
|
+
].freeze
|
|
16
|
+
MYSQL_PATTERN = /mysql|trilogy|mariadb/i
|
|
17
|
+
POSTGRESQL_PATTERN = /postgres/i
|
|
18
|
+
SQLITE_PATTERN = /sqlite/i
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def mysql?(value) = adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
|
|
22
|
+
|
|
23
|
+
def postgresql?(value)
|
|
24
|
+
adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sqlite?(value) = adapter_instance?(value, SQLITE_ADAPTERS) || adapter_name(value).match?(SQLITE_PATTERN)
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def adapter_instance?(value, class_names)
|
|
32
|
+
class_names.any? do |class_name|
|
|
33
|
+
adapter_class = constantize(class_name)
|
|
34
|
+
adapter_class && value.is_a?(adapter_class)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def constantize(name)
|
|
39
|
+
name.split("::").reduce(Object) { |namespace, part| namespace.const_get(part, false) }
|
|
40
|
+
rescue NameError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def adapter_name(value)
|
|
45
|
+
value.respond_to?(:adapter_name) ? value.adapter_name.to_s : value.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "storage/dispatcher"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class CaptureVerifier
|
|
7
|
+
Check = Data.define(:status, :name, :message)
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def call = new.checks
|
|
11
|
+
|
|
12
|
+
def report(checks = call)
|
|
13
|
+
(["LLM Cost Tracker capture verification"] + checks.map { |check| format_check(check) }).join("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def healthy?(checks = call)
|
|
17
|
+
checks.none? { |check| check.status == :error }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def format_check(check)
|
|
23
|
+
"[#{check.status}] #{check.name}: #{check.message}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def checks
|
|
28
|
+
[
|
|
29
|
+
enabled_check,
|
|
30
|
+
*integration_checks,
|
|
31
|
+
*storage_checks
|
|
32
|
+
].compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def enabled_check
|
|
38
|
+
return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
|
|
39
|
+
|
|
40
|
+
Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def integration_checks
|
|
44
|
+
enabled = LlmCostTracker.configuration.instrumented_integrations
|
|
45
|
+
if enabled.empty?
|
|
46
|
+
return [
|
|
47
|
+
Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
LlmCostTracker::Integrations.checks.map do |check|
|
|
52
|
+
Check.new(check.status, "sdk integration #{check.name}", check.message)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def storage_checks
|
|
57
|
+
backend = LlmCostTracker::Storage::Registry.fetch(LlmCostTracker.configuration.storage_backend)
|
|
58
|
+
unless backend.respond_to?(:verify)
|
|
59
|
+
return [
|
|
60
|
+
Check.new(:warn, "storage", "#{LlmCostTracker.configuration.storage_backend} backend has no verifier")
|
|
61
|
+
]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
backend.verify.map do |check|
|
|
65
|
+
Check.new(check.status, check.name, check.message)
|
|
66
|
+
end
|
|
67
|
+
rescue LlmCostTracker::Error => e
|
|
68
|
+
[Check.new(:error, "storage", e.message)]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ConfigurationStorageBackend
|
|
5
|
+
def storage_backend=(value)
|
|
6
|
+
ensure_shared_configuration_mutable!
|
|
7
|
+
@storage_backend = normalize_storage_backend(value)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def normalize_storage_backend(value)
|
|
13
|
+
value = :log if value.nil?
|
|
14
|
+
value = value.to_sym
|
|
15
|
+
return value if self.class::STORAGE_BACKENDS.include?(value)
|
|
16
|
+
return value if defined?(Storage::Registry) && Storage::Registry.registered?(value)
|
|
17
|
+
|
|
18
|
+
names = if defined?(Storage::Registry)
|
|
19
|
+
Storage::Registry.names
|
|
20
|
+
else
|
|
21
|
+
self.class::STORAGE_BACKENDS
|
|
22
|
+
end
|
|
23
|
+
raise Error, "Unknown storage_backend: #{value.inspect}. Use one of: #{names.join(', ')}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -4,10 +4,12 @@ require_relative "errors"
|
|
|
4
4
|
require_relative "tag_key"
|
|
5
5
|
require_relative "value_helpers"
|
|
6
6
|
require_relative "configuration/instrumentation"
|
|
7
|
+
require_relative "configuration/storage_backend"
|
|
7
8
|
|
|
8
9
|
module LlmCostTracker
|
|
9
10
|
class Configuration
|
|
10
11
|
include ConfigurationInstrumentation
|
|
12
|
+
include ConfigurationStorageBackend
|
|
11
13
|
|
|
12
14
|
OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
|
|
13
15
|
|
|
@@ -18,7 +20,6 @@ module LlmCostTracker
|
|
|
18
20
|
SHARED_SCALAR_ATTRIBUTES = %i[enabled custom_storage on_budget_exceeded monthly_budget daily_budget per_call_budget
|
|
19
21
|
log_level prices_file max_tag_count max_tag_value_bytesize].freeze
|
|
20
22
|
SHARED_ENUM_ATTRIBUTES = {
|
|
21
|
-
storage_backend: [STORAGE_BACKENDS, :log],
|
|
22
23
|
budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
|
|
23
24
|
storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
|
|
24
25
|
unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class Doctor
|
|
5
|
+
class CaptureCheck
|
|
6
|
+
def self.call(check_class)
|
|
7
|
+
new(check_class).call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(check_class)
|
|
11
|
+
@check_class = check_class
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
config = LlmCostTracker.configuration
|
|
16
|
+
return disabled_check unless config.enabled
|
|
17
|
+
return integrations_check(config.instrumented_integrations) if config.instrumented_integrations.any?
|
|
18
|
+
|
|
19
|
+
check(:ok, "no SDK integrations enabled; Faraday middleware and manual capture remain available")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :check_class
|
|
25
|
+
|
|
26
|
+
def disabled_check
|
|
27
|
+
check(:warn, "tracking is disabled; set config.enabled = true to record calls")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def integrations_check(integrations)
|
|
31
|
+
check(:ok, "SDK integrations enabled: #{integrations.join(', ')}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check(status, message)
|
|
35
|
+
check_class.new(status, "capture", message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Doctor
|
|
7
|
+
class IngestionCheck
|
|
8
|
+
PENDING_AGE_WARNING_SECONDS = 60
|
|
9
|
+
|
|
10
|
+
def self.call(check_class)
|
|
11
|
+
new(check_class).call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(check_class)
|
|
15
|
+
@check_class = check_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless active_record_storage? && llm_api_calls_table?
|
|
20
|
+
|
|
21
|
+
missing = missing_parts
|
|
22
|
+
if missing.empty?
|
|
23
|
+
quarantined = quarantined_count
|
|
24
|
+
if quarantined.positive?
|
|
25
|
+
return check_class.new(:warn, "durable ingestion", "#{quarantined} inbox events quarantined after retries")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
pending = pending_snapshot
|
|
29
|
+
if stale_pending?(pending)
|
|
30
|
+
return check_class.new(
|
|
31
|
+
:warn,
|
|
32
|
+
"durable ingestion",
|
|
33
|
+
"#{pending.fetch(:count)} inbox events pending; oldest pending age #{pending_age(pending).round}s"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return check_class.new(:ok, "durable ingestion", "inbox and ingestor lease tables available")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
check_class.new(
|
|
41
|
+
:warn,
|
|
42
|
+
"durable ingestion",
|
|
43
|
+
"missing #{missing.join(', ')}; run bin/rails generate llm_cost_tracker:add_ingestion && bin/rails db:migrate"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :check_class
|
|
50
|
+
|
|
51
|
+
def missing_parts
|
|
52
|
+
[
|
|
53
|
+
column_names("llm_api_calls").include?("event_id") ? nil : "llm_api_calls.event_id",
|
|
54
|
+
table_exists?("llm_cost_tracker_inbox_events") ? nil : "llm_cost_tracker_inbox_events",
|
|
55
|
+
table_exists?("llm_cost_tracker_ingestor_leases") ? nil : "llm_cost_tracker_ingestor_leases"
|
|
56
|
+
].compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
|
|
60
|
+
|
|
61
|
+
def llm_api_calls_table? = table_exists?("llm_api_calls")
|
|
62
|
+
|
|
63
|
+
def table_exists?(name)
|
|
64
|
+
LlmCostTracker::LlmApiCall.connection.data_source_exists?(name)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def column_names(table) = LlmCostTracker::LlmApiCall.connection.columns(table).map(&:name)
|
|
70
|
+
|
|
71
|
+
def quarantined_count
|
|
72
|
+
return 0 unless table_exists?("llm_cost_tracker_inbox_events")
|
|
73
|
+
|
|
74
|
+
LlmCostTracker::LlmApiCall.connection.select_value(quarantined_sql).to_i
|
|
75
|
+
rescue StandardError
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def quarantined_sql
|
|
80
|
+
table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
|
|
81
|
+
"SELECT COUNT(*) FROM #{table} WHERE attempts >= #{max_attempts}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pending_snapshot
|
|
85
|
+
row = LlmCostTracker::LlmApiCall.connection.select_one(pending_sql) || {}
|
|
86
|
+
{
|
|
87
|
+
count: row.fetch("pending_count").to_i,
|
|
88
|
+
oldest_at: row["oldest_created_at"] && Time.parse(row.fetch("oldest_created_at").to_s).utc
|
|
89
|
+
}
|
|
90
|
+
rescue StandardError
|
|
91
|
+
{ count: 0, oldest_at: nil }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pending_sql
|
|
95
|
+
table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
|
|
96
|
+
"SELECT COUNT(*) AS pending_count, MIN(created_at) AS oldest_created_at " \
|
|
97
|
+
"FROM #{table} WHERE attempts < #{max_attempts}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stale_pending?(pending)
|
|
101
|
+
pending.fetch(:count).positive? &&
|
|
102
|
+
pending.fetch(:oldest_at) &&
|
|
103
|
+
pending_age(pending) >= PENDING_AGE_WARNING_SECONDS
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def pending_age(pending) = Time.now.utc - pending.fetch(:oldest_at)
|
|
107
|
+
|
|
108
|
+
def max_attempts
|
|
109
|
+
if defined?(LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS)
|
|
110
|
+
LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS
|
|
111
|
+
else
|
|
112
|
+
5
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "price_freshness"
|
|
4
|
+
require_relative "doctor/capture_check"
|
|
5
|
+
require_relative "doctor/ingestion_check"
|
|
4
6
|
|
|
5
7
|
module LlmCostTracker
|
|
6
8
|
class Doctor
|
|
@@ -38,11 +40,13 @@ module LlmCostTracker
|
|
|
38
40
|
def checks
|
|
39
41
|
[
|
|
40
42
|
configuration_check,
|
|
43
|
+
capture_check,
|
|
41
44
|
*integration_checks,
|
|
42
45
|
active_record_check,
|
|
43
46
|
table_check,
|
|
44
47
|
column_check,
|
|
45
48
|
period_totals_check,
|
|
49
|
+
IngestionCheck.call(Check),
|
|
46
50
|
prices_check,
|
|
47
51
|
calls_check
|
|
48
52
|
].compact
|
|
@@ -51,9 +55,12 @@ module LlmCostTracker
|
|
|
51
55
|
private
|
|
52
56
|
|
|
53
57
|
def configuration_check
|
|
54
|
-
|
|
58
|
+
config = LlmCostTracker.configuration
|
|
59
|
+
Check.new(:ok, "configuration", "storage_backend=#{config.storage_backend.inspect}, enabled=#{config.enabled}")
|
|
55
60
|
end
|
|
56
61
|
|
|
62
|
+
def capture_check = CaptureCheck.call(Check)
|
|
63
|
+
|
|
57
64
|
def integration_checks
|
|
58
65
|
LlmCostTracker::Integrations.checks.map do |check|
|
|
59
66
|
Check.new(check.status, check.name.to_s, check.message)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddIngestionGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add durable ActiveRecord ingestion"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_ingestion_to_llm_cost_tracker.rb.erb",
|
|
18
|
+
"db/migrate/add_ingestion_to_llm_cost_tracker.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class AddIngestionToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
add_column :llm_api_calls, :event_id, :string unless column_exists?(:llm_api_calls, :event_id)
|
|
4
|
+
add_index :llm_api_calls, :event_id, unique: true if column_exists?(:llm_api_calls, :event_id) &&
|
|
5
|
+
!index_exists?(:llm_api_calls, :event_id)
|
|
6
|
+
|
|
7
|
+
create_table :llm_cost_tracker_inbox_events do |t|
|
|
8
|
+
t.string :event_id, null: false
|
|
9
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
10
|
+
t.datetime :tracked_at, null: false
|
|
11
|
+
t.text :payload, null: false
|
|
12
|
+
t.datetime :locked_at
|
|
13
|
+
t.string :locked_by
|
|
14
|
+
t.integer :attempts, null: false, default: 0
|
|
15
|
+
t.text :last_error
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end unless table_exists?(:llm_cost_tracker_inbox_events)
|
|
19
|
+
|
|
20
|
+
create_table :llm_cost_tracker_ingestor_leases do |t|
|
|
21
|
+
t.string :name, null: false
|
|
22
|
+
t.string :locked_by
|
|
23
|
+
t.datetime :locked_until
|
|
24
|
+
|
|
25
|
+
t.timestamps
|
|
26
|
+
end unless table_exists?(:llm_cost_tracker_ingestor_leases)
|
|
27
|
+
|
|
28
|
+
add_index :llm_cost_tracker_inbox_events, :event_id, unique: true unless index_exists?(:llm_cost_tracker_inbox_events, :event_id)
|
|
29
|
+
add_index :llm_cost_tracker_inbox_events, :tracked_at unless index_exists?(:llm_cost_tracker_inbox_events, :tracked_at)
|
|
30
|
+
add_index :llm_cost_tracker_inbox_events, [:locked_at, :id] unless index_exists?(:llm_cost_tracker_inbox_events, [:locked_at, :id])
|
|
31
|
+
add_index :llm_cost_tracker_ingestor_leases, :name, unique: true unless index_exists?(:llm_cost_tracker_ingestor_leases, :name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def up
|
|
3
5
|
create_table :llm_cost_tracker_period_totals do |t|
|
|
@@ -73,10 +75,9 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
def day_bucket_sql
|
|
76
|
-
|
|
77
|
-
when /postgres/i
|
|
78
|
+
if postgresql?
|
|
78
79
|
"DATE_TRUNC('day', tracked_at)::date"
|
|
79
|
-
|
|
80
|
+
elsif mysql?
|
|
80
81
|
"DATE(tracked_at)"
|
|
81
82
|
else
|
|
82
83
|
"date(tracked_at)"
|
|
@@ -84,13 +85,20 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
def month_bucket_sql
|
|
87
|
-
|
|
88
|
-
when /postgres/i
|
|
88
|
+
if postgresql?
|
|
89
89
|
"DATE_TRUNC('month', tracked_at)::date"
|
|
90
|
-
|
|
90
|
+
elsif mysql?
|
|
91
91
|
"DATE_FORMAT(tracked_at, '%Y-%m-01')"
|
|
92
92
|
else
|
|
93
93
|
"strftime('%Y-%m-01', tracked_at)"
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
|
+
|
|
97
|
+
def postgresql?
|
|
98
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def mysql?
|
|
102
|
+
LlmCostTracker::ActiveRecordAdapter.mysql?(connection)
|
|
103
|
+
end
|
|
96
104
|
end
|
|
@@ -2,23 +2,19 @@ class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
2
2
|
def up
|
|
3
3
|
unless column_exists?(:llm_api_calls, :stream)
|
|
4
4
|
add_column :llm_api_calls, :stream, :boolean, null: false, default: false
|
|
5
|
-
add_index :llm_api_calls, :stream
|
|
6
5
|
end
|
|
7
6
|
|
|
8
7
|
unless column_exists?(:llm_api_calls, :usage_source)
|
|
9
8
|
add_column :llm_api_calls, :usage_source, :string
|
|
10
|
-
add_index :llm_api_calls, :usage_source
|
|
11
9
|
end
|
|
12
10
|
end
|
|
13
11
|
|
|
14
12
|
def down
|
|
15
13
|
if column_exists?(:llm_api_calls, :usage_source)
|
|
16
|
-
remove_index :llm_api_calls, :usage_source if index_exists?(:llm_api_calls, :usage_source)
|
|
17
14
|
remove_column :llm_api_calls, :usage_source
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
if column_exists?(:llm_api_calls, :stream)
|
|
21
|
-
remove_index :llm_api_calls, :stream if index_exists?(:llm_api_calls, :stream)
|
|
22
18
|
remove_column :llm_api_calls, :stream
|
|
23
19
|
end
|
|
24
20
|
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def change
|
|
3
5
|
create_table :llm_api_calls do |t|
|
|
6
|
+
t.string :event_id, null: false
|
|
4
7
|
t.string :provider, null: false
|
|
5
8
|
t.string :model, null: false
|
|
6
9
|
t.integer :input_tokens, null: false, default: 0
|
|
@@ -37,19 +40,43 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
37
40
|
t.timestamps
|
|
38
41
|
end
|
|
39
42
|
|
|
43
|
+
create_table :llm_cost_tracker_inbox_events do |t|
|
|
44
|
+
t.string :event_id, null: false
|
|
45
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
46
|
+
t.datetime :tracked_at, null: false
|
|
47
|
+
t.text :payload, null: false
|
|
48
|
+
t.datetime :locked_at
|
|
49
|
+
t.string :locked_by
|
|
50
|
+
t.integer :attempts, null: false, default: 0
|
|
51
|
+
t.text :last_error
|
|
52
|
+
|
|
53
|
+
t.timestamps
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
create_table :llm_cost_tracker_ingestor_leases do |t|
|
|
57
|
+
t.string :name, null: false
|
|
58
|
+
t.string :locked_by
|
|
59
|
+
t.datetime :locked_until
|
|
60
|
+
|
|
61
|
+
t.timestamps
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
add_index :llm_api_calls, :event_id, unique: true
|
|
40
65
|
add_index :llm_api_calls, :tracked_at
|
|
41
66
|
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
42
67
|
add_index :llm_api_calls, [:model, :tracked_at]
|
|
43
|
-
add_index :llm_api_calls, :stream
|
|
44
|
-
add_index :llm_api_calls, :usage_source
|
|
45
68
|
add_index :llm_api_calls, :provider_response_id
|
|
46
69
|
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
47
70
|
add_index :llm_cost_tracker_period_totals, [:period, :period_start], unique: true
|
|
71
|
+
add_index :llm_cost_tracker_inbox_events, :event_id, unique: true
|
|
72
|
+
add_index :llm_cost_tracker_inbox_events, :tracked_at
|
|
73
|
+
add_index :llm_cost_tracker_inbox_events, [:locked_at, :id]
|
|
74
|
+
add_index :llm_cost_tracker_ingestor_leases, :name, unique: true
|
|
48
75
|
end
|
|
49
76
|
|
|
50
77
|
private
|
|
51
78
|
|
|
52
79
|
def postgresql?
|
|
53
|
-
|
|
80
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
54
81
|
end
|
|
55
82
|
end
|
|
@@ -4,7 +4,7 @@ LlmCostTracker.configure do |config|
|
|
|
4
4
|
# Set to false to temporarily disable tracking without removing middleware.
|
|
5
5
|
config.enabled = true
|
|
6
6
|
|
|
7
|
-
# :active_record
|
|
7
|
+
# :active_record captures events into a durable inbox and ingests them into llm_api_calls.
|
|
8
8
|
# Other options: :log for local logging, :custom for your own storage callable.
|
|
9
9
|
config.storage_backend = :active_record
|
|
10
10
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def up
|
|
3
5
|
unless postgresql?
|
|
@@ -32,7 +34,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
|
|
|
32
34
|
private
|
|
33
35
|
|
|
34
36
|
def postgresql?
|
|
35
|
-
|
|
37
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def tags_jsonb?
|