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 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Finops
6
+ VERSION = '0.1.2'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'finops/version'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Finops
8
+ end
9
+ end
10
+ 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: []