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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b13e641291a41a378dba7a7eca32aaf67c46bc45cb5d459ded5bc3bdab8e0b0
4
- data.tar.gz: cc8c372089f5de6518222d608255d65f58968bb97d6eb4f77aac61bb475149d1
3
+ metadata.gz: 45a5b0befbd5b6ea879f539a30ecb6675f7481e349c115f52ffc2e167c9e7c8d
4
+ data.tar.gz: 66323d03c6aac956cb0ad78b9bb708cbe7e3c834ede3ab4b34c820e1289f2320
5
5
  SHA512:
6
- metadata.gz: b8f38cc2091b3f09f55325192cc6d92bda63aeddbdbe1548ebb04e6c68037219d7c1a892e86dd5663ef0334289c7107c2f8326cc30faf218e53dee0c4b19e1b9
7
- data.tar.gz: 50c0618742b5006211029aef080fd04c044b20f6b5e33a6bd6d5c070c8971defb3b21bf0f70341ac23900b20f3e80df2071505edf66879519e913a7e95ea1d33
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
@@ -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: :batched,
121
+ status: :failed,
106
122
  model: model,
107
123
  provider: provider,
108
124
  id: entry[:id],
109
125
  response: nil,
110
- meta: { batched: true, queued_at: entry[:queued_at] }
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/llm/hooks/rag_guard'
4
4
  require 'legion/llm/hooks/response_guard'
5
+ require 'legion/llm/hooks/metering'
5
6
 
6
7
  module Legion
7
8
  module LLM
@@ -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
- result = PEAK_HOURS.cover?(time.hour)
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
- if time.hour < PEAK_HOURS.first || time.hour >= PEAK_HOURS.last
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 current UTC hour falls within the configured peak window.
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.hour < peak_range.first
48
- # Before peak — off-peak is now
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.3.21'
5
+ VERSION = '0.3.23'
6
6
  end
7
7
  end
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.21
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