legion-llm 0.3.21 → 0.3.23
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 +4 -4
- data/CHANGELOG.md +17 -0
- data/lib/legion/llm/batch.rb +19 -2
- data/lib/legion/llm/hooks/metering.rb +90 -0
- data/lib/legion/llm/hooks.rb +1 -0
- data/lib/legion/llm/off_peak.rb +6 -25
- data/lib/legion/llm/scheduling.rb +10 -10
- data/lib/legion/llm/version.rb +1 -1
- data/lib/legion/llm.rb +9 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45a5b0befbd5b6ea879f539a30ecb6675f7481e349c115f52ffc2e167c9e7c8d
|
|
4
|
+
data.tar.gz: 66323d03c6aac956cb0ad78b9bb708cbe7e3c834ede3ab4b34c820e1289f2320
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37ace42f654c110b9e633c53f4999fadd52d447f76145f95bd8ea18a3bcd816ad7ff4b6d3c7270eceaa12ab3b2f4a00f0192066df2a0adc53643d4614f534bb8
|
|
7
|
+
data.tar.gz: 7c6d639f5f45dbc4c3fccb94e8fece4e933da6037758d8eec3590c35fa15c24f55c19cdc9cc534b848a879d5de585d1c2cef070faa0b3bf354417cef25f86ab7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Legion LLM Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.23] - 2026-03-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Auto-metering hook: records token usage after every LLM call via gateway MeteringWriter or AMQP transport
|
|
7
|
+
- `Hooks::Metering.install` registers an `after_chat` hook during `LLM.start`
|
|
8
|
+
- Extracts input/output tokens, provider, model, status from response
|
|
9
|
+
- Opt-out via `llm.metering.auto: false` in settings
|
|
10
|
+
- 11 specs covering hook installation, data extraction, availability checks, and edge cases
|
|
11
|
+
|
|
12
|
+
## [0.3.22] - 2026-03-23
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- `Batch.submit_single` now calls `Legion::LLM.chat_direct` instead of returning a stub response
|
|
16
|
+
- Batch flush returns `status: :completed` on success or `status: :failed` with error on exception
|
|
17
|
+
- `OffPeak` module now delegates to `Scheduling` (consolidated duplicate peak-hour logic)
|
|
18
|
+
- `Scheduling.peak_hours?` and `Scheduling.next_off_peak` accept optional `time` parameter
|
|
19
|
+
|
|
3
20
|
## [0.3.21] - 2026-03-23
|
|
4
21
|
|
|
5
22
|
### Added
|
data/lib/legion/llm/batch.rb
CHANGED
|
@@ -101,13 +101,30 @@ module Legion
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def submit_single(entry, provider:, model:)
|
|
104
|
+
response = Legion::LLM.chat_direct(
|
|
105
|
+
messages: entry[:messages],
|
|
106
|
+
model: model,
|
|
107
|
+
**entry[:opts]
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
status: :completed,
|
|
112
|
+
model: model,
|
|
113
|
+
provider: provider,
|
|
114
|
+
id: entry[:id],
|
|
115
|
+
response: response,
|
|
116
|
+
meta: { batched: true, queued_at: entry[:queued_at], completed_at: Time.now.utc }
|
|
117
|
+
}
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Legion::Logging.warn("Batch submit_single failed for #{entry[:id]}: #{e.message}") if defined?(Legion::Logging)
|
|
104
120
|
{
|
|
105
|
-
status: :
|
|
121
|
+
status: :failed,
|
|
106
122
|
model: model,
|
|
107
123
|
provider: provider,
|
|
108
124
|
id: entry[:id],
|
|
109
125
|
response: nil,
|
|
110
|
-
|
|
126
|
+
error: e.message,
|
|
127
|
+
meta: { batched: true, queued_at: entry[:queued_at], failed_at: Time.now.utc }
|
|
111
128
|
}
|
|
112
129
|
end
|
|
113
130
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Hooks
|
|
6
|
+
module Metering
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def install
|
|
10
|
+
Legion::LLM::Hooks.after_chat do |response:, model:, **|
|
|
11
|
+
record(response, model)
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record(response, model)
|
|
17
|
+
return unless metering_available?
|
|
18
|
+
|
|
19
|
+
payload = extract_metering_data(response, model)
|
|
20
|
+
return if payload[:input_tokens].zero? && payload[:output_tokens].zero?
|
|
21
|
+
|
|
22
|
+
publish_metering(payload)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Legion::Logging.debug("[LLM::Metering] record failed: #{e.message}") if defined?(Legion::Logging)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def extract_metering_data(response, model)
|
|
28
|
+
usage = extract_usage(response)
|
|
29
|
+
{
|
|
30
|
+
provider: extract_provider(response),
|
|
31
|
+
model_id: (extract_model(response) || model).to_s,
|
|
32
|
+
input_tokens: usage[:input_tokens],
|
|
33
|
+
output_tokens: usage[:output_tokens],
|
|
34
|
+
event_type: 'llm_completion',
|
|
35
|
+
status: response.is_a?(Hash) && response[:error] ? 'failure' : 'success'
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_usage(response)
|
|
40
|
+
return { input_tokens: 0, output_tokens: 0 } unless response.is_a?(Hash)
|
|
41
|
+
|
|
42
|
+
usage = response[:usage] || {}
|
|
43
|
+
{
|
|
44
|
+
input_tokens: usage[:input_tokens] || usage[:prompt_tokens] || 0,
|
|
45
|
+
output_tokens: usage[:output_tokens] || usage[:completion_tokens] || 0
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def extract_provider(response)
|
|
50
|
+
return nil unless response.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
response.dig(:meta, :provider) || response[:provider]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def extract_model(response)
|
|
56
|
+
return nil unless response.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
response.dig(:meta, :model) || response[:model]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def publish_metering(payload)
|
|
62
|
+
if gateway_metering?
|
|
63
|
+
Legion::Extensions::LLM::Gateway::Runners::MeteringWriter.write_metering_record(payload)
|
|
64
|
+
elsif transport_metering?
|
|
65
|
+
Legion::Transport.publish(
|
|
66
|
+
'lex.metering.record',
|
|
67
|
+
Legion::JSON.dump(payload)
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def gateway_metering?
|
|
73
|
+
defined?(Legion::Extensions::LLM::Gateway::Runners::MeteringWriter)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def transport_metering?
|
|
77
|
+
defined?(Legion::Transport) &&
|
|
78
|
+
Legion::Transport.respond_to?(:connected?) &&
|
|
79
|
+
Legion::Transport.connected?
|
|
80
|
+
rescue StandardError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def metering_available?
|
|
85
|
+
gateway_metering? || transport_metering?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/legion/llm/hooks.rb
CHANGED
data/lib/legion/llm/off_peak.rb
CHANGED
|
@@ -1,44 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'scheduling'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module LLM
|
|
7
|
+
# Simplified peak-hour interface delegating to Scheduling.
|
|
8
|
+
# Preserved for backward compatibility.
|
|
5
9
|
module OffPeak
|
|
6
|
-
# Peak hours in UTC: 14:00-22:00 (9 AM - 5 PM CT)
|
|
7
|
-
PEAK_HOURS = (14..22)
|
|
8
|
-
|
|
9
10
|
class << self
|
|
10
|
-
# Returns true if the given time falls within peak hours.
|
|
11
|
-
#
|
|
12
|
-
# @param time [Time] time to check (defaults to now)
|
|
13
|
-
# @return [Boolean]
|
|
14
11
|
def peak_hour?(time = Time.now.utc)
|
|
15
|
-
|
|
16
|
-
Legion::Logging.debug("OffPeak peak_hour check hour=#{time.hour} peak=#{result}") if defined?(Legion::Logging)
|
|
17
|
-
result
|
|
12
|
+
Scheduling.peak_hours?(time)
|
|
18
13
|
end
|
|
19
14
|
|
|
20
|
-
# Returns true when a non-urgent request should be deferred to off-peak.
|
|
21
|
-
#
|
|
22
|
-
# @param priority [Symbol] :urgent bypasses deferral; :normal and :low defer during peak
|
|
23
|
-
# @return [Boolean]
|
|
24
15
|
def should_defer?(priority: :normal)
|
|
25
16
|
return false if priority.to_sym == :urgent
|
|
26
17
|
|
|
27
18
|
peak_hour?
|
|
28
19
|
end
|
|
29
20
|
|
|
30
|
-
# Returns the next off-peak Time (UTC).
|
|
31
|
-
# If already off-peak, returns the current time.
|
|
32
|
-
# Off-peak begins at the hour after the peak window ends (23:00 UTC).
|
|
33
|
-
#
|
|
34
|
-
# @param time [Time] reference time (defaults to now)
|
|
35
|
-
# @return [Time]
|
|
36
21
|
def next_off_peak(time = Time.now.utc)
|
|
37
|
-
|
|
38
|
-
time
|
|
39
|
-
else
|
|
40
|
-
Time.utc(time.year, time.month, time.day, PEAK_HOURS.last, 0, 0)
|
|
41
|
-
end
|
|
22
|
+
Scheduling.next_off_peak(time)
|
|
42
23
|
end
|
|
43
24
|
end
|
|
44
25
|
end
|
|
@@ -29,9 +29,9 @@ module Legion
|
|
|
29
29
|
result
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# Returns true if the
|
|
33
|
-
def peak_hours?
|
|
34
|
-
hour = Time.now.utc.hour
|
|
32
|
+
# Returns true if the given UTC hour falls within the configured peak window.
|
|
33
|
+
def peak_hours?(time = Time.now.utc)
|
|
34
|
+
hour = time.is_a?(Time) ? time.hour : Time.now.utc.hour
|
|
35
35
|
peak_range.cover?(hour)
|
|
36
36
|
end
|
|
37
37
|
|
|
@@ -39,19 +39,19 @@ module Legion
|
|
|
39
39
|
# Off-peak begins at the hour after the peak window ends.
|
|
40
40
|
#
|
|
41
41
|
# @return [Time] next off-peak start time
|
|
42
|
-
def next_off_peak
|
|
43
|
-
now = Time.now.utc
|
|
42
|
+
def next_off_peak(time = Time.now.utc)
|
|
43
|
+
now = time.is_a?(Time) ? time : Time.now.utc
|
|
44
44
|
peak_end = peak_range.last
|
|
45
45
|
max_defer = settings.fetch(:max_defer_hours, 8)
|
|
46
46
|
|
|
47
|
-
next_time = if now
|
|
48
|
-
#
|
|
49
|
-
now
|
|
50
|
-
else
|
|
51
|
-
# During or after peak — next off-peak is at peak_end + 1
|
|
47
|
+
next_time = if peak_hours?(now)
|
|
48
|
+
# During peak — next off-peak is at peak_end + 1
|
|
52
49
|
candidate = Time.utc(now.year, now.month, now.day, peak_end + 1, 0, 0)
|
|
53
50
|
candidate += 86_400 if candidate <= now
|
|
54
51
|
candidate
|
|
52
|
+
else
|
|
53
|
+
# Already off-peak — return now
|
|
54
|
+
now
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Cap at max_defer_hours from now
|
data/lib/legion/llm/version.rb
CHANGED
data/lib/legion/llm.rb
CHANGED
|
@@ -46,6 +46,8 @@ module Legion
|
|
|
46
46
|
run_discovery
|
|
47
47
|
set_defaults
|
|
48
48
|
|
|
49
|
+
install_hooks
|
|
50
|
+
|
|
49
51
|
@started = true
|
|
50
52
|
Legion::Settings[:llm][:connected] = true
|
|
51
53
|
Legion::Logging.info 'Legion::LLM started'
|
|
@@ -494,6 +496,13 @@ module Legion
|
|
|
494
496
|
cloud_providers.include?(resolved&.to_sym)
|
|
495
497
|
end
|
|
496
498
|
|
|
499
|
+
def install_hooks
|
|
500
|
+
metering_enabled = settings.dig(:metering, :auto) != false
|
|
501
|
+
Hooks::Metering.install if metering_enabled
|
|
502
|
+
rescue StandardError => e
|
|
503
|
+
Legion::Logging.debug("LLM hook installation failed: #{e.message}") if defined?(Legion::Logging)
|
|
504
|
+
end
|
|
505
|
+
|
|
497
506
|
def set_defaults
|
|
498
507
|
default_model = settings[:default_model]
|
|
499
508
|
default_provider = settings[:default_provider]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.23
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -145,6 +145,7 @@ files:
|
|
|
145
145
|
- lib/legion/llm/escalation_history.rb
|
|
146
146
|
- lib/legion/llm/helpers/llm.rb
|
|
147
147
|
- lib/legion/llm/hooks.rb
|
|
148
|
+
- lib/legion/llm/hooks/metering.rb
|
|
148
149
|
- lib/legion/llm/hooks/rag_guard.rb
|
|
149
150
|
- lib/legion/llm/hooks/response_guard.rb
|
|
150
151
|
- lib/legion/llm/off_peak.rb
|