lex-metering 0.1.12 → 0.1.13

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: ecc76227d82fc80c0d3c83736e87af31806eac48bba30352bbbd01007732ef1c
4
- data.tar.gz: 2172ef8c6d8894febe2205b3834cd62aac6fd7be28b3f66018144c3a47422cb1
3
+ metadata.gz: d74b4188d55fe8f0b4b904f04345a241b09040eec1104d5c4dcfb83ebfd6a17e
4
+ data.tar.gz: 38bb6a9e985e52d2dcdf12bb4e1b7223e0f0c0b13c7705ea5c365618e356537c
5
5
  SHA512:
6
- metadata.gz: '0791bb4acbb2c1ac909259effeca27777f8b3f7969eabc67acb8651875738f41e4030e5abfaf03c6d7f300c6868d58b9042fdf31aea11c74f107e0cafb70147e'
7
- data.tar.gz: c1256eda507e1a3742432ddf38d9036be6d72ede02f51f021a6b34f0849da1ab553aa6fabcf2e4b735eef76a844b8b6aaacaa0a456f408d044d7d1adf5855247
6
+ metadata.gz: 6a3458e571639fdf876cf85247885e62591b6e0fb2b36a3e49f4039dd0cfe293eb5b78c244c0fa6e200c8c1c5a97b458acd4b759409eca603dd4f31d468f5f57
7
+ data.tar.gz: 283af82b8353acd86eb1b2446a97b03a4f415e352cbaf06b2807ae145b2f92da1a1c05419785f42143dba60c31aa77325972eef0cd9d8eccea4c56f6d3b45ad4
@@ -10,8 +10,8 @@ jobs:
10
10
  ci:
11
11
  uses: LegionIO/.github/.github/workflows/ci.yml@main
12
12
 
13
- lint:
14
- uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main
13
+ excluded-files:
14
+ uses: LegionIO/.github/.github/workflows/excluded-files.yml@main
15
15
 
16
16
  security:
17
17
  uses: LegionIO/.github/.github/workflows/security-scan.yml@main
@@ -27,8 +27,8 @@ jobs:
27
27
  uses: LegionIO/.github/.github/workflows/stale.yml@main
28
28
 
29
29
  release:
30
- needs: [ci, lint]
30
+ needs: [ci, excluded-files]
31
31
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
32
32
  uses: LegionIO/.github/.github/workflows/release.yml@main
33
33
  secrets:
34
- rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
34
+ rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
data/.rubocop.yml CHANGED
@@ -1,59 +1,2 @@
1
- AllCops:
2
- TargetRubyVersion: 3.4
3
- NewCops: enable
4
- SuggestExtensions: false
5
-
6
- Layout/LineLength:
7
- Max: 160
8
-
9
- Layout/SpaceAroundEqualsInParameterDefault:
10
- EnforcedStyle: space
11
-
12
- Layout/HashAlignment:
13
- EnforcedHashRocketStyle: table
14
- EnforcedColonStyle: table
15
-
16
- Metrics/MethodLength:
17
- Max: 50
18
-
19
- Metrics/ClassLength:
20
- Max: 1500
21
-
22
- Metrics/ModuleLength:
23
- Max: 1500
24
-
25
- Metrics/BlockLength:
26
- Max: 40
27
- Exclude:
28
- - 'spec/**/*'
29
-
30
- Metrics/AbcSize:
31
- Max: 60
32
-
33
- Metrics/CyclomaticComplexity:
34
- Max: 15
35
-
36
- Metrics/PerceivedComplexity:
37
- Max: 17
38
-
39
- Style/Documentation:
40
- Enabled: false
41
-
42
- Style/SymbolArray:
43
- Enabled: true
44
-
45
- Style/FrozenStringLiteralComment:
46
- Enabled: true
47
- EnforcedStyle: always
48
-
49
- Naming/FileName:
50
- Enabled: false
51
-
52
- Naming/PredicateMethod:
53
- Enabled: false
54
-
55
- Gemspec/DevelopmentDependencies:
56
- Enabled: false
57
-
58
- Metrics/ParameterLists:
59
- Enabled: false
1
+ inherit_gem:
2
+ rubocop-legion: config/lex.yml
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.13] - 2026-03-30
4
+
5
+ ### Changed
6
+ - update to rubocop-legion 0.1.7, resolve all offenses
7
+
3
8
  ## [0.1.12] - 2026-03-27
4
9
 
5
10
  ### Fixed
data/CLAUDE.md CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  ## Purpose
8
8
 
9
- Captures LLM token usage metrics per task for cost attribution and intelligent routing. Records input/output/thinking tokens, latency, wall-clock time, CPU time, external API call counts, and routing reason per metered call. Supports per-worker, per-team, and aggregate routing statistics queries.
9
+ Captures LLM token usage metrics per task for cost attribution and intelligent routing. Records input/output/thinking tokens, latency, wall-clock time, CPU time, external API call counts, cost in USD, trace context (extension, runner_function, event_type, status), and routing reason per metered call. Supports per-worker, per-team, and aggregate routing statistics queries. Hourly rollup to a summary table for reporting efficiency.
10
10
 
11
11
  ## Gem Info
12
12
 
13
13
  - **Gem name**: `lex-metering`
14
- - **Version**: `0.1.6`
14
+ - **Version**: `0.1.11`
15
15
  - **Module**: `Legion::Extensions::Metering`
16
16
  - **Ruby**: `>= 3.4`
17
17
  - **License**: MIT
@@ -23,12 +23,17 @@ Captures LLM token usage metrics per task for cost attribution and intelligent r
23
23
  lib/legion/extensions/metering/
24
24
  version.rb
25
25
  actors/
26
- cleanup.rb # Every actor (86400s/daily): calls cleanup_old_records
26
+ cleanup.rb # Every actor (86400s/daily): calls cleanup_old_records
27
+ cost_optimizer.rb # Every actor (604800s/weekly): calls analyze_costs; Singleton mixin
28
+ rollup.rb # Every actor (3600s/hourly): calls rollup_hour; run_now? false
27
29
  data/
28
30
  migrations/
29
- 001_add_metering_records.rb # Creates metering_records table
31
+ 001_add_metering_records.rb # Creates metering_records table (base schema)
32
+ 002_add_trace_columns.rb # Adds cost_usd, status, event_type, extension, runner_function
30
33
  runners/
31
- metering.rb # record, worker_costs, team_costs, routing_stats, cleanup_old_records
34
+ metering.rb # record, worker_costs, team_costs, routing_stats, cleanup_old_records
35
+ cost_optimizer.rb # analyze_costs (LLM-powered model rightsizing recommendations)
36
+ rollup.rb # rollup_hour, purge_raw_records
32
37
  ```
33
38
 
34
39
  ## Database Schema (`metering_records`)
@@ -49,34 +54,60 @@ lib/legion/extensions/metering/
49
54
  | `cpu_time_ms` | Integer | CPU time consumed |
50
55
  | `external_api_calls` | Integer | Non-LLM external API calls made |
51
56
  | `routing_reason` | String(255) | Why this model/provider was chosen |
57
+ | `cost_usd` | Float | Estimated cost in USD (default: 0.0) |
58
+ | `status` | String(50) | Pipeline status at time of record |
59
+ | `event_type` | String(100) | Event type for SIEM/audit correlation |
60
+ | `extension` | String(255) | Calling extension name |
61
+ | `runner_function` | String(255) | Calling runner function name |
52
62
  | `recorded_at` | DateTime | Timestamp (indexed) |
53
63
 
54
64
  ## Runner Methods
55
65
 
56
66
  | Method | Parameters | Returns |
57
67
  |--------|-----------|---------|
58
- | `record` | All schema fields as kwargs | Record hash (also inserted to DB) |
68
+ | `record` | All schema fields as kwargs | Record hash (also inserted to DB when connected) |
59
69
  | `worker_costs` | `worker_id:`, `period: 'daily'` | Aggregated token/call/latency metrics |
60
70
  | `team_costs` | `team:`, `period: 'daily'` | Team-wide aggregation across all team workers |
61
71
  | `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: }` |
72
+ | `cleanup_old_records` | `retention_days: 90` | Deletes records older than cutoff |
63
73
 
64
74
  `period` values: `'daily'`, `'weekly'`, `'monthly'`
65
75
 
66
- ## Cleanup Actor
76
+ ### Rollup (`Runners::Rollup`)
67
77
 
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.
78
+ `rollup_hour` groups `metering_records` by worker/provider/model for the current hour into `metering_hourly_rollup` table with upsert semantics (one row per worker+provider+model+hour).
79
+
80
+ `purge_raw_records(retention_days: 7)` — deletes raw `metering_records` older than the retention window after they have been rolled up.
81
+
82
+ ### CostOptimizer (`Runners::CostOptimizer`)
83
+
84
+ `analyze_costs(window_days: 7, top_n: 10)` — aggregates cost drivers from `metering_records` for the past `window_days` days. Calls `Legion::LLM.chat` (with `caller: { extension: 'lex-metering', operation: 'cost_optimization' }`) to generate model rightsizing recommendations. Returns `{ status:, cost_drivers:, recommendations: }`.
85
+
86
+ Built-in rate table (per 1M tokens): Anthropic claude-opus-4-6 $15, claude-sonnet-4-6 $3, claude-haiku-4-5 $0.25; OpenAI gpt-4o $5, gpt-4o-mini $0.15; Bedrock/Azure default $3.
87
+
88
+ ## Actors
89
+
90
+ | Actor | Interval | Behaviour |
91
+ |-------|----------|-----------|
92
+ | `Cleanup` | 86400s daily | `cleanup_old_records`; `use_runner? false` |
93
+ | `CostOptimizer` | 604800s weekly | `analyze_costs`; `use_runner? false`; Singleton mixin |
94
+ | `Rollup` | 3600s hourly | `rollup_hour`; `run_now? false` |
69
95
 
70
96
  ## Integration Points
71
97
 
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.
98
+ - **legion-data**: `data_required? false` — loads without DB. `record` returns hash for RMQ publishing; query methods require `Legion::Data`.
73
99
  - **LegionIO MCP**: `legion.routing_stats` MCP tool calls `routing_stats` runner
74
100
  - **REST API**: `GET /api/tasks/:id` includes a `:metering` block when lex-metering data exists for the task
75
101
  - **Digital Workers**: `legion worker costs` CLI command delegates to `worker_costs` runner
102
+ - **lex-llm-gateway**: MeteringWriter actor writes to the same `metering_records` table
76
103
 
77
104
  ## Development Notes
78
105
 
79
- - Extension has `data_required? false` — loads without `legion-data`; `record` builds hash only (no DB insert), query methods still require `Legion::Data`
80
- - Has one explicit actor (`Cleanup`); auto-generated subscription actors are created for runner methods
106
+ - Actor module is `module Actor` (singular) per framework convention
81
107
  - `routing_stats` uses `select_append { avg(latency_ms).as(avg_latency) }` — Sequel virtual row syntax
82
- - Time interval filtering uses `Sequel.lit('recorded_at >= ?', cutoff)` with Ruby `Time` arithmetic for cross-database compatibility (PostgreSQL, SQLite, MySQL)
108
+ - Time interval filtering uses `Sequel.lit('recorded_at >= ?', cutoff)` with Ruby `Time` arithmetic for cross-database compatibility
109
+ - `CostOptimizer` actor includes `Legion::Extensions::Actors::Singleton` when available to prevent duplicate weekly runs in a cluster
110
+
111
+ ---
112
+
113
+ **Maintained By**: Matthew Iverson (@Esity)
data/Gemfile CHANGED
@@ -5,5 +5,6 @@ gemspec
5
5
 
6
6
  gem 'rspec', '~> 3.13'
7
7
  gem 'rubocop', '~> 1.75', require: false
8
+ gem 'rubocop-legion', '~> 0.1', require: false
8
9
  gem 'rubocop-rspec', require: false
9
10
  gem 'simplecov', require: false
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Metering
6
6
  module Actor
7
- class Cleanup < Legion::Extensions::Actors::Every
7
+ class Cleanup < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
8
8
  include Legion::Extensions::Actors::Singleton if defined?(Legion::Extensions::Actors::Singleton)
9
9
 
10
10
  def runner_class
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Metering
6
6
  module Actor
7
- class CostOptimizer < Legion::Extensions::Actors::Every
7
+ class CostOptimizer < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
8
8
  include Legion::Extensions::Actors::Singleton if defined?(Legion::Extensions::Actors::Singleton)
9
9
 
10
10
  def runner_class
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Metering
6
6
  module Actor
7
- class Rollup < Legion::Extensions::Actors::Every
7
+ class Rollup < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
8
8
  include Legion::Extensions::Actors::Singleton if defined?(Legion::Extensions::Actors::Singleton)
9
9
 
10
10
  def runner_class
@@ -76,7 +76,7 @@ module Legion
76
76
  return :unknown unless defined?(Legion::Extensions::Synapse)
77
77
 
78
78
  Legion::Extensions::Synapse::Client.new.autonomy_level(worker_id: worker_id)
79
- rescue StandardError
79
+ rescue StandardError => _e
80
80
  :unknown
81
81
  end
82
82
  end
@@ -5,6 +5,8 @@ module Legion
5
5
  module Metering
6
6
  module Runners
7
7
  module CostOptimizer
8
+ extend self
9
+
8
10
  def analyze_costs(window_days: 7, top_n: 10)
9
11
  drivers = collect_cost_data(window_days: window_days)
10
12
  return { status: 'no_data', window_days: window_days, cost_drivers: [], recommendations: [] } if drivers.empty?
@@ -23,7 +25,7 @@ module Legion
23
25
  private
24
26
 
25
27
  def collect_cost_data(window_days:)
26
- return [] unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
28
+ return [] unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection # rubocop:disable Legion/Extension/RunnerReturnHash
27
29
 
28
30
  cutoff = Time.now.utc - (window_days * 86_400)
29
31
  ds = Legion::Data.connection[:metering_records]
@@ -46,7 +48,7 @@ module Legion
46
48
  call_count: row[:call_count] || 0
47
49
  }
48
50
  end
49
- rescue StandardError
51
+ rescue StandardError => _e
50
52
  []
51
53
  end
52
54
 
@@ -70,9 +72,9 @@ module Legion
70
72
  return { recommendations: [] } unless defined?(Legion::LLM)
71
73
 
72
74
  prompt = build_recommendation_prompt(drivers)
73
- result = Legion::LLM.chat(message: prompt, caller: { extension: 'lex-metering', operation: 'cost_optimization' })
75
+ result = llm_chat(message: prompt, caller: { extension: 'lex-metering', operation: 'cost_optimization' })
74
76
  ::JSON.parse(result[:content] || '{}', symbolize_names: true)
75
- rescue StandardError
77
+ rescue StandardError => _e
76
78
  { recommendations: [] }
77
79
  end
78
80
 
@@ -5,9 +5,11 @@ module Legion
5
5
  module Metering
6
6
  module Runners
7
7
  module Metering
8
+ extend self
9
+
8
10
  PERIOD_DAYS = { 'daily' => 1, 'weekly' => 7, 'monthly' => 30 }.freeze
9
11
 
10
- def record(worker_id: nil, task_id: nil, provider: nil, model_id: nil,
12
+ def record(worker_id: nil, task_id: nil, provider: nil, model_id: nil, # rubocop:disable Metrics/ParameterLists
11
13
  input_tokens: 0, output_tokens: 0, thinking_tokens: 0,
12
14
  input_context_bytes: 0, latency_ms: 0, routing_reason: nil,
13
15
  wall_clock_ms: 0, cpu_time_ms: 0, external_api_calls: 0,
@@ -36,8 +38,8 @@ module Legion
36
38
  recorded_at: Time.now.utc
37
39
  }
38
40
 
39
- Legion::Logging.debug "[metering] recorded: provider=#{provider} model=#{model_id} " \
40
- "tokens=#{record[:total_tokens]} latency=#{latency_ms}ms wall_clock=#{wall_clock_ms}ms"
41
+ log.debug("[metering] recorded: provider=#{provider} model=#{model_id} " \
42
+ "tokens=#{record[:total_tokens]} latency=#{latency_ms}ms wall_clock=#{wall_clock_ms}ms")
41
43
  record
42
44
  end
43
45
 
@@ -90,7 +92,7 @@ module Legion
90
92
 
91
93
  cutoff = Time.now.utc - (retention_days * 86_400)
92
94
  count = Legion::Data.connection[:metering_records].where { recorded_at < cutoff }.delete
93
- Legion::Logging.info "[metering] cleanup: purged=#{count} retention_days=#{retention_days} cutoff=#{cutoff}"
95
+ log.info("[metering] cleanup: purged=#{count} retention_days=#{retention_days} cutoff=#{cutoff}")
94
96
  { purged: count, retention_days: retention_days, cutoff: cutoff }
95
97
  end
96
98
 
@@ -98,7 +100,7 @@ module Legion
98
100
 
99
101
  def apply_period_filter(dataset, period)
100
102
  days = PERIOD_DAYS[period]
101
- return dataset unless days
103
+ return dataset unless days # rubocop:disable Legion/Extension/RunnerReturnHash
102
104
 
103
105
  cutoff = Time.now.utc - (days * 86_400)
104
106
  dataset.where(::Sequel.lit('recorded_at >= ?', cutoff))
@@ -5,6 +5,8 @@ module Legion
5
5
  module Metering
6
6
  module Runners
7
7
  module Rollup
8
+ extend self
9
+
8
10
  def rollup_hour(hour: nil, **)
9
11
  return { status: 'skipped', reason: 'data_unavailable' } unless data_available?
10
12
 
@@ -35,7 +37,7 @@ module Legion
35
37
  rolled_up += 1
36
38
  end
37
39
 
38
- Legion::Logging.info "[metering] rollup_hour: hour=#{hour.iso8601} groups=#{rolled_up} raw_records=#{raw_count}"
40
+ log.info("[metering] rollup_hour: hour=#{hour.iso8601} groups=#{rolled_up} raw_records=#{raw_count}")
39
41
  { rolled_up: rolled_up, hour: hour.iso8601, raw_records: raw_count }
40
42
  end
41
43
 
@@ -47,14 +49,14 @@ module Legion
47
49
  .where(::Sequel.lit('recorded_at < ?', cutoff))
48
50
  .delete
49
51
 
50
- Legion::Logging.info "[metering] purge_raw_records: purged=#{count} retention_days=#{retention_days} cutoff=#{cutoff.iso8601}"
52
+ log.info("[metering] purge_raw_records: purged=#{count} retention_days=#{retention_days} cutoff=#{cutoff.iso8601}")
51
53
  { purged: count, retention_days: retention_days, cutoff: cutoff.iso8601 }
52
54
  end
53
55
 
54
56
  private
55
57
 
56
58
  def data_available?(table = nil)
57
- return false unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
59
+ return false unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection # rubocop:disable Legion/Extension/RunnerReturnHash
58
60
 
59
61
  if table
60
62
  Legion::Data.connection.table_exists?(table)
@@ -65,7 +67,7 @@ module Legion
65
67
  end
66
68
 
67
69
  def resolve_hour(hour)
68
- return hour if hour
70
+ return hour if hour # rubocop:disable Legion/Extension/RunnerReturnHash
69
71
 
70
72
  now = Time.now.utc
71
73
  floored = Time.utc(now.year, now.month, now.day, now.hour)
@@ -73,7 +75,7 @@ module Legion
73
75
  end
74
76
 
75
77
  def build_rollup_row(worker_id, provider, model_id, hour, rows)
76
- latencies = rows.map { |r| r[:latency_ms] }.compact
78
+ latencies = rows.filter_map { |r| r[:latency_ms] }
77
79
  avg_latency = latencies.empty? ? 0 : (latencies.sum.to_f / latencies.size).round(2)
78
80
 
79
81
  {
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Metering
6
- VERSION = '0.1.12'
6
+ VERSION = '0.1.13'
7
7
  end
8
8
  end
9
9
  end
@@ -7,7 +7,7 @@ require 'legion/extensions/metering/runners/rollup'
7
7
  module Legion
8
8
  module Extensions
9
9
  module Metering
10
- extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
10
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core, false
11
11
 
12
12
  def self.data_required?
13
13
  false
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.12
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity