lex-metering 0.1.4 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db002082af15a2d97671a7b9ed75897f21ec03d16e360962517fc848527c9ec6
4
- data.tar.gz: cb642644b254256b1fef4a17933f9276a3a972ebee52a37c80e4bf88786e1b57
3
+ metadata.gz: f0014a77ce8b15e51cc4f1ae2edc8804fdd22fc85042fdabe7a556c275ed9d3c
4
+ data.tar.gz: 26b9fd1245b65d5ae61ec9832c2ec3c80fe857ebb13ec28526d828f873ebb33f
5
5
  SHA512:
6
- metadata.gz: 7e107e84a2d199254a12daad43880e9f3b593b3076b0e42964a7fe0ef07410c4a75923761da4128cb0942c4b45db1b791b4923e4e2263484c3b61621d54cf979
7
- data.tar.gz: 60c7c096ea83b68c01fef84244449764ac3ff357465a9aa2a5e74f71f72b94f838ac2bd8805dd969c9cbbe613bdf3a04145e62a47c8d6923134b96399828a767
6
+ metadata.gz: ca6352cb2ff5ef6b9067f962561679e03042ec6ee9780b2efda08f2d1198cb4bc8013884bc6756e713221193bb4ca949ddcc979b8bd09e2f094800535d170d63
7
+ data.tar.gz: 30afaffe29668eb64d0a17d52c566c6e87a98cd8d92b08ffc54f7e9552ffc46b5a06d2302558382cf712dcba06a3081282aa78414185bf55bea29a2ceeaabc8f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.6] - 2026-03-21
4
+
5
+ ### Added
6
+ - `Runners::CostOptimizer` module with `analyze_costs` method for weekly LLM cost analysis
7
+ - `Actor::CostOptimizer` periodic actor (runs weekly) to trigger cost analysis
8
+ - Cost rate tables for Anthropic, OpenAI, Bedrock, and Azure AI models
9
+ - LLM-powered recommendation generation for model rightsizing
10
+
11
+ ## [0.1.5] - 2026-03-20
12
+
13
+ ### Added
14
+ - `Helpers::Economics` module with `payroll_summary`, `worker_report`, and `budget_forecast` methods for labor economics reporting
15
+
3
16
  ## [0.1.4] - 2026-03-18
4
17
 
5
18
  ### Changed
data/CLAUDE.md CHANGED
@@ -22,11 +22,13 @@ Captures LLM token usage metrics per task for cost attribution and intelligent r
22
22
  ```
23
23
  lib/legion/extensions/metering/
24
24
  version.rb
25
+ actors/
26
+ cleanup.rb # Every actor (86400s/daily): calls cleanup_old_records
25
27
  data/
26
28
  migrations/
27
29
  001_add_metering_records.rb # Creates metering_records table
28
30
  runners/
29
- metering.rb # record, worker_costs, team_costs, routing_stats
31
+ metering.rb # record, worker_costs, team_costs, routing_stats, cleanup_old_records
30
32
  ```
31
33
 
32
34
  ## Database Schema (`metering_records`)
@@ -57,9 +59,14 @@ lib/legion/extensions/metering/
57
59
  | `worker_costs` | `worker_id:`, `period: 'daily'` | Aggregated token/call/latency metrics |
58
60
  | `team_costs` | `team:`, `period: 'daily'` | Team-wide aggregation across all team workers |
59
61
  | `routing_stats` | `worker_id: nil` | Breakdowns by routing_reason, provider, model, avg latency |
62
+ | `cleanup_old_records` | `retention_days: 90` | Deletes records older than cutoff; returns `{ purged:, retention_days:, cutoff: }` |
60
63
 
61
64
  `period` values: `'daily'`, `'weekly'`, `'monthly'`
62
65
 
66
+ ## Cleanup Actor
67
+
68
+ `Actor::Cleanup` is an Every actor that calls `cleanup_old_records` once per day (86,400s). It runs with `run_now? false`, `use_runner? false`, `check_subtask? false`, `generate_task? false` — a minimal background trigger that delegates directly to the runner method.
69
+
63
70
  ## Integration Points
64
71
 
65
72
  - **legion-data**: `data_required? false` — loads without DB. `record` returns hash only (for RabbitMQ publishing). Query methods (`worker_costs`, `team_costs`, `routing_stats`, `cleanup_old_records`) still access `metering_records` as a raw Sequel dataset when `Legion::Data` is available.
@@ -70,6 +77,6 @@ lib/legion/extensions/metering/
70
77
  ## Development Notes
71
78
 
72
79
  - Extension has `data_required? false` — loads without `legion-data`; `record` builds hash only (no DB insert), query methods still require `Legion::Data`
73
- - No explicit actors gets auto-generated subscription actors from the framework
80
+ - Has one explicit actor (`Cleanup`); auto-generated subscription actors are created for runner methods
74
81
  - `routing_stats` uses `select_append { avg(latency_ms).as(avg_latency) }` — Sequel virtual row syntax
75
82
  - Time interval filtering uses `Sequel.lit('recorded_at >= ?', cutoff)` with Ruby `Time` arithmetic for cross-database compatibility (PostgreSQL, SQLite, MySQL)
data/Gemfile CHANGED
@@ -2,3 +2,8 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
  gemspec
5
+
6
+ gem 'rspec', '~> 3.13'
7
+ gem 'rubocop', '~> 1.75', require: false
8
+ gem 'rubocop-rspec', require: false
9
+ gem 'simplecov', require: false
data/README.md CHANGED
@@ -6,7 +6,7 @@ Captures LLM token usage, latency, and routing metrics per task for cost attribu
6
6
 
7
7
  ## Purpose
8
8
 
9
- `lex-metering` records every LLM call made through a Legion digital worker tokens consumed, latency, wall-clock time, CPU time, external API calls, and routing reason. Data is persisted to the `metering_records` table and queried for cost attribution and routing statistics.
9
+ `lex-metering` records every LLM call made through a Legion digital worker: tokens consumed (input, output, thinking), latency, wall-clock time, CPU time, external API calls, and routing reason. Data is persisted to the `metering_records` table and queried for cost attribution and routing statistics.
10
10
 
11
11
  ## Installation
12
12
 
@@ -39,7 +39,7 @@ stats = Legion::Extensions::Metering::Runners::Metering.routing_stats
39
39
 
40
40
  ## Database
41
41
 
42
- Requires `legion-data`. Creates the `metering_records` table via Sequel migration.
42
+ Requires `legion-data`. Creates the `metering_records` table via Sequel migration. Extension loads without `legion-data` (`data_required? false`) — `record` returns a hash for RMQ publishing; query methods require `Legion::Data` to be available.
43
43
 
44
44
  ## Record Retention
45
45
 
@@ -52,8 +52,16 @@ Legion::Extensions::Metering::Runners::Metering.cleanup_old_records(retention_da
52
52
  # => { purged: 1234, retention_days: 90, cutoff: 2025-12-15 00:00:00 UTC }
53
53
  ```
54
54
 
55
+ ## Query Methods
56
+
57
+ | Method | Parameters | Period Values |
58
+ |--------|-----------|---------------|
59
+ | `worker_costs` | `worker_id:`, `period: 'daily'` | `'daily'`, `'weekly'`, `'monthly'` |
60
+ | `team_costs` | `team:`, `period: 'daily'` | `'daily'`, `'weekly'`, `'monthly'` |
61
+ | `routing_stats` | `worker_id: nil` | — |
62
+
55
63
  ## Related
56
64
 
57
- - [LegionIO](https://github.com/LegionIO/LegionIO) Framework
58
- - [legion-data](https://github.com/LegionIO/legion-data) Persistence layer
59
- - [Digital Worker Platform](../../../docs/spec-digital-worker-integration.md) Cost governance
65
+ - [LegionIO](https://github.com/LegionIO/LegionIO) - Framework
66
+ - [legion-data](https://github.com/LegionIO/legion-data) - Persistence layer
67
+ - [lex-llm-gateway](https://github.com/LegionIO/lex-llm-gateway) - Gateway that publishes metering events over RMQ
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Metering
6
+ module Actor
7
+ class CostOptimizer < Legion::Extensions::Actors::Every
8
+ def runner_class
9
+ 'Legion::Extensions::Metering::Runners::CostOptimizer'
10
+ end
11
+
12
+ def runner_function
13
+ 'analyze_costs'
14
+ end
15
+
16
+ def time
17
+ 604_800 # once per week
18
+ end
19
+
20
+ def run_now?
21
+ false
22
+ end
23
+
24
+ def use_runner?
25
+ false
26
+ end
27
+
28
+ def check_subtask?
29
+ false
30
+ end
31
+
32
+ def generate_task?
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Metering
6
+ module Helpers
7
+ module Economics
8
+ PERIOD_DAYS = { daily: 1, weekly: 7, monthly: 30 }.freeze
9
+
10
+ def payroll_summary(period: :daily, **)
11
+ return { workers: [], total_cost: 0, avg_productivity: 0 } unless defined?(Legion::Data)
12
+
13
+ days = PERIOD_DAYS.fetch(period.to_sym, 1)
14
+ cutoff = Time.now - (days * 86_400)
15
+ ds = Legion::Data.connection[:metering_records].where(Sequel.lit('recorded_at >= ?', cutoff))
16
+
17
+ workers = ds.group_and_count(:worker_id).all.map do |row|
18
+ {
19
+ worker_id: row[:worker_id],
20
+ task_count: row[:count],
21
+ cost: ds.where(worker_id: row[:worker_id]).sum(:input_tokens).to_f * cost_per_token,
22
+ autonomy: synapse_autonomy(row[:worker_id])
23
+ }
24
+ end
25
+
26
+ total = workers.sum { |w| w[:cost] }
27
+ avg_prod = workers.empty? ? 0 : workers.sum { |w| w[:task_count] } / workers.size.to_f
28
+
29
+ { workers: workers, total_cost: total, avg_productivity: avg_prod, period: period }
30
+ end
31
+
32
+ def worker_report(worker_id:, period: :daily, **)
33
+ return { salary: 0, overtime: 0, productivity: 0 } unless defined?(Legion::Data)
34
+
35
+ days = PERIOD_DAYS.fetch(period.to_sym, 1)
36
+ cutoff = Time.now - (days * 86_400)
37
+ ds = Legion::Data.connection[:metering_records]
38
+ .where(worker_id: worker_id)
39
+ .where(Sequel.lit('recorded_at >= ?', cutoff))
40
+
41
+ task_count = ds.count
42
+ total_tokens = ds.sum(:input_tokens).to_i + ds.sum(:output_tokens).to_i
43
+ salary = total_tokens.to_f * cost_per_token
44
+ avg_latency = ds.avg(:latency_ms).to_f
45
+
46
+ {
47
+ worker_id: worker_id,
48
+ salary: salary,
49
+ overtime: 0,
50
+ productivity: task_count,
51
+ avg_latency: avg_latency,
52
+ autonomy_level: synapse_autonomy(worker_id),
53
+ period: period
54
+ }
55
+ end
56
+
57
+ def budget_forecast(days: 30, **)
58
+ return { projected_cost: 0, trend: :flat } unless defined?(Legion::Data)
59
+
60
+ recent_ds = Legion::Data.connection[:metering_records]
61
+ .where(Sequel.lit('recorded_at >= ?', Time.now - 86_400))
62
+ daily_cost = (recent_ds.sum(:input_tokens).to_i + recent_ds.sum(:output_tokens).to_i) *
63
+ cost_per_token
64
+
65
+ { projected_cost: daily_cost * days, daily_average: daily_cost, days: days,
66
+ trend: daily_cost.positive? ? :active : :flat }
67
+ end
68
+
69
+ private
70
+
71
+ def cost_per_token
72
+ 0.000003
73
+ end
74
+
75
+ def synapse_autonomy(worker_id)
76
+ return :unknown unless defined?(Legion::Extensions::Synapse)
77
+
78
+ Legion::Extensions::Synapse::Client.new.autonomy_level(worker_id: worker_id)
79
+ rescue StandardError
80
+ :unknown
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Metering
6
+ module Runners
7
+ module CostOptimizer
8
+ def analyze_costs(window_days: 7, top_n: 10)
9
+ drivers = collect_cost_data(window_days: window_days)
10
+ return { status: 'no_data', window_days: window_days, cost_drivers: [], recommendations: [] } if drivers.empty?
11
+
12
+ top_drivers = drivers.sort_by { |d| -(d[:total_cost] || 0) }.first(top_n)
13
+ recommendations = generate_recommendations(top_drivers)
14
+
15
+ {
16
+ status: 'analyzed',
17
+ window_days: window_days,
18
+ cost_drivers: top_drivers,
19
+ recommendations: recommendations[:recommendations] || []
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def collect_cost_data(window_days:)
26
+ return [] unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
27
+
28
+ cutoff = Time.now.utc - (window_days * 86_400)
29
+ ds = Legion::Data.connection[:metering_records]
30
+ .where(::Sequel.lit('recorded_at >= ?', cutoff))
31
+
32
+ grouped = ds.group(:provider, :model_id)
33
+ .select_append do
34
+ [sum(total_tokens).as(total_tokens),
35
+ sum(input_tokens).as(input_tokens),
36
+ sum(output_tokens).as(output_tokens),
37
+ count(Sequel.lit('*')).as(call_count)]
38
+ end
39
+
40
+ grouped.all.map do |row|
41
+ {
42
+ extension: row[:provider],
43
+ model: row[:model_id],
44
+ total_tokens: row[:total_tokens] || 0,
45
+ total_cost: estimate_cost(row[:provider], row[:model_id], row[:total_tokens] || 0),
46
+ call_count: row[:call_count] || 0
47
+ }
48
+ end
49
+ rescue StandardError
50
+ []
51
+ end
52
+
53
+ def estimate_cost(provider, model, total_tokens)
54
+ rate = cost_rate(provider, model)
55
+ (total_tokens * rate / 1_000_000.0).round(4)
56
+ end
57
+
58
+ def cost_rate(provider, model)
59
+ rates = {
60
+ 'anthropic' => { 'claude-opus-4-6' => 15.0, 'claude-sonnet-4-6' => 3.0, 'claude-haiku-4-5' => 0.25 },
61
+ 'openai' => { 'gpt-4o' => 5.0, 'gpt-4o-mini' => 0.15, 'gpt-4.1' => 2.0 },
62
+ 'bedrock' => { 'default' => 3.0 },
63
+ 'azure-ai' => { 'default' => 3.0 }
64
+ }
65
+ provider_rates = rates[provider&.to_s] || {}
66
+ provider_rates[model&.to_s] || provider_rates['default'] || 1.0
67
+ end
68
+
69
+ def generate_recommendations(drivers)
70
+ return { recommendations: [] } unless defined?(Legion::LLM)
71
+
72
+ prompt = build_recommendation_prompt(drivers)
73
+ result = Legion::LLM.chat(message: prompt)
74
+ ::JSON.parse(result[:content] || '{}', symbolize_names: true)
75
+ rescue StandardError
76
+ { recommendations: [] }
77
+ end
78
+
79
+ def build_recommendation_prompt(drivers)
80
+ lines = drivers.map do |d|
81
+ "#{d[:extension]}/#{d[:model]}: #{d[:total_tokens]} tokens, $#{d[:total_cost]}, #{d[:call_count]} calls"
82
+ end
83
+
84
+ <<~PROMPT
85
+ Analyze these LLM cost drivers from the past week and recommend model rightsizing.
86
+ Focus on cases where a cheaper model could handle the workload.
87
+
88
+ #{lines.join("\n")}
89
+
90
+ Return JSON: { "recommendations": [{ "extension": "...", "current_model": "...", "suggested_model": "...", "rationale": "...", "estimated_savings_pct": N }] }
91
+ PROMPT
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Metering
6
- VERSION = '0.1.4'
6
+ VERSION = '0.1.6'
7
7
  end
8
8
  end
9
9
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/metering/version'
4
+ require 'legion/extensions/metering/runners/cost_optimizer'
4
5
 
5
6
  module Legion
6
7
  module Extensions
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-metering
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -113,7 +113,10 @@ files:
113
113
  - lex-metering.gemspec
114
114
  - lib/legion/extensions/metering.rb
115
115
  - lib/legion/extensions/metering/actors/cleanup.rb
116
+ - lib/legion/extensions/metering/actors/cost_optimizer.rb
116
117
  - lib/legion/extensions/metering/data/migrations/001_add_metering_records.rb
118
+ - lib/legion/extensions/metering/helpers/economics.rb
119
+ - lib/legion/extensions/metering/runners/cost_optimizer.rb
117
120
  - lib/legion/extensions/metering/runners/metering.rb
118
121
  - lib/legion/extensions/metering/version.rb
119
122
  homepage: https://github.com/LegionIO/lex-metering