lex-metering 0.1.1
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/.github/workflows/ci.yml +16 -0
- data/.gitignore +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +59 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +75 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +59 -0
- data/lex-metering.gemspec +38 -0
- data/lib/legion/extensions/metering/actors/cleanup.rb +39 -0
- data/lib/legion/extensions/metering/data/migrations/001_add_metering_records.rb +28 -0
- data/lib/legion/extensions/metering/runners/metering.rb +100 -0
- data/lib/legion/extensions/metering/version.rb +9 -0
- data/lib/legion/extensions/metering.rb +19 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7c946a94465b617a2dd09a892a954f82e6877d69ca5e95deab2e12323c4baeb5
|
|
4
|
+
data.tar.gz: 6045e0987b95775a42f940ab9dfbe51a8c3c331bb2ccd41dcb1470d8b5dd6022
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a2b33f3404050a72676eba080f1bc94bd1a1680ef3bd869385c1d3a47c94eaa36453bd1426603018f2a4bb495eadf0e8f454198dfebb4de34f4621366ff39fdc
|
|
7
|
+
data.tar.gz: 36c8fb718ab7a60ebd81ab210344832bd9981fd68ded369890a9abf68e9045dab4eea7220cc23b6cfb497c4fefb4351e47e22bd9e33976b0c6f7ab59c786156f
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
ci:
|
|
9
|
+
uses: LegionIO/.github/.github/workflows/ci.yml@main
|
|
10
|
+
|
|
11
|
+
release:
|
|
12
|
+
needs: ci
|
|
13
|
+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
14
|
+
uses: LegionIO/.github/.github/workflows/release.yml@main
|
|
15
|
+
secrets:
|
|
16
|
+
rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }}
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.1] - 2026-03-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `cleanup_old_records` runner method with configurable retention (default 90 days)
|
|
7
|
+
- `Cleanup` periodic actor (runs daily) to prune old metering records
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-03-13
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `record` method for capturing LLM token usage metrics
|
|
13
|
+
- `worker_costs` aggregation by worker ID and time period
|
|
14
|
+
- `team_costs` aggregation across team members
|
|
15
|
+
- `routing_stats` breakdown by routing reason, provider, and model
|
|
16
|
+
- Database migration for `metering_records` table
|
|
17
|
+
- Full RSpec test coverage for all runner methods
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# lex-metering: LLM Cost Metering for LegionIO
|
|
2
|
+
|
|
3
|
+
**Repository Level 3 Documentation**
|
|
4
|
+
- **Parent**: `/Users/miverso2/rubymine/legion/extensions-core/CLAUDE.md`
|
|
5
|
+
- **Grandparent**: `/Users/miverso2/rubymine/legion/CLAUDE.md`
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Gem Info
|
|
12
|
+
|
|
13
|
+
- **Gem name**: `lex-metering`
|
|
14
|
+
- **Version**: `0.1.0`
|
|
15
|
+
- **Module**: `Legion::Extensions::Metering`
|
|
16
|
+
- **Ruby**: `>= 3.4`
|
|
17
|
+
- **License**: MIT
|
|
18
|
+
- **GitHub**: https://github.com/LegionIO/lex-metering
|
|
19
|
+
|
|
20
|
+
## File Structure
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
lib/legion/extensions/metering/
|
|
24
|
+
version.rb
|
|
25
|
+
data/
|
|
26
|
+
migrations/
|
|
27
|
+
001_add_metering_records.rb # Creates metering_records table
|
|
28
|
+
runners/
|
|
29
|
+
metering.rb # record, worker_costs, team_costs, routing_stats
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Database Schema (`metering_records`)
|
|
33
|
+
|
|
34
|
+
| Column | Type | Description |
|
|
35
|
+
|--------|------|-------------|
|
|
36
|
+
| `worker_id` | String(36) | Digital worker ID (nullable for non-worker tasks) |
|
|
37
|
+
| `task_id` | Integer | Legion task ID |
|
|
38
|
+
| `provider` | String(100) | LLM provider (e.g., 'anthropic', 'openai', 'bedrock') |
|
|
39
|
+
| `model_id` | String(255) | Model identifier |
|
|
40
|
+
| `input_tokens` | Integer | Prompt tokens |
|
|
41
|
+
| `output_tokens` | Integer | Completion tokens |
|
|
42
|
+
| `thinking_tokens` | Integer | Thinking/reasoning tokens (Anthropic extended thinking) |
|
|
43
|
+
| `total_tokens` | Integer | Sum of all token types |
|
|
44
|
+
| `input_context_bytes` | Integer | Raw context size in bytes |
|
|
45
|
+
| `latency_ms` | Integer | LLM API round-trip time |
|
|
46
|
+
| `wall_clock_ms` | Integer | Total wall-clock time for the task |
|
|
47
|
+
| `cpu_time_ms` | Integer | CPU time consumed |
|
|
48
|
+
| `external_api_calls` | Integer | Non-LLM external API calls made |
|
|
49
|
+
| `routing_reason` | String(255) | Why this model/provider was chosen |
|
|
50
|
+
| `recorded_at` | DateTime | Timestamp (indexed) |
|
|
51
|
+
|
|
52
|
+
## Runner Methods
|
|
53
|
+
|
|
54
|
+
| Method | Parameters | Returns |
|
|
55
|
+
|--------|-----------|---------|
|
|
56
|
+
| `record` | All schema fields as kwargs | Record hash (also inserted to DB) |
|
|
57
|
+
| `worker_costs` | `worker_id:`, `period: 'daily'` | Aggregated token/call/latency metrics |
|
|
58
|
+
| `team_costs` | `team:`, `period: 'daily'` | Team-wide aggregation across all team workers |
|
|
59
|
+
| `routing_stats` | `worker_id: nil` | Breakdowns by routing_reason, provider, model, avg latency |
|
|
60
|
+
|
|
61
|
+
`period` values: `'daily'`, `'weekly'`, `'monthly'`
|
|
62
|
+
|
|
63
|
+
## Integration Points
|
|
64
|
+
|
|
65
|
+
- **legion-data**: `data_required? true` — will not load if DB unavailable. Accesses `metering_records` as a raw Sequel dataset (no Sequel::Model subclass).
|
|
66
|
+
- **LegionIO MCP**: `legion.routing_stats` MCP tool calls `routing_stats` runner
|
|
67
|
+
- **REST API**: `GET /api/tasks/:id` includes a `:metering` block when lex-metering data exists for the task
|
|
68
|
+
- **Digital Workers**: `legion worker costs` CLI command delegates to `worker_costs` runner
|
|
69
|
+
|
|
70
|
+
## Development Notes
|
|
71
|
+
|
|
72
|
+
- Extension has `data_required? true` (both at module level and instance level) — will skip loading if `legion-data` is not connected
|
|
73
|
+
- No explicit actors — gets auto-generated subscription actors from the framework
|
|
74
|
+
- `routing_stats` uses `select_append { avg(latency_ms).as(avg_latency) }` — Sequel virtual row syntax
|
|
75
|
+
- Time interval filtering uses `Sequel.lit("CURRENT_TIMESTAMP - INTERVAL '...'")` which is PostgreSQL syntax; SQLite uses different interval syntax (known limitation)
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Iverson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# lex-metering
|
|
2
|
+
|
|
3
|
+
Captures LLM token usage, latency, and routing metrics per task for cost attribution and intelligent routing decisions.
|
|
4
|
+
|
|
5
|
+
**Ruby >= 3.4** | **License**: MIT | **Author**: [@Esity](https://github.com/Esity)
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Included with the LegionIO framework. No separate installation needed.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Record an LLM call
|
|
19
|
+
Legion::Extensions::Metering::Runners::Metering.record(
|
|
20
|
+
worker_id: 'my-worker',
|
|
21
|
+
task_id: 42,
|
|
22
|
+
provider: 'anthropic',
|
|
23
|
+
model_id: 'claude-opus-4-6',
|
|
24
|
+
input_tokens: 1000,
|
|
25
|
+
output_tokens: 500,
|
|
26
|
+
latency_ms: 1200,
|
|
27
|
+
routing_reason: 'cost_optimization'
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Query worker costs
|
|
31
|
+
costs = Legion::Extensions::Metering::Runners::Metering.worker_costs(
|
|
32
|
+
worker_id: 'my-worker',
|
|
33
|
+
period: 'daily'
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Query routing statistics
|
|
37
|
+
stats = Legion::Extensions::Metering::Runners::Metering.routing_stats
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Database
|
|
41
|
+
|
|
42
|
+
Requires `legion-data`. Creates the `metering_records` table via Sequel migration.
|
|
43
|
+
|
|
44
|
+
## Record Retention
|
|
45
|
+
|
|
46
|
+
Metering records are pruned automatically by the `Cleanup` actor, which runs once per day. The default retention period is 90 days. Records with a `recorded_at` older than the cutoff are permanently deleted.
|
|
47
|
+
|
|
48
|
+
To trigger cleanup manually:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
Legion::Extensions::Metering::Runners::Metering.cleanup_old_records(retention_days: 90)
|
|
52
|
+
# => { purged: 1234, retention_days: 90, cutoff: 2025-12-15 00:00:00 UTC }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Related
|
|
56
|
+
|
|
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
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require 'legion/extensions/metering/version'
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = 'lex-metering'
|
|
9
|
+
spec.version = Legion::Extensions::Metering::VERSION
|
|
10
|
+
spec.authors = ['Esity']
|
|
11
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
12
|
+
|
|
13
|
+
spec.summary = 'Legion::Extensions::Metering'
|
|
14
|
+
spec.description = 'Captures LLM token usage metrics per task for cost attribution and intelligent routing'
|
|
15
|
+
spec.homepage = 'https://github.com/LegionIO/lex-metering'
|
|
16
|
+
spec.license = 'MIT'
|
|
17
|
+
spec.required_ruby_version = '>= 3.4'
|
|
18
|
+
|
|
19
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
20
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-metering'
|
|
21
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-metering/blob/main/CHANGELOG.md'
|
|
22
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-metering'
|
|
23
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-metering/issues'
|
|
24
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
25
|
+
|
|
26
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
27
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
28
|
+
end
|
|
29
|
+
spec.require_paths = ['lib']
|
|
30
|
+
|
|
31
|
+
spec.add_dependency 'legionio'
|
|
32
|
+
|
|
33
|
+
spec.add_development_dependency 'rake'
|
|
34
|
+
spec.add_development_dependency 'rspec'
|
|
35
|
+
spec.add_development_dependency 'rubocop'
|
|
36
|
+
spec.add_development_dependency 'rubocop-rspec'
|
|
37
|
+
spec.add_development_dependency 'simplecov'
|
|
38
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Metering
|
|
6
|
+
module Actors
|
|
7
|
+
class Cleanup < Legion::Extensions::Actors::Every
|
|
8
|
+
def runner_class
|
|
9
|
+
'Legion::Extensions::Metering::Runners::Metering'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def runner_function
|
|
13
|
+
'cleanup_old_records'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def time
|
|
17
|
+
86_400 # once per day
|
|
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,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
up do
|
|
5
|
+
create_table(:metering_records) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :worker_id, null: true, size: 36, index: true
|
|
8
|
+
Integer :task_id, null: true, index: true
|
|
9
|
+
String :provider, null: true, size: 100, index: true
|
|
10
|
+
String :model_id, null: true, size: 255
|
|
11
|
+
Integer :input_tokens, null: false, default: 0
|
|
12
|
+
Integer :output_tokens, null: false, default: 0
|
|
13
|
+
Integer :thinking_tokens, null: false, default: 0
|
|
14
|
+
Integer :total_tokens, null: false, default: 0
|
|
15
|
+
Integer :input_context_bytes, null: false, default: 0
|
|
16
|
+
Integer :latency_ms, null: false, default: 0
|
|
17
|
+
Integer :wall_clock_ms, null: false, default: 0
|
|
18
|
+
Integer :cpu_time_ms, null: false, default: 0
|
|
19
|
+
Integer :external_api_calls, null: false, default: 0
|
|
20
|
+
String :routing_reason, null: true, size: 255
|
|
21
|
+
DateTime :recorded_at, null: false, default: Sequel::CURRENT_TIMESTAMP, index: true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
down do
|
|
26
|
+
drop_table :metering_records
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Metering
|
|
6
|
+
module Runners
|
|
7
|
+
module Metering
|
|
8
|
+
def record(worker_id: nil, task_id: nil, provider: nil, model_id: nil,
|
|
9
|
+
input_tokens: 0, output_tokens: 0, thinking_tokens: 0,
|
|
10
|
+
input_context_bytes: 0, latency_ms: 0, routing_reason: nil,
|
|
11
|
+
wall_clock_ms: 0, cpu_time_ms: 0, external_api_calls: 0, **)
|
|
12
|
+
record = {
|
|
13
|
+
worker_id: worker_id,
|
|
14
|
+
task_id: task_id,
|
|
15
|
+
provider: provider,
|
|
16
|
+
model_id: model_id,
|
|
17
|
+
input_tokens: input_tokens,
|
|
18
|
+
output_tokens: output_tokens,
|
|
19
|
+
thinking_tokens: thinking_tokens,
|
|
20
|
+
total_tokens: input_tokens + output_tokens + thinking_tokens,
|
|
21
|
+
input_context_bytes: input_context_bytes,
|
|
22
|
+
latency_ms: latency_ms,
|
|
23
|
+
wall_clock_ms: wall_clock_ms,
|
|
24
|
+
cpu_time_ms: cpu_time_ms,
|
|
25
|
+
external_api_calls: external_api_calls,
|
|
26
|
+
routing_reason: routing_reason,
|
|
27
|
+
recorded_at: Time.now.utc
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Legion::Data.connection[:metering_records].insert(record) if defined?(Legion::Data) && Legion::Data.connection
|
|
31
|
+
Legion::Logging.debug "[metering] recorded: provider=#{provider} model=#{model_id} " \
|
|
32
|
+
"tokens=#{record[:total_tokens]} latency=#{latency_ms}ms wall_clock=#{wall_clock_ms}ms"
|
|
33
|
+
record
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def worker_costs(worker_id:, period: 'daily', **)
|
|
37
|
+
ds = Legion::Data.connection[:metering_records].where(worker_id: worker_id)
|
|
38
|
+
|
|
39
|
+
case period
|
|
40
|
+
when 'daily'
|
|
41
|
+
ds = ds.where { recorded_at >= Sequel.lit("CURRENT_TIMESTAMP - INTERVAL '1 day'") }
|
|
42
|
+
when 'weekly'
|
|
43
|
+
ds = ds.where { recorded_at >= Sequel.lit("CURRENT_TIMESTAMP - INTERVAL '7 days'") }
|
|
44
|
+
when 'monthly'
|
|
45
|
+
ds = ds.where { recorded_at >= Sequel.lit("CURRENT_TIMESTAMP - INTERVAL '30 days'") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
worker_id: worker_id,
|
|
50
|
+
period: period,
|
|
51
|
+
total_tokens: ds.sum(:total_tokens) || 0,
|
|
52
|
+
input_tokens: ds.sum(:input_tokens) || 0,
|
|
53
|
+
output_tokens: ds.sum(:output_tokens) || 0,
|
|
54
|
+
thinking_tokens: ds.sum(:thinking_tokens) || 0,
|
|
55
|
+
total_calls: ds.count,
|
|
56
|
+
avg_latency_ms: ds.avg(:latency_ms)&.round(1) || 0,
|
|
57
|
+
by_provider: ds.group_and_count(:provider).all,
|
|
58
|
+
by_model: ds.group_and_count(:model_id).all
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def team_costs(team:, period: 'daily', **)
|
|
63
|
+
workers = Legion::Data::Model::DigitalWorker.where(team: team).select_map(:worker_id)
|
|
64
|
+
ds = Legion::Data.connection[:metering_records].where(worker_id: workers)
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
team: team,
|
|
68
|
+
period: period,
|
|
69
|
+
worker_count: workers.size,
|
|
70
|
+
total_tokens: ds.sum(:total_tokens) || 0,
|
|
71
|
+
total_calls: ds.count,
|
|
72
|
+
by_worker: ds.group_and_count(:worker_id).all
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def routing_stats(worker_id: nil, **)
|
|
77
|
+
ds = Legion::Data.connection[:metering_records]
|
|
78
|
+
ds = ds.where(worker_id: worker_id) if worker_id
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
by_routing_reason: ds.group_and_count(:routing_reason).all,
|
|
82
|
+
by_provider: ds.group_and_count(:provider).all,
|
|
83
|
+
by_model: ds.group_and_count(:model_id).all,
|
|
84
|
+
avg_latency_by_provider: ds.group(:provider).select_append { avg(latency_ms).as(avg_latency) }.all
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cleanup_old_records(retention_days: 90, **)
|
|
89
|
+
return { purged: 0, retention_days: retention_days, cutoff: nil } unless defined?(Legion::Data) && Legion::Data.connection
|
|
90
|
+
|
|
91
|
+
cutoff = Time.now.utc - (retention_days * 86_400)
|
|
92
|
+
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}"
|
|
94
|
+
{ purged: count, retention_days: retention_days, cutoff: cutoff }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/metering/version'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Metering
|
|
8
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
9
|
+
|
|
10
|
+
def self.data_required?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def data_required?
|
|
15
|
+
true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-metering
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
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: legionio
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rspec
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rubocop
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rubocop-rspec
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: simplecov
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
description: Captures LLM token usage metrics per task for cost attribution and intelligent
|
|
97
|
+
routing
|
|
98
|
+
email:
|
|
99
|
+
- matthewdiverson@gmail.com
|
|
100
|
+
executables: []
|
|
101
|
+
extensions: []
|
|
102
|
+
extra_rdoc_files: []
|
|
103
|
+
files:
|
|
104
|
+
- ".github/workflows/ci.yml"
|
|
105
|
+
- ".gitignore"
|
|
106
|
+
- ".rspec"
|
|
107
|
+
- ".rubocop.yml"
|
|
108
|
+
- CHANGELOG.md
|
|
109
|
+
- CLAUDE.md
|
|
110
|
+
- Gemfile
|
|
111
|
+
- LICENSE
|
|
112
|
+
- README.md
|
|
113
|
+
- lex-metering.gemspec
|
|
114
|
+
- lib/legion/extensions/metering.rb
|
|
115
|
+
- lib/legion/extensions/metering/actors/cleanup.rb
|
|
116
|
+
- lib/legion/extensions/metering/data/migrations/001_add_metering_records.rb
|
|
117
|
+
- lib/legion/extensions/metering/runners/metering.rb
|
|
118
|
+
- lib/legion/extensions/metering/version.rb
|
|
119
|
+
homepage: https://github.com/LegionIO/lex-metering
|
|
120
|
+
licenses:
|
|
121
|
+
- MIT
|
|
122
|
+
metadata:
|
|
123
|
+
homepage_uri: https://github.com/LegionIO/lex-metering
|
|
124
|
+
source_code_uri: https://github.com/LegionIO/lex-metering
|
|
125
|
+
changelog_uri: https://github.com/LegionIO/lex-metering/blob/main/CHANGELOG.md
|
|
126
|
+
documentation_uri: https://github.com/LegionIO/lex-metering
|
|
127
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-metering/issues
|
|
128
|
+
rubygems_mfa_required: 'true'
|
|
129
|
+
rdoc_options: []
|
|
130
|
+
require_paths:
|
|
131
|
+
- lib
|
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '3.4'
|
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
138
|
+
requirements:
|
|
139
|
+
- - ">="
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: '0'
|
|
142
|
+
requirements: []
|
|
143
|
+
rubygems_version: 3.6.9
|
|
144
|
+
specification_version: 4
|
|
145
|
+
summary: Legion::Extensions::Metering
|
|
146
|
+
test_files: []
|