legion-llm 0.3.17 → 0.3.18

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: 4f0427a4ddb7c7118e21cf0abd805ae05c994f78c35be322307858a9ad8d0b3c
4
- data.tar.gz: b010081a1f8007df86babebd563d5fada164eeabcd573594e3aaa310317c1474
3
+ metadata.gz: 0a19ae18f6bbb96680e6bfbcad1e81bd3e67c6b1be72e0615cf24852a69b2e59
4
+ data.tar.gz: f293c1bc52cb97652e545efb4877b32592d8aa0d96d190bef4f2624d6b277a5a
5
5
  SHA512:
6
- metadata.gz: e882a711b7c9a56d03c0fc3f1753c47cf6c148c7312cb3d50302dcaeda1ca67e09bef67ed37f295480625f981b4e32b1d0a4e96736fa9a6f3ac61af8cd1b57ec
7
- data.tar.gz: 59cb342d0af6c92e94caff375f557de3dac75413c19821f28d5c471f319d748cdcfff99ebd8328ea1fcf0e4dff98614cf262eba69037f5b7ce3d72f567bf2499
6
+ metadata.gz: 488d6fec5178b75b4f48b9031cd3e172b9637d1044ca210912ff1bc0a5b91818d2fd379725426e8be58bfc30638f0cd6ef83a2f550e94e728f40b74b1a39a5ad
7
+ data.tar.gz: b738f793fc22c4dbf3400da0fb9c13d2bb427321fa1f1d8a594482a35239644d2e4d0fc96e9e08c295c95a2e3ff388e9f45b1cddf3e7db0f1bd00a4139c257ed
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.3.18] - 2026-03-22
4
+
5
+ ### Added
6
+ - Logging across routing, health tracking, caching, and discovery subsystems
7
+ - `Router.resolve`: `.info` on route decision (tier/provider/model/rule), `.debug` on candidate filtering counts, `.debug` when no rules match
8
+ - `Router::HealthTracker`: `.warn` on circuit state transitions (closed->open, half_open->open, open->half_open, any->closed), `.debug` on latency penalty applied
9
+ - `Router::Rule`: `.debug` on intent mismatch, schedule constraint rejections (valid_from, valid_until, hours, days)
10
+ - `Cache`: `.debug` on cache miss and cache write, `.warn` on swallowed get/set errors
11
+ - `ResponseCache`: `.warn` on spool overflow to disk, `.debug` on async poll status, `.warn` on fail_request
12
+ - `DaemonClient`: `.warn` on mark_unhealthy, `.warn` on 403/429 responses, `.info` on health check result
13
+ - `StructuredOutput`: `.warn` on JSON parse failure with attempt count, `.debug` when using prompt-based fallback
14
+ - `Compressor`: `.debug` on compression applied (level, original length, compressed length)
15
+ - `Discovery::Ollama`: `.warn` on HTTP failure, `.debug` on model list refresh with count
16
+ - `Discovery::System`: `.warn` on system command failures (sysctl, vm_stat, /proc/meminfo)
17
+ - `ShadowEval`: `.debug` on evaluation triggered, `.warn` on failure
18
+ - `Scheduling`: `.debug` on defer decision
19
+ - `OffPeak`: `.debug` on peak hour check result
20
+ - `Arbitrage`: `.debug` on model selection result
21
+
22
+ ### Changed
23
+ - `Router::Rule#within_schedule?` refactored to extract `schedule_rejection` helper (reduces cyclomatic complexity)
24
+
3
25
  ## [0.3.17] - 2026-03-22
4
26
 
5
27
  ### Added
@@ -56,7 +56,9 @@ module Legion
56
56
 
57
57
  return nil if scored.empty?
58
58
 
59
- scored.min_by { |_model, cost| cost }&.first
59
+ selected = scored.min_by { |_model, cost| cost }&.first
60
+ Legion::Logging.debug("Arbitrage selected model=#{selected} capability=#{capability}") if defined?(Legion::Logging)
61
+ selected
60
62
  end
61
63
 
62
64
  # Returns the merged cost table: defaults overridden by any settings-defined entries.
@@ -27,10 +27,14 @@ module Legion
27
27
  return nil unless available?
28
28
 
29
29
  raw = Legion::Cache.get(cache_key)
30
- return nil if raw.nil?
30
+ if raw.nil?
31
+ Legion::Logging.debug("LLM cache miss key=#{cache_key}") if defined?(Legion::Logging)
32
+ return nil
33
+ end
31
34
 
32
35
  ::JSON.parse(raw, symbolize_names: true)
33
- rescue StandardError
36
+ rescue StandardError => e
37
+ Legion::Logging.warn("LLM cache get error key=#{cache_key}: #{e.message}") if defined?(Legion::Logging)
34
38
  nil
35
39
  end
36
40
 
@@ -39,8 +43,10 @@ module Legion
39
43
  return false unless available?
40
44
 
41
45
  Legion::Cache.set(cache_key, ::JSON.dump(response), ttl)
46
+ Legion::Logging.debug("LLM cache write key=#{cache_key} ttl=#{ttl}") if defined?(Legion::Logging)
42
47
  true
43
- rescue StandardError
48
+ rescue StandardError => e
49
+ Legion::Logging.warn("LLM cache set error key=#{cache_key}: #{e.message}") if defined?(Legion::Logging)
44
50
  false
45
51
  end
46
52
 
@@ -19,10 +19,12 @@ module Legion
19
19
  def compress(text, level: LIGHT)
20
20
  return text if text.nil? || text.empty? || level <= NONE
21
21
 
22
+ original_length = text.length
22
23
  segments = split_segments(text)
23
24
  result = segments.map { |seg| seg[:protected] ? seg[:text] : compress_prose(seg[:text], level) }.join
24
25
 
25
26
  result = collapse_whitespace(result) if level >= AGGRESSIVE
27
+ Legion::Logging.debug("Compressor applied level=#{level} original=#{original_length} compressed=#{result.length}") if defined?(Legion::Logging)
26
28
  result
27
29
  end
28
30
 
@@ -76,6 +76,7 @@ module Legion
76
76
  healthy = response.code == '200'
77
77
  @healthy = healthy
78
78
  @health_checked_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
79
+ Legion::Logging.info("Daemon health check result=#{healthy ? 'healthy' : 'unhealthy'} url=#{daemon_url}") if defined?(Legion::Logging)
79
80
  healthy
80
81
  rescue StandardError
81
82
  mark_unhealthy
@@ -84,6 +85,7 @@ module Legion
84
85
 
85
86
  # Marks the daemon as unhealthy and records the timestamp.
86
87
  def mark_unhealthy
88
+ Legion::Logging.warn("Daemon marked unhealthy url=#{daemon_url}") if defined?(Legion::Logging)
87
89
  @healthy = false
88
90
  @health_checked_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
89
91
  end
@@ -128,9 +130,11 @@ module Legion
128
130
  data = parsed.fetch(:data, {})
129
131
  { status: :accepted, request_id: data[:request_id], poll_key: data[:poll_key] }
130
132
  when 403
133
+ Legion::Logging.warn("Daemon returned 403 Denied url=#{daemon_url}") if defined?(Legion::Logging)
131
134
  { status: :denied, error: parsed.fetch(:error, parsed) }
132
135
  when 429
133
136
  retry_after = extract_retry_after(response, parsed)
137
+ Legion::Logging.warn("Daemon returned 429 RateLimited url=#{daemon_url} retry_after=#{retry_after}") if defined?(Legion::Logging)
134
138
  { status: :rate_limited, retry_after: retry_after }
135
139
  when 503
136
140
  { status: :unavailable }
@@ -30,10 +30,13 @@ module Legion
30
30
  if response.success?
31
31
  parsed = ::JSON.parse(response.body)
32
32
  @models = parsed['models'] || []
33
+ Legion::Logging.debug("Discovery::Ollama model list refreshed count=#{@models.size}") if defined?(Legion::Logging)
33
34
  else
35
+ Legion::Logging.warn("Discovery::Ollama HTTP failure status=#{response.status}") if defined?(Legion::Logging)
34
36
  @models ||= []
35
37
  end
36
- rescue StandardError
38
+ rescue StandardError => e
39
+ Legion::Logging.warn("Discovery::Ollama HTTP failure: #{e.message}") if defined?(Legion::Logging)
37
40
  @models ||= []
38
41
  ensure
39
42
  @last_refreshed_at = Time.now
@@ -94,7 +94,8 @@ module Legion
94
94
  def fetch_macos_total
95
95
  raw = `sysctl -n hw.memsize`.strip.to_i
96
96
  @total_memory_mb = raw / 1024 / 1024
97
- rescue StandardError
97
+ rescue StandardError => e
98
+ Legion::Logging.warn("Discovery::System sysctl command failed: #{e.message}") if defined?(Legion::Logging)
98
99
  @total_memory_mb = nil
99
100
  end
100
101
 
@@ -104,7 +105,8 @@ module Legion
104
105
  free = vm_output[/Pages free:\s+(\d+)/, 1].to_i
105
106
  inactive = vm_output[/Pages inactive:\s+(\d+)/, 1].to_i
106
107
  @available_memory_mb = (free + inactive) * page_size / 1024 / 1024
107
- rescue StandardError
108
+ rescue StandardError => e
109
+ Legion::Logging.warn("Discovery::System vm_stat command failed: #{e.message}") if defined?(Legion::Logging)
108
110
  @available_memory_mb = nil
109
111
  end
110
112
 
@@ -112,7 +114,8 @@ module Legion
112
114
  meminfo = File.read('/proc/meminfo')
113
115
  total_kb = meminfo[/MemTotal:\s+(\d+)/, 1].to_i
114
116
  @total_memory_mb = total_kb / 1024
115
- rescue StandardError
117
+ rescue StandardError => e
118
+ Legion::Logging.warn("Discovery::System /proc/meminfo read failed: #{e.message}") if defined?(Legion::Logging)
116
119
  @total_memory_mb = nil
117
120
  end
118
121
 
@@ -121,7 +124,8 @@ module Legion
121
124
  free_kb = meminfo[/MemFree:\s+(\d+)/, 1].to_i
122
125
  inactive_kb = meminfo[/Inactive:\s+(\d+)/, 1].to_i
123
126
  @available_memory_mb = (free_kb + inactive_kb) / 1024
124
- rescue StandardError
127
+ rescue StandardError => e
128
+ Legion::Logging.warn("Discovery::System /proc/meminfo available read failed: #{e.message}") if defined?(Legion::Logging)
125
129
  @available_memory_mb = nil
126
130
  end
127
131
 
@@ -12,7 +12,9 @@ module Legion
12
12
  # @param time [Time] time to check (defaults to now)
13
13
  # @return [Boolean]
14
14
  def peak_hour?(time = Time.now.utc)
15
- PEAK_HOURS.cover?(time.hour)
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
16
18
  end
17
19
 
18
20
  # Returns true when a non-urgent request should be deferred to off-peak.
@@ -26,6 +26,7 @@ module Legion
26
26
 
27
27
  # Writes error details and marks status as :error.
28
28
  def fail_request(request_id, code:, message:, ttl: DEFAULT_TTL)
29
+ Legion::Logging.warn("ResponseCache fail_request request_id=#{request_id} code=#{code} message=#{message}") if defined?(Legion::Logging)
29
30
  payload = ::JSON.dump({ code: code, message: message })
30
31
  cache_set(error_key(request_id), payload, ttl)
31
32
  cache_set(status_key(request_id), 'error', ttl)
@@ -69,6 +70,7 @@ module Legion
69
70
 
70
71
  loop do
71
72
  current = status(request_id)
73
+ Legion::Logging.debug("ResponseCache poll request_id=#{request_id} status=#{current}") if defined?(Legion::Logging)
72
74
 
73
75
  case current
74
76
  when :done
@@ -120,6 +122,7 @@ module Legion
120
122
 
121
123
  private_class_method def self.write_response(request_id, response_text, ttl)
122
124
  if response_text.bytesize > SPOOL_THRESHOLD
125
+ Legion::Logging.warn("ResponseCache spool overflow request_id=#{request_id} bytes=#{response_text.bytesize}") if defined?(Legion::Logging)
123
126
  FileUtils.mkdir_p(SPOOL_DIR)
124
127
  path = File.join(SPOOL_DIR, "#{request_id}.txt")
125
128
  File.write(path, response_text)
@@ -49,7 +49,10 @@ module Legion
49
49
 
50
50
  if circuit[:state] == :open
51
51
  elapsed = Time.now - circuit[:opened_at]
52
- return :half_open if elapsed >= @cooldown_seconds
52
+ if elapsed >= @cooldown_seconds
53
+ Legion::Logging.warn("Circuit open->half_open for provider=#{provider} (cooldown elapsed)") if defined?(Legion::Logging)
54
+ return :half_open
55
+ end
53
56
  end
54
57
 
55
58
  circuit[:state]
@@ -82,11 +85,13 @@ module Legion
82
85
  if circuit_state(provider) == :half_open
83
86
  circuit[:state] = :open
84
87
  circuit[:opened_at] = Time.now
88
+ Legion::Logging.warn("Circuit half_open->open for provider=#{provider} (error during probe)") if defined?(Legion::Logging)
85
89
  else
86
90
  circuit[:failures] += 1.0
87
91
  if circuit[:failures] >= @failure_threshold
88
92
  circuit[:state] = :open
89
93
  circuit[:opened_at] = Time.now
94
+ Legion::Logging.warn("Circuit closed->open for provider=#{provider} (failures=#{circuit[:failures]})") if defined?(Legion::Logging)
90
95
  end
91
96
  end
92
97
  end
@@ -94,10 +99,12 @@ module Legion
94
99
  register_handler(:success) do |payload|
95
100
  provider = payload[:provider]
96
101
  ensure_circuit(provider)
102
+ prev_state = circuit_state(provider)
97
103
  circuit = @circuits[provider]
98
104
  circuit[:failures] = 0
99
105
  circuit[:state] = :closed
100
106
  circuit[:opened_at] = nil
107
+ Legion::Logging.warn("Circuit #{prev_state}->closed for provider=#{provider}") if defined?(Legion::Logging) && prev_state != :closed
101
108
  end
102
109
 
103
110
  register_handler(:quality_failure) do |payload|
@@ -108,11 +115,13 @@ module Legion
108
115
  if circuit_state(provider) == :half_open
109
116
  circuit[:state] = :open
110
117
  circuit[:opened_at] = Time.now
118
+ Legion::Logging.warn("Circuit half_open->open for provider=#{provider} (quality failure during probe)") if defined?(Legion::Logging)
111
119
  else
112
120
  circuit[:failures] += 0.5
113
121
  if circuit[:failures] >= @failure_threshold
114
122
  circuit[:state] = :open
115
123
  circuit[:opened_at] = Time.now
124
+ Legion::Logging.warn("Circuit closed->open for provider=#{provider} (quality failures=#{circuit[:failures]})") if defined?(Legion::Logging)
116
125
  end
117
126
  end
118
127
  end
@@ -152,7 +161,9 @@ module Legion
152
161
  return 0 if avg <= LATENCY_THRESHOLD_MS
153
162
 
154
163
  multiplier = (avg / LATENCY_THRESHOLD_MS).floor
155
- [LATENCY_PENALTY_STEP * multiplier, OPEN_PENALTY].max
164
+ penalty = [LATENCY_PENALTY_STEP * multiplier, OPEN_PENALTY].max
165
+ Legion::Logging.debug("Latency penalty applied to provider=#{provider} avg_ms=#{avg.round} penalty=#{penalty}") if defined?(Legion::Logging)
166
+ penalty
156
167
  end
157
168
  end
158
169
  end
@@ -39,9 +39,17 @@ module Legion
39
39
 
40
40
  def matches_intent?(intent)
41
41
  @conditions.all? do |key, value|
42
- return false unless intent.key?(key)
42
+ unless intent.key?(key)
43
+ Legion::Logging.debug("Rule '#{@name}' rejected: missing intent key=#{key}") if defined?(Legion::Logging)
44
+ return false
45
+ end
43
46
 
44
- intent[key].to_s == value.to_s
47
+ unless intent[key].to_s == value.to_s
48
+ Legion::Logging.debug("Rule '#{@name}' rejected: intent #{key}=#{intent[key]} != #{value}") if defined?(Legion::Logging)
49
+ return false
50
+ end
51
+
52
+ true
45
53
  end
46
54
  end
47
55
 
@@ -60,17 +68,32 @@ module Legion
60
68
 
61
69
  sched = @schedule.transform_keys(&:to_s)
62
70
  now = localize(now, sched['timezone'])
63
-
64
- return false if sched['valid_from'] && now < Time.parse(sched['valid_from'])
65
- return false if sched['valid_until'] && now > Time.parse(sched['valid_until'])
66
- return false if sched['hours'] && !within_hours?(sched['hours'], now)
67
- return false if sched['days'] && !on_allowed_day?(sched['days'], now)
68
-
69
- true
71
+ schedule_rejection(sched, now).nil?
70
72
  end
71
73
 
72
74
  private
73
75
 
76
+ def schedule_rejection(sched, now)
77
+ if sched['valid_from'] && now < Time.parse(sched['valid_from'])
78
+ Legion::Logging.debug("Rule '#{@name}' rejected: before valid_from=#{sched['valid_from']}") if defined?(Legion::Logging)
79
+ return :valid_from
80
+ end
81
+ if sched['valid_until'] && now > Time.parse(sched['valid_until'])
82
+ Legion::Logging.debug("Rule '#{@name}' rejected: after valid_until=#{sched['valid_until']}") if defined?(Legion::Logging)
83
+ return :valid_until
84
+ end
85
+ if sched['hours'] && !within_hours?(sched['hours'], now)
86
+ Legion::Logging.debug("Rule '#{@name}' rejected: outside schedule hours=#{sched['hours']}") if defined?(Legion::Logging)
87
+ return :hours
88
+ end
89
+ if sched['days'] && !on_allowed_day?(sched['days'], now)
90
+ Legion::Logging.debug("Rule '#{@name}' rejected: outside schedule days=#{sched['days']}") if defined?(Legion::Logging)
91
+ return :days
92
+ end
93
+
94
+ nil
95
+ end
96
+
74
97
  def localize(time, timezone_name)
75
98
  return time unless timezone_name
76
99
 
@@ -28,7 +28,17 @@ module Legion
28
28
  rules = load_rules
29
29
  candidates = select_candidates(rules, merged)
30
30
  best = pick_best(candidates)
31
- best&.to_resolution
31
+ resolution = best&.to_resolution
32
+
33
+ if resolution
34
+ if defined?(Legion::Logging)
35
+ Legion::Logging.info("Routed to tier=#{resolution.tier} provider=#{resolution.provider} model=#{resolution.model} via rule='#{resolution.rule}'")
36
+ end
37
+ elsif defined?(Legion::Logging)
38
+ Legion::Logging.debug('Router: no rules matched, resolution is nil')
39
+ end
40
+
41
+ resolution
32
42
  end
33
43
 
34
44
  def resolve_chain(intent: nil, tier: nil, model: nil, provider: nil, max_escalations: nil)
@@ -100,6 +110,8 @@ module Legion
100
110
  end
101
111
 
102
112
  def select_candidates(rules, intent)
113
+ Legion::Logging.debug("Router: selecting candidates from #{rules.size} rules") if defined?(Legion::Logging)
114
+
103
115
  # 1. Collect constraints from constraint rules that match the intent
104
116
  constraints = rules
105
117
  .select { |r| r.constraint && r.matches_intent?(intent) }
@@ -118,7 +130,11 @@ module Legion
118
130
  discovered = unconstrained.reject { |r| excluded_by_discovery?(r) }
119
131
 
120
132
  # 5. Filter by tier availability
121
- discovered.select { |r| tier_available?(r.target[:tier] || r.target['tier']) }
133
+ final = discovered.select { |r| tier_available?(r.target[:tier] || r.target['tier']) }
134
+
135
+ Legion::Logging.debug("Router: #{final.size} candidates after filtering (started with #{rules.size})") if defined?(Legion::Logging)
136
+
137
+ final
122
138
  end
123
139
 
124
140
  def excluded_by_constraint?(rule, constraints)
@@ -24,7 +24,9 @@ module Legion
24
24
  return false unless enabled?
25
25
  return false if urgency.to_sym == :immediate
26
26
 
27
- eligible_for_deferral?(intent.to_sym) && peak_hours?
27
+ result = eligible_for_deferral?(intent.to_sym) && peak_hours?
28
+ Legion::Logging.debug("Scheduling defer decision intent=#{intent} urgency=#{urgency} defer=#{result}") if defined?(Legion::Logging)
29
+ result
28
30
  end
29
31
 
30
32
  # Returns true if the current UTC hour falls within the configured peak window.
@@ -17,6 +17,7 @@ module Legion
17
17
 
18
18
  def evaluate(primary_response:, messages: nil, shadow_model: nil) # rubocop:disable Lint/UnusedMethodArgument
19
19
  shadow_model ||= Legion::Settings.dig(:llm, :shadow, :model) || 'gpt-4o-mini'
20
+ Legion::Logging.debug("ShadowEval triggered primary_model=#{primary_response[:model]} shadow_model=#{shadow_model}") if defined?(Legion::Logging)
20
21
 
21
22
  shadow_response = Legion::LLM.send(:chat_single,
22
23
  model: shadow_model, provider: nil,
@@ -27,6 +28,7 @@ module Legion
27
28
  Legion::Events.emit('llm.shadow_eval', comparison) if defined?(Legion::Events)
28
29
  comparison
29
30
  rescue StandardError => e
31
+ Legion::Logging.warn("ShadowEval failed shadow_model=#{shadow_model}: #{e.message}") if defined?(Legion::Logging)
30
32
  { error: e.message, shadow_model: shadow_model }
31
33
  end
32
34
 
@@ -26,6 +26,7 @@ module Legion
26
26
  json_schema: { name: 'response', schema: schema } },
27
27
  **opts.except(:attempt))
28
28
  else
29
+ Legion::Logging.debug("StructuredOutput using prompt-based fallback for model=#{model}") if defined?(Legion::Logging)
29
30
  instruction = "You MUST respond with valid JSON matching this schema:\n" \
30
31
  "```json\n#{Legion::JSON.dump(schema)}\n```\n" \
31
32
  'Respond with ONLY the JSON object, no other text.'
@@ -37,8 +38,10 @@ module Legion
37
38
  end
38
39
 
39
40
  def handle_parse_error(error, messages, schema, model, result, **opts)
40
- if retry_enabled? && (opts[:attempt] || 0) < max_retries
41
- retry_with_instruction(messages, schema, model, attempt: (opts[:attempt] || 0) + 1, **opts)
41
+ attempt = opts[:attempt] || 0
42
+ Legion::Logging.warn("StructuredOutput JSON parse failure attempt=#{attempt} model=#{model}: #{error.message}") if defined?(Legion::Logging)
43
+ if retry_enabled? && attempt < max_retries
44
+ retry_with_instruction(messages, schema, model, attempt: attempt + 1, **opts)
42
45
  else
43
46
  { data: nil, error: "JSON parse failed: #{error.message}", raw: result&.dig(:content), valid: false }
44
47
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.3.17'
5
+ VERSION = '0.3.18'
6
6
  end
7
7
  end
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.17
4
+ version: 0.3.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity