lex-finops 0.1.2
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 +7 -0
- data/lib/legion/extensions/finops/helpers/budget_store.rb +89 -0
- data/lib/legion/extensions/finops/helpers/cost_calculator.rb +43 -0
- data/lib/legion/extensions/finops/runners/budget.rb +68 -0
- data/lib/legion/extensions/finops/runners/cost_attribution.rb +68 -0
- data/lib/legion/extensions/finops/version.rb +9 -0
- data/lib/legion/extensions/finops.rb +10 -0
- metadata +145 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f9cb0d0ed46a270344440e8f344fc319957c74d9d0aae5144e4a0182cb36d063
|
|
4
|
+
data.tar.gz: 5aeba9c49abbe8e184da089e1e74f73aa9ba69ccb7e65b47847f3e088c3b1df4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2d7098c01e6135acc49c7c1f6570d91f1bd750f66e859aee47bc89874b42a4af72d87d921ce0bb8e1b9c49c9a8fc725c01d32b00cb9756cd2adb2e7450f8d172
|
|
7
|
+
data.tar.gz: 449a3a43392919cce61d9e7033b9a1ef362353ffda0ef71679549771cdb0a98c63e52b97829b41af1efeab5202c940c23760affa2153082a53130255b0e1c97e
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Finops
|
|
6
|
+
module Helpers
|
|
7
|
+
module BudgetStore
|
|
8
|
+
BudgetEntry = Struct.new(:entity_type, :entity_id, :period_key, :limit_usd,
|
|
9
|
+
:spent_usd, :hard_stop, :alerts_sent)
|
|
10
|
+
|
|
11
|
+
ALERT_THRESHOLDS = [50, 75, 90, 100].freeze
|
|
12
|
+
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@budgets = {}
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def set_budget(entity_type:, entity_id:, limit_usd:, period_key:, hard_stop: false)
|
|
19
|
+
key = budget_key(entity_type, entity_id, period_key)
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
@budgets[key] = BudgetEntry.new(
|
|
22
|
+
entity_type: entity_type, entity_id: entity_id, period_key: period_key,
|
|
23
|
+
limit_usd: limit_usd, spent_usd: 0.0, hard_stop: hard_stop, alerts_sent: Set.new
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def record_spend(entity_type:, entity_id:, period_key:, amount_usd:)
|
|
29
|
+
key = budget_key(entity_type, entity_id, period_key)
|
|
30
|
+
alerts = []
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
entry = @budgets[key]
|
|
33
|
+
return { recorded: false, reason: :no_budget } unless entry
|
|
34
|
+
|
|
35
|
+
entry.spent_usd += amount_usd
|
|
36
|
+
alerts = check_thresholds(entry)
|
|
37
|
+
end
|
|
38
|
+
{ recorded: true, alerts: alerts }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check_budget(entity_type:, entity_id:, period_key:, estimated_cost: 0)
|
|
42
|
+
key = budget_key(entity_type, entity_id, period_key)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
entry = @budgets[key]
|
|
45
|
+
return { allowed: true, reason: :no_budget } unless entry
|
|
46
|
+
|
|
47
|
+
remaining = [entry.limit_usd - entry.spent_usd, 0].max
|
|
48
|
+
pct = ((entry.spent_usd + estimated_cost) / entry.limit_usd * 100).round(1)
|
|
49
|
+
if pct >= 100 && entry.hard_stop
|
|
50
|
+
{ allowed: false, reason: :hard_stop, percent_used: pct, remaining_usd: remaining }
|
|
51
|
+
else
|
|
52
|
+
{ allowed: true, percent_used: pct, remaining_usd: remaining }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def status(entity_type:, entity_id:, period_key:)
|
|
58
|
+
key = budget_key(entity_type, entity_id, period_key)
|
|
59
|
+
@mutex.synchronize { @budgets[key]&.to_h }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear_all
|
|
63
|
+
@mutex.synchronize { @budgets.clear }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def check_thresholds(entry)
|
|
67
|
+
pct = (entry.spent_usd / entry.limit_usd * 100).round(1)
|
|
68
|
+
alerts = []
|
|
69
|
+
ALERT_THRESHOLDS.each do |t|
|
|
70
|
+
threshold_sym = :"t#{t}"
|
|
71
|
+
next unless pct >= t && !entry.alerts_sent.include?(threshold_sym)
|
|
72
|
+
|
|
73
|
+
entry.alerts_sent.add(threshold_sym)
|
|
74
|
+
alerts << { threshold: t, percent_used: pct,
|
|
75
|
+
entity_type: entry.entity_type, entity_id: entry.entity_id }
|
|
76
|
+
end
|
|
77
|
+
alerts
|
|
78
|
+
end
|
|
79
|
+
private_class_method :check_thresholds
|
|
80
|
+
|
|
81
|
+
def budget_key(entity_type, entity_id, period_key)
|
|
82
|
+
"#{entity_type}:#{entity_id}:#{period_key}"
|
|
83
|
+
end
|
|
84
|
+
private_class_method :budget_key
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Finops
|
|
6
|
+
module Helpers
|
|
7
|
+
module CostCalculator
|
|
8
|
+
DEFAULT_RATES = { input_per_1k: 0.005, output_per_1k: 0.015, thinking_per_1k: 0.005 }.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def estimate_cost(provider:, model:, input_tokens:, output_tokens:, thinking_tokens: 0)
|
|
13
|
+
rates = pricing_for(provider, model)
|
|
14
|
+
(input_tokens * rates[:input_per_1k] / 1000.0) +
|
|
15
|
+
(output_tokens * rates[:output_per_1k] / 1000.0) +
|
|
16
|
+
(thinking_tokens * rates[:thinking_per_1k] / 1000.0)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pricing_for(provider, model)
|
|
20
|
+
finops = finops_settings
|
|
21
|
+
pricing = finops[:pricing]
|
|
22
|
+
return DEFAULT_RATES unless pricing.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
provider_pricing = pricing[provider.to_sym]
|
|
25
|
+
return DEFAULT_RATES unless provider_pricing.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
model_pricing = provider_pricing[model.to_s] || provider_pricing[:default]
|
|
28
|
+
return model_pricing.transform_keys(&:to_sym) if model_pricing.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
DEFAULT_RATES
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def finops_settings
|
|
34
|
+
settings = Legion::Settings[:finops]
|
|
35
|
+
settings.is_a?(Hash) ? settings : {}
|
|
36
|
+
rescue StandardError
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Finops
|
|
6
|
+
module Runners
|
|
7
|
+
module Budget
|
|
8
|
+
def check_budget(worker_id:, tenant_id: nil, estimated_cost: 0, **)
|
|
9
|
+
period_key = Time.now.utc.strftime('%Y-%m')
|
|
10
|
+
results = []
|
|
11
|
+
results << Helpers::BudgetStore.check_budget(
|
|
12
|
+
entity_type: :worker, entity_id: worker_id,
|
|
13
|
+
period_key: period_key, estimated_cost: estimated_cost
|
|
14
|
+
)
|
|
15
|
+
if tenant_id
|
|
16
|
+
results << Helpers::BudgetStore.check_budget(
|
|
17
|
+
entity_type: :tenant, entity_id: tenant_id,
|
|
18
|
+
period_key: period_key, estimated_cost: estimated_cost
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
blocked = results.find { |r| !r[:allowed] }
|
|
22
|
+
blocked || { allowed: true }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_spend(worker_id:, cost_usd:, tenant_id: nil, team: nil, **)
|
|
26
|
+
period_key = Time.now.utc.strftime('%Y-%m')
|
|
27
|
+
all_alerts = []
|
|
28
|
+
[[:worker, worker_id], [:tenant, tenant_id], [:team, team]].each do |type, id|
|
|
29
|
+
next unless id
|
|
30
|
+
|
|
31
|
+
result = Helpers::BudgetStore.record_spend(
|
|
32
|
+
entity_type: type, entity_id: id,
|
|
33
|
+
period_key: period_key, amount_usd: cost_usd
|
|
34
|
+
)
|
|
35
|
+
all_alerts.concat(result[:alerts]) if result[:alerts]
|
|
36
|
+
end
|
|
37
|
+
emit_budget_alerts(all_alerts) unless all_alerts.empty?
|
|
38
|
+
{ recorded: true, alerts_fired: all_alerts.size }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set_budget(entity_type:, entity_id:, limit_usd:, hard_stop: false, **)
|
|
42
|
+
period_key = Time.now.utc.strftime('%Y-%m')
|
|
43
|
+
Helpers::BudgetStore.set_budget(
|
|
44
|
+
entity_type: entity_type, entity_id: entity_id,
|
|
45
|
+
limit_usd: limit_usd, period_key: period_key, hard_stop: hard_stop
|
|
46
|
+
)
|
|
47
|
+
{ set: true, entity_type: entity_type, entity_id: entity_id, limit_usd: limit_usd }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def budget_status(entity_type:, entity_id:, **)
|
|
51
|
+
period_key = Time.now.utc.strftime('%Y-%m')
|
|
52
|
+
Helpers::BudgetStore.status(entity_type: entity_type, entity_id: entity_id, period_key: period_key)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def emit_budget_alerts(alerts)
|
|
58
|
+
return unless defined?(Legion::Events)
|
|
59
|
+
|
|
60
|
+
alerts.each do |alert|
|
|
61
|
+
Legion::Events.emit('finops.budget_alert', alert)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Finops
|
|
6
|
+
module Runners
|
|
7
|
+
module CostAttribution
|
|
8
|
+
def attribute_cost(worker_id:, provider:, model:, input_tokens:,
|
|
9
|
+
output_tokens:, tenant_id: nil, team: nil,
|
|
10
|
+
task_id: nil, extension: nil, thinking_tokens: 0, **)
|
|
11
|
+
cost = Helpers::CostCalculator.estimate_cost(
|
|
12
|
+
provider: provider, model: model,
|
|
13
|
+
input_tokens: input_tokens, output_tokens: output_tokens,
|
|
14
|
+
thinking_tokens: thinking_tokens
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
record = {
|
|
18
|
+
worker_id: worker_id, tenant_id: tenant_id, team: team,
|
|
19
|
+
task_id: task_id, extension: extension,
|
|
20
|
+
provider: provider, model: model,
|
|
21
|
+
input_tokens: input_tokens, output_tokens: output_tokens,
|
|
22
|
+
thinking_tokens: thinking_tokens,
|
|
23
|
+
cost_usd: cost, attributed_at: Time.now.utc.iso8601
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
emit_attribution(record) if defined?(Legion::Events)
|
|
27
|
+
record
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
SUMMARY_COLUMNS = %i[worker_id provider model_id].freeze
|
|
31
|
+
|
|
32
|
+
def cost_summary(group_by: :worker_id, since: nil, limit: 20, **)
|
|
33
|
+
return { error: 'data_unavailable' } unless metering_available?
|
|
34
|
+
|
|
35
|
+
col = SUMMARY_COLUMNS.include?(group_by.to_sym) ? group_by.to_sym : :worker_id
|
|
36
|
+
rows = aggregate_metering(col, since: since, limit: limit)
|
|
37
|
+
{ group_by: col, rows: rows, count: rows.size }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def aggregate_metering(col, since:, limit:)
|
|
43
|
+
ds = Legion::Data.connection[:metering_records]
|
|
44
|
+
ds = ds.where { recorded_at >= since } if since
|
|
45
|
+
ds.group_and_count(col)
|
|
46
|
+
.select_append { sum(input_tokens).as(total_input) }
|
|
47
|
+
.select_append { sum(output_tokens).as(total_output) }
|
|
48
|
+
.select_append { sum(total_tokens).as(total_tokens) }
|
|
49
|
+
.order(Sequel.desc(:count))
|
|
50
|
+
.limit(limit)
|
|
51
|
+
.all
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def metering_available?
|
|
55
|
+
defined?(Legion::Data) && Legion::Data.respond_to?(:connection) &&
|
|
56
|
+
Legion::Data.connection&.table_exists?(:metering_records)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def emit_attribution(record)
|
|
62
|
+
Legion::Events.emit('finops.cost_attributed', record)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-finops
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Esity
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: legion-cache
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 1.3.11
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 1.3.11
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: legion-crypt
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 1.4.9
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 1.4.9
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: legion-data
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 1.4.17
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 1.4.17
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: legion-json
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: 1.2.1
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: 1.2.1
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: legion-logging
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 1.3.2
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: 1.3.2
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: legion-settings
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 1.3.14
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 1.3.14
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: legion-transport
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 1.3.9
|
|
103
|
+
type: :runtime
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: 1.3.9
|
|
110
|
+
description: Budget enforcement, cost attribution, and FinOps reporting for LegionIO
|
|
111
|
+
email:
|
|
112
|
+
- matthewdiverson@gmail.com
|
|
113
|
+
executables: []
|
|
114
|
+
extensions: []
|
|
115
|
+
extra_rdoc_files: []
|
|
116
|
+
files:
|
|
117
|
+
- lib/legion/extensions/finops.rb
|
|
118
|
+
- lib/legion/extensions/finops/helpers/budget_store.rb
|
|
119
|
+
- lib/legion/extensions/finops/helpers/cost_calculator.rb
|
|
120
|
+
- lib/legion/extensions/finops/runners/budget.rb
|
|
121
|
+
- lib/legion/extensions/finops/runners/cost_attribution.rb
|
|
122
|
+
- lib/legion/extensions/finops/version.rb
|
|
123
|
+
homepage: https://github.com/LegionIO
|
|
124
|
+
licenses:
|
|
125
|
+
- MIT
|
|
126
|
+
metadata:
|
|
127
|
+
rubygems_mfa_required: 'true'
|
|
128
|
+
rdoc_options: []
|
|
129
|
+
require_paths:
|
|
130
|
+
- lib
|
|
131
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - ">="
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: '3.4'
|
|
136
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
137
|
+
requirements:
|
|
138
|
+
- - ">="
|
|
139
|
+
- !ruby/object:Gem::Version
|
|
140
|
+
version: '0'
|
|
141
|
+
requirements: []
|
|
142
|
+
rubygems_version: 3.6.9
|
|
143
|
+
specification_version: 4
|
|
144
|
+
summary: LEX::Finops
|
|
145
|
+
test_files: []
|