legion-llm 0.3.20 → 0.3.22

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: 19f442b46ed976f59b4f44878bff395fc497dde75bc9b3df8a4471442e0cb3eb
4
- data.tar.gz: 590d08bf5499d7a95989ea2a8e098c386a6a4b9eae1e5bcbda89b390f50c20a9
3
+ metadata.gz: db13bc01a538ce15c213a0ee49dae011b79c0bdf0148ebb940dad6b54cc769c4
4
+ data.tar.gz: a68d77b17f0eeff3e841620cc43bae9601e9a9069b555ca583b507ab258677db
5
5
  SHA512:
6
- metadata.gz: 490852eca96d4356949c3dc2a3bfa811c35a539ea47d89f8a12cc5a5098b3709ec03cf83677927378f8f96357af2de88dfbb24ffed68f2268535d3b692c0d1ce
7
- data.tar.gz: ae3efe882cde912a9cae6bca01605200027e13f6ff72232f04140bd67f030224600a6a6f4692d221c986e4d4d2228e3c64d1c7bbb50112521f78612f5aad3f46
6
+ metadata.gz: ab9351b4781dcf146d552f555d0da7eaa444a94d15125af387af33b5ae3741863fccccaafd1fd981faca6e3781589fd6f5a4a273b9a02bf940d389763c55150c
7
+ data.tar.gz: 4e17454656a9baf87b78a75e99bfe5cc6215a48d1135cc053abab4e85b8b300e3ece10ab5e6628dabbd7a22cbc167d1d3e1d5c63d87da226a8092db6f1ed3b64
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.3.22] - 2026-03-23
4
+
5
+ ### Changed
6
+ - `Batch.submit_single` now calls `Legion::LLM.chat_direct` instead of returning a stub response
7
+ - Batch flush returns `status: :completed` on success or `status: :failed` with error on exception
8
+ - `OffPeak` module now delegates to `Scheduling` (consolidated duplicate peak-hour logic)
9
+ - `Scheduling.peak_hours?` and `Scheduling.next_off_peak` accept optional `time` parameter
10
+
11
+ ## [0.3.21] - 2026-03-23
12
+
13
+ ### Added
14
+ - `Legion::LLM::ToolRegistry` thread-safe tool class registry for auto-attaching tools to chat sessions
15
+ - Wire ToolRegistry into `chat_single` so globally registered tools are available in every session
16
+
17
+ ### Fixed
18
+ - Fix `CostTracker.settings_pricing` reading from wrong settings key (`:'legion-llm'` instead of `:llm`)
19
+ - Fix `ShadowEval.evaluate` not passing `messages:` to shadow model (shadow got no context to respond to)
20
+
3
21
  ## [0.3.20] - 2026-03-22
4
22
 
5
23
  ### 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: :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
@@ -84,7 +84,7 @@ module Legion
84
84
  def settings_pricing
85
85
  return {} unless defined?(Legion::Settings)
86
86
 
87
- pricing = Legion::Settings.dig(:'legion-llm', :pricing)
87
+ pricing = Legion::Settings.dig(:llm, :pricing)
88
88
  pricing.is_a?(Hash) ? pricing : {}
89
89
  rescue StandardError => e
90
90
  Legion::Logging.warn("CostTracker settings unavailable: #{e.message}") if defined?(Legion::Logging)
@@ -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
@@ -15,14 +15,14 @@ module Legion
15
15
  rand < rate
16
16
  end
17
17
 
18
- def evaluate(primary_response:, messages: nil, shadow_model: nil) # rubocop:disable Lint/UnusedMethodArgument
18
+ def evaluate(primary_response:, messages: nil, shadow_model: nil)
19
19
  shadow_model ||= Legion::Settings.dig(:llm, :shadow, :model) || 'gpt-4o-mini'
20
20
  Legion::Logging.debug("ShadowEval triggered primary_model=#{primary_response[:model]} shadow_model=#{shadow_model}") if defined?(Legion::Logging)
21
21
 
22
22
  shadow_response = Legion::LLM.send(:chat_single,
23
23
  model: shadow_model, provider: nil,
24
- intent: nil, tier: nil,
25
- skip_shadow: true)
24
+ messages: messages, intent: nil,
25
+ tier: nil)
26
26
 
27
27
  comparison = compare(primary_response, shadow_response, shadow_model)
28
28
  Legion::Events.emit('llm.shadow_eval', comparison) if defined?(Legion::Events)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module ToolRegistry
6
+ @tools = []
7
+ @mutex = Mutex.new
8
+
9
+ class << self
10
+ def register(tool_class)
11
+ @mutex.synchronize do
12
+ @tools << tool_class unless @tools.include?(tool_class)
13
+ end
14
+ end
15
+
16
+ def tools
17
+ @mutex.synchronize { @tools.dup }
18
+ end
19
+
20
+ def clear
21
+ @mutex.synchronize { @tools.clear }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.3.20'
5
+ VERSION = '0.3.22'
6
6
  end
7
7
  end
data/lib/legion/llm.rb CHANGED
@@ -17,6 +17,7 @@ require_relative 'llm/batch'
17
17
  require_relative 'llm/scheduling'
18
18
  require_relative 'llm/off_peak'
19
19
  require_relative 'llm/cost_tracker'
20
+ require_relative 'llm/tool_registry'
20
21
 
21
22
  begin
22
23
  require 'legion/extensions/llm/gateway'
@@ -119,6 +120,9 @@ module Legion
119
120
  end
120
121
  end
121
122
 
123
+ if defined?(Legion::Logging)
124
+ Legion::Logging.debug "[LLM] chat_direct escalate=#{escalate} message_present=#{!message.nil?} model=#{model} provider=#{provider}"
125
+ end
122
126
  result = if escalate && message
123
127
  chat_with_escalation(
124
128
  model: model, provider: provider, intent: intent, tier: tier,
@@ -129,6 +133,7 @@ module Legion
129
133
  chat_single(model: model, provider: provider, intent: intent, tier: tier,
130
134
  temperature: temperature, message: message, **kwargs)
131
135
  end
136
+ Legion::Logging.debug "[LLM] chat_direct result_class=#{result.class} result_nil=#{result.nil?}" if defined?(Legion::Logging)
132
137
 
133
138
  if cache_key && result.is_a?(Hash)
134
139
  ttl = settings.dig(:prompt_caching, :response_cache, :ttl_seconds) || Cache::DEFAULT_TTL
@@ -283,7 +288,11 @@ module Legion
283
288
  Legion::Extensions::LLM::Gateway::Runners::Inference.chat(**)
284
289
  end
285
290
 
286
- def chat_single(model:, provider:, intent:, tier:, message: nil, **kwargs)
291
+ def chat_single(model:, provider:, intent:, tier:, message: nil, **kwargs) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
292
+ explicit_tools = kwargs.delete(:tools)
293
+ tools = explicit_tools || ToolRegistry.tools
294
+ tools = nil if tools.empty?
295
+
287
296
  if (intent || tier) && Router.routing_enabled?
288
297
  resolution = Router.resolve(intent: intent, tier: tier, model: model, provider: provider)
289
298
  if resolution
@@ -307,10 +316,17 @@ module Legion
307
316
 
308
317
  inject_anthropic_cache_control!(opts, provider)
309
318
 
319
+ if defined?(Legion::Logging)
320
+ Legion::Logging.debug "[LLM] chat_single model=#{opts[:model]} provider=#{opts[:provider]} message_present=#{!message.nil?} tools=#{tools&.size || 0}"
321
+ end
310
322
  session = RubyLLM.chat(**opts)
323
+ tools&.each { |tool| session.with_tool(tool) }
311
324
  return session unless message
312
325
 
313
- session.ask(message)
326
+ Legion::Logging.debug '[LLM] chat_single calling session.ask' if defined?(Legion::Logging)
327
+ response = session.ask(message)
328
+ Legion::Logging.debug "[LLM] chat_single response_class=#{response.class} response_nil=#{response.nil?}" if defined?(Legion::Logging)
329
+ response
314
330
  end
315
331
 
316
332
  def chat_with_escalation(model:, provider:, intent:, tier:, max_escalations:, quality_check:, message:, **kwargs)
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.20
4
+ version: 0.3.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -161,6 +161,7 @@ files:
161
161
  - lib/legion/llm/settings.rb
162
162
  - lib/legion/llm/shadow_eval.rb
163
163
  - lib/legion/llm/structured_output.rb
164
+ - lib/legion/llm/tool_registry.rb
164
165
  - lib/legion/llm/transport/exchanges/escalation.rb
165
166
  - lib/legion/llm/transport/messages/escalation_event.rb
166
167
  - lib/legion/llm/version.rb