llm_cost_tracker 0.6.0 → 0.7.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 +14 -0
- data/README.md +2 -3
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -17
- data/config/routes.rb +1 -1
- data/lib/llm_cost_tracker/active_record_adapter.rb +9 -5
- data/lib/llm_cost_tracker/assets.rb +0 -6
- data/lib/llm_cost_tracker/budget.rb +3 -5
- data/lib/llm_cost_tracker/capture_verifier.rb +3 -10
- data/lib/llm_cost_tracker/configuration.rb +2 -10
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +1 -3
- data/lib/llm_cost_tracker/doctor.rb +8 -13
- data/lib/llm_cost_tracker/engine.rb +0 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +2 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +7 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -17
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -2
- data/lib/llm_cost_tracker/period_grouping.rb +3 -5
- data/lib/llm_cost_tracker/railtie.rb +2 -4
- data/lib/llm_cost_tracker/report.rb +2 -4
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +2 -1
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +1 -6
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +1 -1
- data/lib/llm_cost_tracker/storage/{dispatcher.rb → writer.rb} +4 -14
- data/lib/llm_cost_tracker/tag_sql.rb +1 -1
- data/lib/llm_cost_tracker/tags_column.rb +2 -0
- data/lib/llm_cost_tracker/tracker.rb +2 -2
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +2 -14
- data/lib/tasks/llm_cost_tracker.rake +1 -1
- metadata +33 -60
- data/docs/architecture.md +0 -28
- data/docs/budgets.md +0 -45
- data/docs/configuration.md +0 -65
- data/docs/cookbook.md +0 -185
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +0 -38
- data/docs/extending.md +0 -32
- data/docs/operations.md +0 -44
- data/docs/pricing.md +0 -94
- data/docs/querying.md +0 -36
- data/docs/streaming.md +0 -70
- data/docs/technical/README.md +0 -10
- data/docs/technical/data-flow.md +0 -70
- data/docs/technical/extension-points.md +0 -111
- data/docs/technical/module-map.md +0 -197
- data/docs/technical/operational-notes.md +0 -97
- data/docs/upgrading.md +0 -47
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +0 -26
- data/lib/llm_cost_tracker/engine_compatibility.rb +0 -15
- data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -32
- data/lib/llm_cost_tracker/storage/log_backend.rb +0 -38
- data/lib/llm_cost_tracker/storage/registry.rb +0 -63
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9de844d3302fda39c5a8b32736d8abff488a5cd6cfd00932f492a1ec701f308d
|
|
4
|
+
data.tar.gz: a8f942605f9cfbcc77f7e998e3e504150fa820e44829156e901cb06bdcc6d0cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c7d9869224101f85298dab0b4395b0c2b96634f46f24e76487412f0f8418e4270fb15124f6f238499c3205f4ad2ebae6e8bcba1b265f7496e3509b7da7c5ea9
|
|
7
|
+
data.tar.gz: f07e91cf62de94d7e7c76eff7ec4b798208724df5722374d6a72884445a70a64ef83421b90aa8be51b887e277f3e8bcb7eec3ad14987eec25609483bc11f0667
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.7.0] - 2026-04-29
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- BREAKING: ActiveRecord is now the only storage path; removed `storage_backend`, `custom_storage`, `Storage.register`, `:log`, and `:custom`.
|
|
12
|
+
- BREAKING: PostgreSQL and MySQL are now the only supported database adapters; SQLite support was removed.
|
|
13
|
+
- Runtime dependencies now include Rails and ActiveRecord.
|
|
14
|
+
|
|
15
|
+
## [0.6.1] - 2026-04-29
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Exclude repository documentation from the published gem package.
|
|
20
|
+
|
|
7
21
|
## [0.6.0] - 2026-04-29
|
|
8
22
|
|
|
9
23
|
### Added
|
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@ If you have OpenAI, Anthropic, or Gemini in production and someone keeps asking
|
|
|
10
10
|
|
|
11
11
|
It is not Langfuse, Helicone, or LiteLLM. It does not capture prompts, score completions, or replay traces. It does one thing: tells you which provider, which model, which feature, and which user burned how much money. That's the entire pitch.
|
|
12
12
|
|
|
13
|
-
Requires Ruby 3.3+,
|
|
13
|
+
Requires Ruby 3.3+, Rails 7.1+, PostgreSQL or MySQL, and Faraday 2.0+.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -35,7 +35,6 @@ Drop this into `config/initializers/llm_cost_tracker.rb`:
|
|
|
35
35
|
|
|
36
36
|
```ruby
|
|
37
37
|
LlmCostTracker.configure do |config|
|
|
38
|
-
config.storage_backend = :active_record
|
|
39
38
|
config.default_tags = -> { { environment: Rails.env } }
|
|
40
39
|
config.instrument :openai
|
|
41
40
|
end
|
|
@@ -219,7 +218,7 @@ Mount the engine wherever you want — it's plain ERB, no JavaScript bundle, no
|
|
|
219
218
|
mount LlmCostTracker::Engine => "/llm-costs"
|
|
220
219
|
```
|
|
221
220
|
|
|
222
|
-
Pages: overview (spend trend, budget status, anomaly banner), models, calls (filterable, paginated, CSV export), tags, data quality. Reads
|
|
221
|
+
Pages: overview (spend trend, budget status, anomaly banner), models, calls (filterable, paginated, CSV export), tags, data quality. Reads the ActiveRecord ledger in `llm_api_calls`.
|
|
223
222
|
|
|
224
223
|
Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs/dashboard.md).
|
|
225
224
|
|
|
@@ -45,7 +45,7 @@ module LlmCostTracker
|
|
|
45
45
|
return postgresql_sql if ActiveRecordAdapter.postgresql?(connection)
|
|
46
46
|
return mysql_sql if ActiveRecordAdapter.mysql?(connection)
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
ActiveRecordAdapter.ensure_supported!(connection)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def mysql_sql
|
|
@@ -83,22 +83,6 @@ module LlmCostTracker
|
|
|
83
83
|
SQL
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
-
def sqlite_sql
|
|
87
|
-
<<~SQL.squish
|
|
88
|
-
SELECT je.key AS key,
|
|
89
|
-
COUNT(*) AS calls_count,
|
|
90
|
-
COUNT(DISTINCT je.value) AS distinct_values
|
|
91
|
-
FROM (#{subquery}) AS sub,
|
|
92
|
-
json_each(sub.tags) AS je
|
|
93
|
-
WHERE sub.tags IS NOT NULL
|
|
94
|
-
AND sub.tags != '{}'
|
|
95
|
-
AND sub.tags != ''
|
|
96
|
-
GROUP BY je.key
|
|
97
|
-
ORDER BY calls_count DESC
|
|
98
|
-
LIMIT #{limit}
|
|
99
|
-
SQL
|
|
100
|
-
end
|
|
101
|
-
|
|
102
86
|
def normalized_limit(value)
|
|
103
87
|
value = value.to_i
|
|
104
88
|
value.positive? ? [value, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
data/config/routes.rb
CHANGED
|
@@ -8,6 +8,6 @@ LlmCostTracker::Engine.routes.draw do
|
|
|
8
8
|
get "tags/:key", to: "tags#show", as: :tag, format: false
|
|
9
9
|
get "data_quality", to: "data_quality#index", as: :data_quality
|
|
10
10
|
|
|
11
|
-
get "assets/#{LlmCostTracker::Assets
|
|
11
|
+
get "assets/#{LlmCostTracker::Assets::STYLESHEET_FILENAME}",
|
|
12
12
|
to: "assets#stylesheet", as: :stylesheet
|
|
13
13
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module ActiveRecordAdapter
|
|
5
7
|
MYSQL_ADAPTERS = %w[
|
|
@@ -10,12 +12,8 @@ module LlmCostTracker
|
|
|
10
12
|
POSTGRESQL_ADAPTERS = %w[
|
|
11
13
|
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
12
14
|
].freeze
|
|
13
|
-
SQLITE_ADAPTERS = %w[
|
|
14
|
-
ActiveRecord::ConnectionAdapters::SQLite3Adapter
|
|
15
|
-
].freeze
|
|
16
15
|
MYSQL_PATTERN = /mysql|trilogy|mariadb/i
|
|
17
16
|
POSTGRESQL_PATTERN = /postgres/i
|
|
18
|
-
SQLITE_PATTERN = /sqlite/i
|
|
19
17
|
|
|
20
18
|
class << self
|
|
21
19
|
def mysql?(value) = adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
|
|
@@ -24,7 +22,13 @@ module LlmCostTracker
|
|
|
24
22
|
adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
|
|
25
23
|
end
|
|
26
24
|
|
|
27
|
-
def
|
|
25
|
+
def supported?(value) = mysql?(value) || postgresql?(value)
|
|
26
|
+
|
|
27
|
+
def ensure_supported!(value)
|
|
28
|
+
return if supported?(value)
|
|
29
|
+
|
|
30
|
+
raise Error, "Unsupported database adapter: #{adapter_name(value)}. Use PostgreSQL or MySQL."
|
|
31
|
+
end
|
|
28
32
|
|
|
29
33
|
private
|
|
30
34
|
|
|
@@ -9,11 +9,5 @@ module LlmCostTracker
|
|
|
9
9
|
STYLESHEET_PATH = File.join(ROOT, STYLESHEET).freeze
|
|
10
10
|
STYLESHEET_FINGERPRINT = Digest::SHA256.file(STYLESHEET_PATH).hexdigest[0, 12].freeze
|
|
11
11
|
STYLESHEET_FILENAME = "application-#{STYLESHEET_FINGERPRINT}.css".freeze
|
|
12
|
-
|
|
13
|
-
class << self
|
|
14
|
-
def root = ROOT
|
|
15
|
-
def stylesheet_fingerprint = STYLESHEET_FINGERPRINT
|
|
16
|
-
def stylesheet_filename = STYLESHEET_FILENAME
|
|
17
|
-
end
|
|
18
12
|
end
|
|
19
13
|
end
|
|
@@ -8,7 +8,6 @@ module LlmCostTracker
|
|
|
8
8
|
def enforce!
|
|
9
9
|
config = LlmCostTracker.configuration
|
|
10
10
|
return unless config.budget_exceeded_behavior == :block_requests
|
|
11
|
-
return unless config.active_record?
|
|
12
11
|
|
|
13
12
|
budgets = enforce_period_budgets(config)
|
|
14
13
|
return if budgets.empty?
|
|
@@ -28,7 +27,7 @@ module LlmCostTracker
|
|
|
28
27
|
|
|
29
28
|
check_per_call_budget(event, config)
|
|
30
29
|
budgets = check_period_budgets(config)
|
|
31
|
-
totals = totals_for_check(event,
|
|
30
|
+
totals = totals_for_check(event, budgets)
|
|
32
31
|
|
|
33
32
|
budgets.each do |period, budget|
|
|
34
33
|
total = totals.fetch(period)
|
|
@@ -63,11 +62,10 @@ module LlmCostTracker
|
|
|
63
62
|
}.compact
|
|
64
63
|
end
|
|
65
64
|
|
|
66
|
-
def totals_for_check(event,
|
|
65
|
+
def totals_for_check(event, budgets)
|
|
67
66
|
return {} if budgets.empty?
|
|
68
|
-
return active_record_totals(budgets.keys, time: event.tracked_at) if config.active_record?
|
|
69
67
|
|
|
70
|
-
budgets.
|
|
68
|
+
active_record_totals(budgets.keys, time: event.tracked_at)
|
|
71
69
|
end
|
|
72
70
|
|
|
73
71
|
def active_record_totals(periods, time:)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "storage/
|
|
3
|
+
require_relative "storage/active_record_backend"
|
|
4
4
|
|
|
5
5
|
module LlmCostTracker
|
|
6
6
|
class CaptureVerifier
|
|
@@ -48,20 +48,13 @@ module LlmCostTracker
|
|
|
48
48
|
]
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
LlmCostTracker::Integrations.checks.map do |check|
|
|
51
|
+
LlmCostTracker::Integrations::Registry.checks.map do |check|
|
|
52
52
|
Check.new(check.status, "sdk integration #{check.name}", check.message)
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def storage_checks
|
|
57
|
-
|
|
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|
|
|
57
|
+
LlmCostTracker::Storage::ActiveRecordBackend.verify.map do |check|
|
|
65
58
|
Check.new(check.status, check.name, check.message)
|
|
66
59
|
end
|
|
67
60
|
rescue LlmCostTracker::Error => e
|
|
@@ -4,21 +4,18 @@ 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"
|
|
8
7
|
|
|
9
8
|
module LlmCostTracker
|
|
10
9
|
class Configuration
|
|
11
10
|
include ConfigurationInstrumentation
|
|
12
|
-
include ConfigurationStorageBackend
|
|
13
11
|
|
|
14
12
|
OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
|
|
15
13
|
|
|
16
14
|
BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
|
|
17
15
|
STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
|
|
18
|
-
STORAGE_BACKENDS = %i[log active_record custom].freeze
|
|
19
16
|
UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
|
|
20
|
-
SHARED_SCALAR_ATTRIBUTES = %i[enabled
|
|
21
|
-
|
|
17
|
+
SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget log_level
|
|
18
|
+
prices_file max_tag_count max_tag_value_bytesize].freeze
|
|
22
19
|
SHARED_ENUM_ATTRIBUTES = {
|
|
23
20
|
budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
|
|
24
21
|
storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
|
|
@@ -34,7 +31,6 @@ module LlmCostTracker
|
|
|
34
31
|
:instrumented_integrations,
|
|
35
32
|
:report_tag_breakdowns,
|
|
36
33
|
:redacted_tag_keys,
|
|
37
|
-
:storage_backend,
|
|
38
34
|
:storage_error_behavior,
|
|
39
35
|
:unknown_pricing_behavior,
|
|
40
36
|
:openai_compatible_providers
|
|
@@ -42,8 +38,6 @@ module LlmCostTracker
|
|
|
42
38
|
|
|
43
39
|
def initialize
|
|
44
40
|
@enabled = true
|
|
45
|
-
self.storage_backend = :log
|
|
46
|
-
@custom_storage = nil
|
|
47
41
|
@default_tags = {}
|
|
48
42
|
@on_budget_exceeded = nil
|
|
49
43
|
@monthly_budget = nil
|
|
@@ -134,8 +128,6 @@ module LlmCostTracker
|
|
|
134
128
|
copy
|
|
135
129
|
end
|
|
136
130
|
|
|
137
|
-
def active_record? = storage_backend == :active_record
|
|
138
|
-
|
|
139
131
|
private
|
|
140
132
|
|
|
141
133
|
def normalize_enum(name, value, allowed, default:)
|
|
@@ -16,7 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def call
|
|
19
|
-
return unless
|
|
19
|
+
return unless llm_api_calls_table?
|
|
20
20
|
|
|
21
21
|
missing = missing_parts
|
|
22
22
|
if missing.empty?
|
|
@@ -56,8 +56,6 @@ module LlmCostTracker
|
|
|
56
56
|
].compact
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
|
|
60
|
-
|
|
61
59
|
def llm_api_calls_table? = table_exists?("llm_api_calls")
|
|
62
60
|
|
|
63
61
|
def table_exists?(name)
|
|
@@ -40,7 +40,7 @@ module LlmCostTracker
|
|
|
40
40
|
def checks
|
|
41
41
|
[
|
|
42
42
|
configuration_check,
|
|
43
|
-
|
|
43
|
+
CaptureCheck.call(Check),
|
|
44
44
|
*integration_checks,
|
|
45
45
|
active_record_check,
|
|
46
46
|
table_check,
|
|
@@ -56,26 +56,23 @@ module LlmCostTracker
|
|
|
56
56
|
|
|
57
57
|
def configuration_check
|
|
58
58
|
config = LlmCostTracker.configuration
|
|
59
|
-
Check.new(:ok, "configuration", "
|
|
59
|
+
Check.new(:ok, "configuration", "active_record ledger enabled=#{config.enabled}")
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
def capture_check = CaptureCheck.call(Check)
|
|
63
|
-
|
|
64
62
|
def integration_checks
|
|
65
|
-
LlmCostTracker::Integrations.checks.map do |check|
|
|
63
|
+
LlmCostTracker::Integrations::Registry.checks.map do |check|
|
|
66
64
|
Check.new(check.status, check.name.to_s, check.message)
|
|
67
65
|
end
|
|
68
66
|
end
|
|
69
67
|
|
|
70
68
|
def active_record_check
|
|
71
|
-
return Check.new(:ok, "storage", "ActiveRecord storage is disabled") unless active_record_storage?
|
|
72
69
|
return Check.new(:ok, "active_record", "available") if active_record_available?
|
|
73
70
|
|
|
74
|
-
Check.new(:error, "active_record", "unavailable
|
|
71
|
+
Check.new(:error, "active_record", "unavailable")
|
|
75
72
|
end
|
|
76
73
|
|
|
77
74
|
def table_check
|
|
78
|
-
return unless
|
|
75
|
+
return unless active_record_available?
|
|
79
76
|
return Check.new(:ok, "llm_api_calls", "table exists") if llm_api_calls_table?
|
|
80
77
|
|
|
81
78
|
Check.new(
|
|
@@ -86,7 +83,7 @@ module LlmCostTracker
|
|
|
86
83
|
end
|
|
87
84
|
|
|
88
85
|
def column_check
|
|
89
|
-
return unless
|
|
86
|
+
return unless llm_api_calls_table?
|
|
90
87
|
|
|
91
88
|
columns = column_names("llm_api_calls")
|
|
92
89
|
missing_core = CORE_COLUMNS - columns
|
|
@@ -106,7 +103,7 @@ module LlmCostTracker
|
|
|
106
103
|
end
|
|
107
104
|
|
|
108
105
|
def period_totals_check
|
|
109
|
-
return unless
|
|
106
|
+
return unless llm_api_calls_table?
|
|
110
107
|
if table_exists?("llm_cost_tracker_period_totals")
|
|
111
108
|
return Check.new(:ok, "period totals", "llm_cost_tracker_period_totals exists")
|
|
112
109
|
end
|
|
@@ -133,7 +130,7 @@ module LlmCostTracker
|
|
|
133
130
|
end
|
|
134
131
|
|
|
135
132
|
def calls_check
|
|
136
|
-
return unless
|
|
133
|
+
return unless llm_api_calls_table?
|
|
137
134
|
|
|
138
135
|
count = LlmCostTracker::LlmApiCall.count
|
|
139
136
|
return Check.new(:warn, "tracked calls", "none recorded yet") if count.zero?
|
|
@@ -142,8 +139,6 @@ module LlmCostTracker
|
|
|
142
139
|
Check.new(:ok, "tracked calls", "#{count} recorded; latest #{latest}")
|
|
143
140
|
end
|
|
144
141
|
|
|
145
|
-
def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
|
|
146
|
-
|
|
147
142
|
def active_record_available?
|
|
148
143
|
require_relative "llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
|
|
149
144
|
LlmCostTracker::LlmApiCall.connection
|
|
@@ -2,12 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "rails"
|
|
4
4
|
require_relative "../llm_cost_tracker"
|
|
5
|
-
require_relative "engine_compatibility"
|
|
6
5
|
require_relative "assets"
|
|
7
6
|
require "rack/files"
|
|
8
7
|
|
|
9
|
-
LlmCostTracker::EngineCompatibility.check_rails_version!(Rails.version)
|
|
10
|
-
|
|
11
8
|
module LlmCostTracker
|
|
12
9
|
class Engine < ::Rails::Engine
|
|
13
10
|
isolate_namespace LlmCostTracker
|
|
@@ -80,7 +80,7 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
80
80
|
elsif mysql?
|
|
81
81
|
"DATE(tracked_at)"
|
|
82
82
|
else
|
|
83
|
-
"
|
|
83
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -90,7 +90,7 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
90
90
|
elsif mysql?
|
|
91
91
|
"DATE_FORMAT(tracked_at, '%Y-%m-01')"
|
|
92
92
|
else
|
|
93
|
-
"
|
|
93
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
@@ -24,8 +24,10 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
24
24
|
t.string :pricing_mode
|
|
25
25
|
if postgresql?
|
|
26
26
|
t.jsonb :tags, null: false, default: {}
|
|
27
|
+
elsif mysql?
|
|
28
|
+
t.json :tags, null: false
|
|
27
29
|
else
|
|
28
|
-
|
|
30
|
+
raise "LLM Cost Tracker supports PostgreSQL and MySQL only"
|
|
29
31
|
end
|
|
30
32
|
t.datetime :tracked_at, null: false
|
|
31
33
|
|
|
@@ -79,4 +81,8 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
79
81
|
def postgresql?
|
|
80
82
|
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
81
83
|
end
|
|
84
|
+
|
|
85
|
+
def mysql?
|
|
86
|
+
LlmCostTracker::ActiveRecordAdapter.mysql?(connection)
|
|
87
|
+
end
|
|
82
88
|
end
|
|
@@ -4,10 +4,6 @@ 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 captures events into a durable inbox and ingests them into llm_api_calls.
|
|
8
|
-
# Other options: :log for local logging, :custom for your own storage callable.
|
|
9
|
-
config.storage_backend = :active_record
|
|
10
|
-
|
|
11
7
|
# Tags are merged into every event. Use a callable for request/job-time context.
|
|
12
8
|
config.default_tags = -> { { environment: Rails.env } }
|
|
13
9
|
|
|
@@ -34,7 +30,7 @@ LlmCostTracker.configure do |config|
|
|
|
34
30
|
# every model must have known pricing before it can be used.
|
|
35
31
|
config.unknown_pricing_behavior = :warn
|
|
36
32
|
|
|
37
|
-
#
|
|
33
|
+
# LLM Cost Tracker logs warnings through Rails.logger when available.
|
|
38
34
|
config.log_level = :info
|
|
39
35
|
<% if options[:prices] -%>
|
|
40
36
|
|
|
@@ -65,16 +61,4 @@ LlmCostTracker.configure do |config|
|
|
|
65
61
|
# for bin/rails llm_cost_tracker:report.
|
|
66
62
|
# config.openai_compatible_providers["llm.my-company.com"] = "internal_gateway"
|
|
67
63
|
# config.report_tag_breakdowns = %w[feature user_id]
|
|
68
|
-
|
|
69
|
-
# Use :custom when you want to send events to your own sink instead of ActiveRecord.
|
|
70
|
-
# Return false from custom_storage to skip budget checks for that event.
|
|
71
|
-
# config.storage_backend = :custom
|
|
72
|
-
# config.custom_storage = ->(event) {
|
|
73
|
-
# Rails.logger.info(
|
|
74
|
-
# provider: event.provider,
|
|
75
|
-
# model: event.model,
|
|
76
|
-
# total_cost: event.cost&.total_cost,
|
|
77
|
-
# tags: event.tags
|
|
78
|
-
# )
|
|
79
|
-
# }
|
|
80
64
|
end
|
|
@@ -7,13 +7,11 @@ module LlmCostTracker
|
|
|
7
7
|
PERIOD_FORMATS = {
|
|
8
8
|
day: {
|
|
9
9
|
postgres: "YYYY-MM-DD",
|
|
10
|
-
mysql: "%Y-%m-%d"
|
|
11
|
-
sqlite: "%Y-%m-%d"
|
|
10
|
+
mysql: "%Y-%m-%d"
|
|
12
11
|
},
|
|
13
12
|
month: {
|
|
14
13
|
postgres: "YYYY-MM",
|
|
15
|
-
mysql: "%Y-%m"
|
|
16
|
-
sqlite: "%Y-%m"
|
|
14
|
+
mysql: "%Y-%m"
|
|
17
15
|
}
|
|
18
16
|
}.freeze
|
|
19
17
|
|
|
@@ -41,7 +39,7 @@ module LlmCostTracker
|
|
|
41
39
|
elsif ActiveRecordAdapter.mysql?(connection)
|
|
42
40
|
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
43
41
|
else
|
|
44
|
-
|
|
42
|
+
ActiveRecordAdapter.ensure_supported!(connection)
|
|
45
43
|
end
|
|
46
44
|
end
|
|
47
45
|
|
|
@@ -21,10 +21,8 @@ module LlmCostTracker
|
|
|
21
21
|
|
|
22
22
|
initializer "llm_cost_tracker.configure" do
|
|
23
23
|
ActiveSupport.on_load(:active_record) do
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
require_relative "storage/active_record_store"
|
|
27
|
-
end
|
|
24
|
+
require_relative "llm_api_call"
|
|
25
|
+
require_relative "storage/active_record_store"
|
|
28
26
|
end
|
|
29
27
|
end
|
|
30
28
|
end
|
|
@@ -5,10 +5,8 @@ require_relative "report_formatter"
|
|
|
5
5
|
|
|
6
6
|
module LlmCostTracker
|
|
7
7
|
class Report
|
|
8
|
-
DEFAULT_DAYS = ReportData::DEFAULT_DAYS
|
|
9
|
-
|
|
10
8
|
class << self
|
|
11
|
-
def generate(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
9
|
+
def generate(days: ReportData::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
12
10
|
report_data = ReportData.build(
|
|
13
11
|
days: days,
|
|
14
12
|
now: now,
|
|
@@ -23,7 +21,7 @@ module LlmCostTracker
|
|
|
23
21
|
"Unable to build LLM cost report: #{e.class}: #{e.message}"
|
|
24
22
|
end
|
|
25
23
|
|
|
26
|
-
def data(days: DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
24
|
+
def data(days: ReportData::DEFAULT_DAYS, now: Time.now.utc, tag_breakdowns: nil)
|
|
27
25
|
ReportData.build(days: days, now: now, tag_breakdowns: tag_breakdowns)
|
|
28
26
|
end
|
|
29
27
|
end
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
|
-
require_relative "registry"
|
|
6
5
|
require_relative "active_record_inbox"
|
|
7
6
|
require_relative "active_record_ingestor"
|
|
8
7
|
require_relative "active_record_store"
|
|
9
8
|
|
|
10
9
|
module LlmCostTracker
|
|
11
10
|
module Storage
|
|
11
|
+
VerificationResult = Data.define(:status, :name, :message)
|
|
12
|
+
|
|
12
13
|
class ActiveRecordBackend
|
|
13
14
|
VERIFY_TAG = "llm_cost_tracker_verify"
|
|
14
15
|
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "time"
|
|
5
5
|
|
|
6
|
-
require_relative "../active_record_adapter"
|
|
7
6
|
require_relative "../cost"
|
|
8
7
|
require_relative "../event"
|
|
9
8
|
require_relative "../inbox_event"
|
|
@@ -110,7 +109,7 @@ module LlmCostTracker
|
|
|
110
109
|
|
|
111
110
|
def insert_row(row)
|
|
112
111
|
connection = LlmCostTracker::LlmApiCall.connection
|
|
113
|
-
if connection.transaction_open?
|
|
112
|
+
if connection.transaction_open?
|
|
114
113
|
insert_with_separate_connection(row)
|
|
115
114
|
else
|
|
116
115
|
execute_insert(connection, row)
|
|
@@ -155,10 +154,6 @@ module LlmCostTracker
|
|
|
155
154
|
def symbolize_keys(hash)
|
|
156
155
|
hash.transform_keys(&:to_sym)
|
|
157
156
|
end
|
|
158
|
-
|
|
159
|
-
def sqlite_database?(connection)
|
|
160
|
-
ActiveRecordAdapter.sqlite?(connection)
|
|
161
|
-
end
|
|
162
157
|
end
|
|
163
158
|
end
|
|
164
159
|
end
|
|
@@ -17,7 +17,7 @@ module LlmCostTracker
|
|
|
17
17
|
return Arel.sql(mysql_sql) if ActiveRecordAdapter.mysql?(connection)
|
|
18
18
|
return Arel.sql(postgres_sql) if ActiveRecordAdapter.postgresql?(connection)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
ActiveRecordAdapter.ensure_supported!(connection)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
private
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
3
4
|
require_relative "../logging"
|
|
4
|
-
require_relative "registry"
|
|
5
5
|
require_relative "active_record_backend"
|
|
6
|
-
require_relative "custom_backend"
|
|
7
|
-
require_relative "log_backend"
|
|
8
6
|
|
|
9
7
|
module LlmCostTracker
|
|
10
8
|
module Storage
|
|
11
|
-
class
|
|
9
|
+
class Writer
|
|
12
10
|
class << self
|
|
13
11
|
def save(event)
|
|
14
|
-
|
|
12
|
+
ActiveRecordBackend.save(event)
|
|
15
13
|
rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
|
|
16
14
|
raise
|
|
17
15
|
rescue StandardError => e
|
|
@@ -21,25 +19,17 @@ module LlmCostTracker
|
|
|
21
19
|
|
|
22
20
|
private
|
|
23
21
|
|
|
24
|
-
def backend
|
|
25
|
-
Registry.fetch(LlmCostTracker.configuration.storage_backend)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
22
|
def handle_error(error)
|
|
29
23
|
case LlmCostTracker.configuration.storage_error_behavior
|
|
30
24
|
when :ignore
|
|
31
25
|
nil
|
|
32
26
|
when :warn
|
|
33
|
-
Logging.warn("
|
|
27
|
+
Logging.warn("ActiveRecord ledger write failed: #{error.class}: #{error.message}")
|
|
34
28
|
when :raise
|
|
35
29
|
raise StorageError, error
|
|
36
30
|
end
|
|
37
31
|
end
|
|
38
32
|
end
|
|
39
33
|
end
|
|
40
|
-
|
|
41
|
-
Registry.register(:log, LogBackend)
|
|
42
|
-
Registry.register(:active_record, ActiveRecordBackend)
|
|
43
|
-
Registry.register(:custom, CustomBackend)
|
|
44
34
|
end
|
|
45
35
|
end
|
|
@@ -16,7 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
elsif ActiveRecordAdapter.mysql?(model.connection)
|
|
17
17
|
"JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
|
|
18
18
|
else
|
|
19
|
-
|
|
19
|
+
ActiveRecordAdapter.ensure_supported!(model.connection)
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -79,6 +79,8 @@ module LlmCostTracker
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
def build_lct_schema_capabilities(columns, adapter_name)
|
|
82
|
+
ActiveRecordAdapter.ensure_supported!(adapter_name)
|
|
83
|
+
|
|
82
84
|
tag_column = columns["tags"]
|
|
83
85
|
tags_jsonb = tag_column && (tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb")
|
|
84
86
|
tags_mysql_json =
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
|
-
require_relative "storage/
|
|
5
|
+
require_relative "storage/writer"
|
|
6
6
|
|
|
7
7
|
module LlmCostTracker
|
|
8
8
|
class Tracker
|
|
@@ -41,7 +41,7 @@ module LlmCostTracker
|
|
|
41
41
|
|
|
42
42
|
ActiveSupport::Notifications.instrument(EVENT_NAME, event.to_h)
|
|
43
43
|
|
|
44
|
-
stored = Storage::
|
|
44
|
+
stored = Storage::Writer.save(event)
|
|
45
45
|
Budget.check!(event) unless stored == false
|
|
46
46
|
|
|
47
47
|
event
|