legion-llm 0.3.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 +18 -0
- data/.rubocop.yml +56 -0
- data/CHANGELOG.md +71 -0
- data/CLAUDE.md +388 -0
- data/Gemfile +14 -0
- data/LICENSE +20 -0
- data/README.md +615 -0
- data/docs/plans/2026-03-15-ollama-discovery-design.md +164 -0
- data/docs/plans/2026-03-15-ollama-discovery-implementation.md +1147 -0
- data/legion-llm.gemspec +32 -0
- data/lib/legion/llm/bedrock_bearer_auth.rb +53 -0
- data/lib/legion/llm/compressor.rb +75 -0
- data/lib/legion/llm/discovery/ollama.rb +88 -0
- data/lib/legion/llm/discovery/system.rb +139 -0
- data/lib/legion/llm/escalation_history.rb +28 -0
- data/lib/legion/llm/helpers/llm.rb +59 -0
- data/lib/legion/llm/providers.rb +88 -0
- data/lib/legion/llm/quality_checker.rb +56 -0
- data/lib/legion/llm/router/escalation_chain.rb +49 -0
- data/lib/legion/llm/router/health_tracker.rb +160 -0
- data/lib/legion/llm/router/resolution.rb +43 -0
- data/lib/legion/llm/router/rule.rb +103 -0
- data/lib/legion/llm/router.rb +279 -0
- data/lib/legion/llm/settings.rb +97 -0
- data/lib/legion/llm/transport/exchanges/escalation.rb +14 -0
- data/lib/legion/llm/transport/messages/escalation_event.rb +13 -0
- data/lib/legion/llm/version.rb +7 -0
- data/lib/legion/llm.rb +264 -0
- metadata +136 -0
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
# Ollama Discovery & System Memory Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add Ollama model discovery (`/api/tags`) and OS memory introspection to legion-llm so the router can skip rules targeting models that aren't pulled or that exceed available RAM.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two new modules (`Discovery::Ollama`, `Discovery::System`) provide lazy TTL-cached lookups. The Router's `select_candidates` pipeline gains one new filter step between constraint filtering and tier availability. Settings add a `discovery` key.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Faraday (transitive dep via ruby_llm), macOS `sysctl`/`vm_stat`, Linux `/proc/meminfo`, RSpec + WebMock
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
### Task 1: Add discovery settings defaults
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `lib/legion/llm/settings.rb:6-14`
|
|
17
|
+
|
|
18
|
+
**Step 1: Write the failing test**
|
|
19
|
+
|
|
20
|
+
Create `spec/legion/llm/discovery/settings_spec.rb`:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# frozen_string_literal: true
|
|
24
|
+
|
|
25
|
+
require 'spec_helper'
|
|
26
|
+
|
|
27
|
+
RSpec.describe 'Discovery settings defaults' do
|
|
28
|
+
it 'includes discovery key in LLM settings' do
|
|
29
|
+
expect(Legion::Settings[:llm][:discovery]).to be_a(Hash)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'defaults enabled to true' do
|
|
33
|
+
expect(Legion::Settings[:llm][:discovery][:enabled]).to be true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'defaults refresh_seconds to 60' do
|
|
37
|
+
expect(Legion::Settings[:llm][:discovery][:refresh_seconds]).to eq(60)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'defaults memory_floor_mb to 2048' do
|
|
41
|
+
expect(Legion::Settings[:llm][:discovery][:memory_floor_mb]).to eq(2048)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Step 2: Run test to verify it fails**
|
|
47
|
+
|
|
48
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/settings_spec.rb -v`
|
|
49
|
+
Expected: FAIL — `discovery` key is nil
|
|
50
|
+
|
|
51
|
+
**Step 3: Write minimal implementation**
|
|
52
|
+
|
|
53
|
+
Edit `lib/legion/llm/settings.rb`. Add `discovery: discovery_defaults` to the `default` hash and add the `discovery_defaults` method:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
def self.default
|
|
57
|
+
{
|
|
58
|
+
enabled: true,
|
|
59
|
+
connected: false,
|
|
60
|
+
default_model: nil,
|
|
61
|
+
default_provider: nil,
|
|
62
|
+
providers: providers,
|
|
63
|
+
routing: routing_defaults,
|
|
64
|
+
discovery: discovery_defaults
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.discovery_defaults
|
|
69
|
+
{
|
|
70
|
+
enabled: true,
|
|
71
|
+
refresh_seconds: 60,
|
|
72
|
+
memory_floor_mb: 2048
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Step 4: Run test to verify it passes**
|
|
78
|
+
|
|
79
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/settings_spec.rb -v`
|
|
80
|
+
Expected: 4 examples, 0 failures
|
|
81
|
+
|
|
82
|
+
**Step 5: Run full suite to verify no regressions**
|
|
83
|
+
|
|
84
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
|
|
85
|
+
Expected: All existing tests pass
|
|
86
|
+
|
|
87
|
+
**Step 6: Commit**
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
cd /Users/miverso2/rubymine/legion/legion-llm
|
|
91
|
+
git add lib/legion/llm/settings.rb spec/legion/llm/discovery/settings_spec.rb
|
|
92
|
+
git commit -m "add discovery settings defaults"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### Task 2: Implement Discovery::System
|
|
98
|
+
|
|
99
|
+
**Files:**
|
|
100
|
+
- Create: `lib/legion/llm/discovery/system.rb`
|
|
101
|
+
- Create: `spec/legion/llm/discovery/system_spec.rb`
|
|
102
|
+
|
|
103
|
+
**Step 1: Write the failing test**
|
|
104
|
+
|
|
105
|
+
Create `spec/legion/llm/discovery/system_spec.rb`:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# frozen_string_literal: true
|
|
109
|
+
|
|
110
|
+
require 'spec_helper'
|
|
111
|
+
require 'legion/llm/discovery/system'
|
|
112
|
+
|
|
113
|
+
RSpec.describe Legion::LLM::Discovery::System do
|
|
114
|
+
before { described_class.reset! }
|
|
115
|
+
|
|
116
|
+
describe '.platform' do
|
|
117
|
+
it 'returns :macos, :linux, or :unknown' do
|
|
118
|
+
expect(%i[macos linux unknown]).to include(described_class.platform)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
describe '.total_memory_mb' do
|
|
123
|
+
context 'on macOS' do
|
|
124
|
+
before do
|
|
125
|
+
allow(described_class).to receive(:platform).and_return(:macos)
|
|
126
|
+
# 64 GB in bytes
|
|
127
|
+
allow(described_class).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'returns total memory in MB' do
|
|
131
|
+
described_class.refresh!
|
|
132
|
+
expect(described_class.total_memory_mb).to eq(65_536)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
context 'on Linux' do
|
|
137
|
+
before do
|
|
138
|
+
allow(described_class).to receive(:platform).and_return(:linux)
|
|
139
|
+
meminfo = "MemTotal: 65536000 kB\nMemFree: 32000000 kB\nInactive: 8000000 kB\n"
|
|
140
|
+
allow(File).to receive(:read).with('/proc/meminfo').and_return(meminfo)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'returns total memory in MB' do
|
|
144
|
+
described_class.refresh!
|
|
145
|
+
expect(described_class.total_memory_mb).to eq(64_000)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
context 'on unknown platform' do
|
|
150
|
+
before { allow(described_class).to receive(:platform).and_return(:unknown) }
|
|
151
|
+
|
|
152
|
+
it 'returns nil' do
|
|
153
|
+
described_class.refresh!
|
|
154
|
+
expect(described_class.total_memory_mb).to be_nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe '.available_memory_mb' do
|
|
160
|
+
context 'on macOS' do
|
|
161
|
+
before do
|
|
162
|
+
allow(described_class).to receive(:platform).and_return(:macos)
|
|
163
|
+
allow(described_class).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
|
|
164
|
+
vm_stat_output = <<~VMSTAT
|
|
165
|
+
Mach Virtual Memory Statistics: (page size of 16384 bytes)
|
|
166
|
+
Pages free: 500000.
|
|
167
|
+
Pages active: 200000.
|
|
168
|
+
Pages inactive: 300000.
|
|
169
|
+
Pages speculative: 50000.
|
|
170
|
+
Pages throttled: 0.
|
|
171
|
+
Pages wired down: 100000.
|
|
172
|
+
Pages purgeable: 10000.
|
|
173
|
+
VMSTAT
|
|
174
|
+
allow(described_class).to receive(:`).with('vm_stat').and_return(vm_stat_output)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'returns free + inactive pages in MB (excludes disk cache)' do
|
|
178
|
+
described_class.refresh!
|
|
179
|
+
# (500000 + 300000) pages * 16384 bytes / 1024 / 1024 = 12500 MB
|
|
180
|
+
expect(described_class.available_memory_mb).to eq(12_500)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
context 'on Linux' do
|
|
185
|
+
before do
|
|
186
|
+
allow(described_class).to receive(:platform).and_return(:linux)
|
|
187
|
+
meminfo = "MemTotal: 65536000 kB\nMemFree: 32000000 kB\nInactive: 8000000 kB\n"
|
|
188
|
+
allow(File).to receive(:read).with('/proc/meminfo').and_return(meminfo)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'returns MemFree + Inactive in MB' do
|
|
192
|
+
described_class.refresh!
|
|
193
|
+
# (32000000 + 8000000) kB / 1024 = 39062 MB
|
|
194
|
+
expect(described_class.available_memory_mb).to eq(39_062)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe '.memory_pressure?' do
|
|
200
|
+
before do
|
|
201
|
+
allow(described_class).to receive(:platform).and_return(:macos)
|
|
202
|
+
allow(described_class).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
context 'when available memory is below floor' do
|
|
206
|
+
before do
|
|
207
|
+
vm_stat_output = <<~VMSTAT
|
|
208
|
+
Mach Virtual Memory Statistics: (page size of 16384 bytes)
|
|
209
|
+
Pages free: 50000.
|
|
210
|
+
Pages active: 200000.
|
|
211
|
+
Pages inactive: 50000.
|
|
212
|
+
Pages speculative: 0.
|
|
213
|
+
VMSTAT
|
|
214
|
+
allow(described_class).to receive(:`).with('vm_stat').and_return(vm_stat_output)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'returns true' do
|
|
218
|
+
described_class.refresh!
|
|
219
|
+
# (50000 + 50000) * 16384 / 1024 / 1024 = 1562 MB < 2048 default floor
|
|
220
|
+
expect(described_class.memory_pressure?).to be true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
context 'when available memory is above floor' do
|
|
225
|
+
before do
|
|
226
|
+
vm_stat_output = <<~VMSTAT
|
|
227
|
+
Mach Virtual Memory Statistics: (page size of 16384 bytes)
|
|
228
|
+
Pages free: 500000.
|
|
229
|
+
Pages active: 200000.
|
|
230
|
+
Pages inactive: 300000.
|
|
231
|
+
Pages speculative: 0.
|
|
232
|
+
VMSTAT
|
|
233
|
+
allow(described_class).to receive(:`).with('vm_stat').and_return(vm_stat_output)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'returns false' do
|
|
237
|
+
described_class.refresh!
|
|
238
|
+
expect(described_class.memory_pressure?).to be false
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe '.stale?' do
|
|
244
|
+
it 'returns true when never refreshed' do
|
|
245
|
+
expect(described_class.stale?).to be true
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it 'returns false immediately after refresh' do
|
|
249
|
+
allow(described_class).to receive(:platform).and_return(:unknown)
|
|
250
|
+
described_class.refresh!
|
|
251
|
+
expect(described_class.stale?).to be false
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
describe '.reset!' do
|
|
256
|
+
it 'clears cached data' do
|
|
257
|
+
allow(described_class).to receive(:platform).and_return(:unknown)
|
|
258
|
+
described_class.refresh!
|
|
259
|
+
described_class.reset!
|
|
260
|
+
expect(described_class.stale?).to be true
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Step 2: Run test to verify it fails**
|
|
267
|
+
|
|
268
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/system_spec.rb -v`
|
|
269
|
+
Expected: FAIL — file not found / constant not defined
|
|
270
|
+
|
|
271
|
+
**Step 3: Write minimal implementation**
|
|
272
|
+
|
|
273
|
+
Create `lib/legion/llm/discovery/system.rb`:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
# frozen_string_literal: true
|
|
277
|
+
|
|
278
|
+
module Legion
|
|
279
|
+
module LLM
|
|
280
|
+
module Discovery
|
|
281
|
+
module System
|
|
282
|
+
class << self
|
|
283
|
+
def total_memory_mb
|
|
284
|
+
ensure_fresh
|
|
285
|
+
@total_memory_mb
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def available_memory_mb
|
|
289
|
+
ensure_fresh
|
|
290
|
+
@available_memory_mb
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def memory_pressure?
|
|
294
|
+
avail = available_memory_mb
|
|
295
|
+
return false if avail.nil?
|
|
296
|
+
|
|
297
|
+
floor = discovery_settings[:memory_floor_mb] || 2048
|
|
298
|
+
avail < floor
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def platform
|
|
302
|
+
@platform ||= detect_platform
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def refresh!
|
|
306
|
+
case platform
|
|
307
|
+
when :macos then refresh_macos
|
|
308
|
+
when :linux then refresh_linux
|
|
309
|
+
else
|
|
310
|
+
@total_memory_mb = nil
|
|
311
|
+
@available_memory_mb = nil
|
|
312
|
+
end
|
|
313
|
+
@last_refreshed_at = Time.now
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def reset!
|
|
317
|
+
@total_memory_mb = nil
|
|
318
|
+
@available_memory_mb = nil
|
|
319
|
+
@last_refreshed_at = nil
|
|
320
|
+
@platform = nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def stale?
|
|
324
|
+
return true if @last_refreshed_at.nil?
|
|
325
|
+
|
|
326
|
+
ttl = discovery_settings[:refresh_seconds] || 60
|
|
327
|
+
Time.now - @last_refreshed_at > ttl
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
def ensure_fresh
|
|
333
|
+
refresh! if stale?
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def detect_platform
|
|
337
|
+
case RbConfig::CONFIG['host_os']
|
|
338
|
+
when /darwin/i then :macos
|
|
339
|
+
when /linux/i then :linux
|
|
340
|
+
else :unknown
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def refresh_macos
|
|
345
|
+
raw_total = `sysctl -n hw.memsize`.strip.to_i
|
|
346
|
+
@total_memory_mb = raw_total / 1024 / 1024
|
|
347
|
+
|
|
348
|
+
vm_output = `vm_stat`
|
|
349
|
+
page_size = vm_output[/page size of (\d+) bytes/, 1]&.to_i || 16_384
|
|
350
|
+
free = vm_output[/Pages free:\s+(\d+)/, 1]&.to_i || 0
|
|
351
|
+
inactive = vm_output[/Pages inactive:\s+(\d+)/, 1]&.to_i || 0
|
|
352
|
+
|
|
353
|
+
@available_memory_mb = (free + inactive) * page_size / 1024 / 1024
|
|
354
|
+
rescue StandardError
|
|
355
|
+
@total_memory_mb = nil
|
|
356
|
+
@available_memory_mb = nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def refresh_linux
|
|
360
|
+
meminfo = File.read('/proc/meminfo')
|
|
361
|
+
total_kb = meminfo[/MemTotal:\s+(\d+)/, 1]&.to_i || 0
|
|
362
|
+
free_kb = meminfo[/MemFree:\s+(\d+)/, 1]&.to_i || 0
|
|
363
|
+
inactive_kb = meminfo[/Inactive:\s+(\d+)/, 1]&.to_i || 0
|
|
364
|
+
|
|
365
|
+
@total_memory_mb = total_kb / 1024
|
|
366
|
+
@available_memory_mb = (free_kb + inactive_kb) / 1024
|
|
367
|
+
rescue StandardError
|
|
368
|
+
@total_memory_mb = nil
|
|
369
|
+
@available_memory_mb = nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def discovery_settings
|
|
373
|
+
return {} unless Legion.const_defined?('Settings')
|
|
374
|
+
|
|
375
|
+
Legion::Settings[:llm][:discovery] || {}
|
|
376
|
+
rescue StandardError
|
|
377
|
+
{}
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Step 4: Run test to verify it passes**
|
|
387
|
+
|
|
388
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/system_spec.rb -v`
|
|
389
|
+
Expected: All examples pass
|
|
390
|
+
|
|
391
|
+
**Step 5: Run full suite**
|
|
392
|
+
|
|
393
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
|
|
394
|
+
Expected: All tests pass
|
|
395
|
+
|
|
396
|
+
**Step 6: Commit**
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
cd /Users/miverso2/rubymine/legion/legion-llm
|
|
400
|
+
git add lib/legion/llm/discovery/system.rb spec/legion/llm/discovery/system_spec.rb
|
|
401
|
+
git commit -m "add Discovery::System for OS memory introspection"
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
### Task 3: Implement Discovery::Ollama
|
|
407
|
+
|
|
408
|
+
**Files:**
|
|
409
|
+
- Create: `lib/legion/llm/discovery/ollama.rb`
|
|
410
|
+
- Create: `spec/legion/llm/discovery/ollama_spec.rb`
|
|
411
|
+
|
|
412
|
+
**Step 1: Write the failing test**
|
|
413
|
+
|
|
414
|
+
Create `spec/legion/llm/discovery/ollama_spec.rb`:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
# frozen_string_literal: true
|
|
418
|
+
|
|
419
|
+
require 'spec_helper'
|
|
420
|
+
require 'legion/llm/discovery/ollama'
|
|
421
|
+
|
|
422
|
+
RSpec.describe Legion::LLM::Discovery::Ollama do
|
|
423
|
+
before { described_class.reset! }
|
|
424
|
+
|
|
425
|
+
let(:tags_response) do
|
|
426
|
+
{
|
|
427
|
+
'models' => [
|
|
428
|
+
{ 'name' => 'llama3.1:8b', 'size' => 4_700_000_000, 'digest' => 'sha256:abc' },
|
|
429
|
+
{ 'name' => 'qwen2.5:32b', 'size' => 20_000_000_000, 'digest' => 'sha256:def' },
|
|
430
|
+
{ 'name' => 'nomic-embed-text', 'size' => 274_000_000, 'digest' => 'sha256:ghi' }
|
|
431
|
+
]
|
|
432
|
+
}
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
before do
|
|
436
|
+
stub_request(:get, 'http://localhost:11434/api/tags')
|
|
437
|
+
.to_return(status: 200, body: tags_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
describe '.models' do
|
|
441
|
+
it 'returns array of model hashes from Ollama' do
|
|
442
|
+
expect(described_class.models).to be_an(Array)
|
|
443
|
+
expect(described_class.models.size).to eq(3)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
it 'includes model name and size' do
|
|
447
|
+
model = described_class.models.first
|
|
448
|
+
expect(model['name']).to eq('llama3.1:8b')
|
|
449
|
+
expect(model['size']).to eq(4_700_000_000)
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
describe '.model_names' do
|
|
454
|
+
it 'returns array of model name strings' do
|
|
455
|
+
expect(described_class.model_names).to eq(['llama3.1:8b', 'qwen2.5:32b', 'nomic-embed-text'])
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
describe '.model_available?' do
|
|
460
|
+
it 'returns true for a pulled model' do
|
|
461
|
+
expect(described_class.model_available?('llama3.1:8b')).to be true
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
it 'returns false for a model not pulled' do
|
|
465
|
+
expect(described_class.model_available?('nonexistent:latest')).to be false
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
describe '.model_size' do
|
|
470
|
+
it 'returns size in bytes for a known model' do
|
|
471
|
+
expect(described_class.model_size('qwen2.5:32b')).to eq(20_000_000_000)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
it 'returns nil for an unknown model' do
|
|
475
|
+
expect(described_class.model_size('nonexistent:latest')).to be_nil
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
describe 'when Ollama is not running' do
|
|
480
|
+
before do
|
|
481
|
+
described_class.reset!
|
|
482
|
+
stub_request(:get, 'http://localhost:11434/api/tags').to_timeout
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
it 'returns empty array for models' do
|
|
486
|
+
expect(described_class.models).to eq([])
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
it 'returns false for model_available?' do
|
|
490
|
+
expect(described_class.model_available?('llama3.1:8b')).to be false
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
describe 'when Ollama returns non-200' do
|
|
495
|
+
before do
|
|
496
|
+
described_class.reset!
|
|
497
|
+
stub_request(:get, 'http://localhost:11434/api/tags').to_return(status: 500, body: 'error')
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
it 'returns empty array for models' do
|
|
501
|
+
expect(described_class.models).to eq([])
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
describe '.stale?' do
|
|
506
|
+
it 'returns true when never refreshed' do
|
|
507
|
+
expect(described_class.stale?).to be true
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
it 'returns false immediately after refresh' do
|
|
511
|
+
described_class.refresh!
|
|
512
|
+
expect(described_class.stale?).to be false
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
describe '.reset!' do
|
|
517
|
+
it 'clears cached models' do
|
|
518
|
+
described_class.refresh!
|
|
519
|
+
expect(described_class.models.size).to eq(3)
|
|
520
|
+
described_class.reset!
|
|
521
|
+
# After reset, next access triggers fresh fetch
|
|
522
|
+
stub_request(:get, 'http://localhost:11434/api/tags')
|
|
523
|
+
.to_return(status: 200, body: { 'models' => [] }.to_json)
|
|
524
|
+
expect(described_class.models).to eq([])
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
describe 'TTL-based staleness' do
|
|
529
|
+
it 'uses refresh_seconds from settings' do
|
|
530
|
+
Legion::Settings[:llm][:discovery] = { enabled: true, refresh_seconds: 10 }
|
|
531
|
+
described_class.refresh!
|
|
532
|
+
expect(described_class.stale?).to be false
|
|
533
|
+
|
|
534
|
+
# Simulate time passing
|
|
535
|
+
described_class.instance_variable_set(:@last_refreshed_at, Time.now - 11)
|
|
536
|
+
expect(described_class.stale?).to be true
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
describe 'custom base_url' do
|
|
541
|
+
before do
|
|
542
|
+
described_class.reset!
|
|
543
|
+
Legion::Settings[:llm][:providers][:ollama][:base_url] = 'http://gpu-server:11434'
|
|
544
|
+
stub_request(:get, 'http://gpu-server:11434/api/tags')
|
|
545
|
+
.to_return(status: 200, body: tags_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
it 'queries the configured base_url' do
|
|
549
|
+
described_class.refresh!
|
|
550
|
+
expect(described_class.model_names).to eq(['llama3.1:8b', 'qwen2.5:32b', 'nomic-embed-text'])
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
**Step 2: Run test to verify it fails**
|
|
557
|
+
|
|
558
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/ollama_spec.rb -v`
|
|
559
|
+
Expected: FAIL — constant not defined
|
|
560
|
+
|
|
561
|
+
**Step 3: Write minimal implementation**
|
|
562
|
+
|
|
563
|
+
Create `lib/legion/llm/discovery/ollama.rb`:
|
|
564
|
+
|
|
565
|
+
```ruby
|
|
566
|
+
# frozen_string_literal: true
|
|
567
|
+
|
|
568
|
+
require 'faraday'
|
|
569
|
+
require 'json'
|
|
570
|
+
|
|
571
|
+
module Legion
|
|
572
|
+
module LLM
|
|
573
|
+
module Discovery
|
|
574
|
+
module Ollama
|
|
575
|
+
class << self
|
|
576
|
+
def models
|
|
577
|
+
ensure_fresh
|
|
578
|
+
@models || []
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def model_names
|
|
582
|
+
models.map { |m| m['name'] }
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def model_available?(name)
|
|
586
|
+
model_names.include?(name)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def model_size(name)
|
|
590
|
+
models.find { |m| m['name'] == name }&.dig('size')
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def refresh!
|
|
594
|
+
response = connection.get('/api/tags')
|
|
595
|
+
if response.success?
|
|
596
|
+
parsed = ::JSON.parse(response.body)
|
|
597
|
+
@models = parsed['models'] || []
|
|
598
|
+
else
|
|
599
|
+
@models = [] unless @models
|
|
600
|
+
end
|
|
601
|
+
rescue StandardError
|
|
602
|
+
@models ||= []
|
|
603
|
+
ensure
|
|
604
|
+
@last_refreshed_at = Time.now
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def reset!
|
|
608
|
+
@models = nil
|
|
609
|
+
@last_refreshed_at = nil
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def stale?
|
|
613
|
+
return true if @last_refreshed_at.nil?
|
|
614
|
+
|
|
615
|
+
ttl = discovery_settings[:refresh_seconds] || 60
|
|
616
|
+
Time.now - @last_refreshed_at > ttl
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
private
|
|
620
|
+
|
|
621
|
+
def ensure_fresh
|
|
622
|
+
refresh! if stale?
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def connection
|
|
626
|
+
base = ollama_base_url
|
|
627
|
+
Faraday.new(url: base) do |f|
|
|
628
|
+
f.options.timeout = 2
|
|
629
|
+
f.options.open_timeout = 2
|
|
630
|
+
f.adapter Faraday.default_adapter
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def ollama_base_url
|
|
635
|
+
return 'http://localhost:11434' unless Legion.const_defined?('Settings')
|
|
636
|
+
|
|
637
|
+
Legion::Settings[:llm].dig(:providers, :ollama, :base_url) || 'http://localhost:11434'
|
|
638
|
+
rescue StandardError
|
|
639
|
+
'http://localhost:11434'
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def discovery_settings
|
|
643
|
+
return {} unless Legion.const_defined?('Settings')
|
|
644
|
+
|
|
645
|
+
Legion::Settings[:llm][:discovery] || {}
|
|
646
|
+
rescue StandardError
|
|
647
|
+
{}
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Step 4: Run test to verify it passes**
|
|
657
|
+
|
|
658
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/ollama_spec.rb -v`
|
|
659
|
+
Expected: All examples pass
|
|
660
|
+
|
|
661
|
+
**Step 5: Run full suite**
|
|
662
|
+
|
|
663
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
|
|
664
|
+
Expected: All tests pass
|
|
665
|
+
|
|
666
|
+
**Step 6: Commit**
|
|
667
|
+
|
|
668
|
+
```bash
|
|
669
|
+
cd /Users/miverso2/rubymine/legion/legion-llm
|
|
670
|
+
git add lib/legion/llm/discovery/ollama.rb spec/legion/llm/discovery/ollama_spec.rb
|
|
671
|
+
git commit -m "add Discovery::Ollama for model tag introspection"
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
### Task 4: Integrate discovery into Router.select_candidates
|
|
677
|
+
|
|
678
|
+
**Files:**
|
|
679
|
+
- Modify: `lib/legion/llm/router.rb:1-8` (add require) and `lib/legion/llm/router.rb:88-105` (add filter step)
|
|
680
|
+
- Create: `spec/legion/llm/discovery/router_integration_spec.rb`
|
|
681
|
+
|
|
682
|
+
**Step 1: Write the failing test**
|
|
683
|
+
|
|
684
|
+
Create `spec/legion/llm/discovery/router_integration_spec.rb`:
|
|
685
|
+
|
|
686
|
+
```ruby
|
|
687
|
+
# frozen_string_literal: true
|
|
688
|
+
|
|
689
|
+
require 'spec_helper'
|
|
690
|
+
require 'legion/llm/discovery/ollama'
|
|
691
|
+
require 'legion/llm/discovery/system'
|
|
692
|
+
|
|
693
|
+
RSpec.describe 'Router discovery integration' do
|
|
694
|
+
let(:rules_with_local) do
|
|
695
|
+
[
|
|
696
|
+
{
|
|
697
|
+
name: 'local-small',
|
|
698
|
+
when: { capability: 'basic' },
|
|
699
|
+
then: { tier: 'local', provider: 'ollama', model: 'llama3.1:8b' },
|
|
700
|
+
priority: 80,
|
|
701
|
+
cost_multiplier: 0.1
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
name: 'cloud-fallback',
|
|
705
|
+
when: { capability: 'basic' },
|
|
706
|
+
then: { tier: 'cloud', provider: 'bedrock', model: 'claude-sonnet-4-6' },
|
|
707
|
+
priority: 20,
|
|
708
|
+
cost_multiplier: 1.0
|
|
709
|
+
}
|
|
710
|
+
]
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
before do
|
|
714
|
+
Legion::LLM::Router.reset!
|
|
715
|
+
Legion::LLM::Discovery::Ollama.reset!
|
|
716
|
+
Legion::LLM::Discovery::System.reset!
|
|
717
|
+
allow(Legion::LLM::Router).to receive(:tier_available?).and_return(true)
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def configure_routing(rules:)
|
|
721
|
+
Legion::Settings[:llm] = Legion::Settings[:llm].merge(
|
|
722
|
+
routing: {
|
|
723
|
+
enabled: true,
|
|
724
|
+
rules: rules,
|
|
725
|
+
default_intent: { privacy: 'normal', capability: 'basic' }
|
|
726
|
+
},
|
|
727
|
+
discovery: { enabled: true, refresh_seconds: 60, memory_floor_mb: 2048 }
|
|
728
|
+
)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
describe 'when Ollama model is not pulled' do
|
|
732
|
+
before do
|
|
733
|
+
configure_routing(rules: rules_with_local)
|
|
734
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(false)
|
|
735
|
+
allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(32_000)
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
it 'skips the local rule and falls through to cloud' do
|
|
739
|
+
result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
|
|
740
|
+
expect(result).not_to be_nil
|
|
741
|
+
expect(result.rule).to eq('cloud-fallback')
|
|
742
|
+
expect(result.tier).to eq(:cloud)
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
describe 'when Ollama model is pulled and fits in memory' do
|
|
747
|
+
before do
|
|
748
|
+
configure_routing(rules: rules_with_local)
|
|
749
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(true)
|
|
750
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_size).with('llama3.1:8b').and_return(4_700_000_000)
|
|
751
|
+
allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(32_000)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
it 'selects the local rule' do
|
|
755
|
+
result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
|
|
756
|
+
expect(result).not_to be_nil
|
|
757
|
+
expect(result.rule).to eq('local-small')
|
|
758
|
+
expect(result.tier).to eq(:local)
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
describe 'when model is pulled but does not fit in memory' do
|
|
763
|
+
before do
|
|
764
|
+
configure_routing(rules: rules_with_local)
|
|
765
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(true)
|
|
766
|
+
# Model is 4.7 GB, but only 3 GB available after floor
|
|
767
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_size).with('llama3.1:8b').and_return(4_700_000_000)
|
|
768
|
+
allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(5_000)
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
it 'skips the local rule (insufficient memory after floor)' do
|
|
772
|
+
# 5000 MB available - 2048 MB floor = 2952 MB usable, model needs ~4482 MB
|
|
773
|
+
result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
|
|
774
|
+
expect(result).not_to be_nil
|
|
775
|
+
expect(result.rule).to eq('cloud-fallback')
|
|
776
|
+
end
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
describe 'when discovery is disabled' do
|
|
780
|
+
before do
|
|
781
|
+
Legion::Settings[:llm] = Legion::Settings[:llm].merge(
|
|
782
|
+
routing: {
|
|
783
|
+
enabled: true,
|
|
784
|
+
rules: rules_with_local,
|
|
785
|
+
default_intent: { privacy: 'normal', capability: 'basic' }
|
|
786
|
+
},
|
|
787
|
+
discovery: { enabled: false }
|
|
788
|
+
)
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
it 'does not filter by discovery — local rule passes through' do
|
|
792
|
+
result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
|
|
793
|
+
expect(result).not_to be_nil
|
|
794
|
+
expect(result.rule).to eq('local-small')
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
describe 'when system memory is nil (unknown platform)' do
|
|
799
|
+
before do
|
|
800
|
+
configure_routing(rules: rules_with_local)
|
|
801
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).with('llama3.1:8b').and_return(true)
|
|
802
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_size).with('llama3.1:8b').and_return(4_700_000_000)
|
|
803
|
+
allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(nil)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
it 'bypasses memory check (permissive) and selects local rule' do
|
|
807
|
+
result = Legion::LLM::Router.resolve(intent: { capability: 'basic' })
|
|
808
|
+
expect(result).not_to be_nil
|
|
809
|
+
expect(result.rule).to eq('local-small')
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
describe 'non-Ollama rules are unaffected' do
|
|
814
|
+
let(:cloud_only_rules) do
|
|
815
|
+
[
|
|
816
|
+
{
|
|
817
|
+
name: 'cloud-reasoning',
|
|
818
|
+
when: { capability: 'reasoning' },
|
|
819
|
+
then: { tier: 'cloud', provider: 'bedrock', model: 'claude-sonnet-4-6' },
|
|
820
|
+
priority: 50,
|
|
821
|
+
cost_multiplier: 1.0
|
|
822
|
+
}
|
|
823
|
+
]
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
before { configure_routing(rules: cloud_only_rules) }
|
|
827
|
+
|
|
828
|
+
it 'does not check discovery for cloud rules' do
|
|
829
|
+
expect(Legion::LLM::Discovery::Ollama).not_to receive(:model_available?)
|
|
830
|
+
Legion::LLM::Router.resolve(intent: { capability: 'reasoning' })
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
end
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
**Step 2: Run test to verify it fails**
|
|
837
|
+
|
|
838
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/router_integration_spec.rb -v`
|
|
839
|
+
Expected: FAIL — discovery filtering not implemented
|
|
840
|
+
|
|
841
|
+
**Step 3: Write minimal implementation**
|
|
842
|
+
|
|
843
|
+
Edit `lib/legion/llm/router.rb`:
|
|
844
|
+
|
|
845
|
+
At the top, add requires after the existing ones (line 4):
|
|
846
|
+
|
|
847
|
+
```ruby
|
|
848
|
+
require_relative 'discovery/ollama'
|
|
849
|
+
require_relative 'discovery/system'
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
In `select_candidates` method (currently lines 88-105), add step 4.5 between step 4 and step 5. Replace the method with:
|
|
853
|
+
|
|
854
|
+
```ruby
|
|
855
|
+
def select_candidates(rules, intent)
|
|
856
|
+
# 1. Collect constraints from constraint rules that match the intent
|
|
857
|
+
constraints = rules
|
|
858
|
+
.select { |r| r.constraint && r.matches_intent?(intent) }
|
|
859
|
+
.map(&:constraint)
|
|
860
|
+
|
|
861
|
+
# 2. Filter by intent match
|
|
862
|
+
matched = rules.select { |r| r.matches_intent?(intent) }
|
|
863
|
+
|
|
864
|
+
# 3. Filter by schedule
|
|
865
|
+
scheduled = matched.select(&:within_schedule?)
|
|
866
|
+
|
|
867
|
+
# 4. Reject rules excluded by active constraints
|
|
868
|
+
unconstrained = scheduled.reject { |r| excluded_by_constraint?(r, constraints) }
|
|
869
|
+
|
|
870
|
+
# 4.5 Reject Ollama rules where model is not pulled or doesn't fit
|
|
871
|
+
discovered = unconstrained.reject { |r| excluded_by_discovery?(r) }
|
|
872
|
+
|
|
873
|
+
# 5. Filter by tier availability
|
|
874
|
+
discovered.select { |r| tier_available?(r.target[:tier] || r.target['tier']) }
|
|
875
|
+
end
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
Add the new private method `excluded_by_discovery?`:
|
|
879
|
+
|
|
880
|
+
```ruby
|
|
881
|
+
def excluded_by_discovery?(rule)
|
|
882
|
+
return false unless discovery_enabled?
|
|
883
|
+
|
|
884
|
+
tier = (rule.target[:tier] || rule.target['tier'])&.to_sym
|
|
885
|
+
provider = (rule.target[:provider] || rule.target['provider'])&.to_sym
|
|
886
|
+
model = rule.target[:model] || rule.target['model']
|
|
887
|
+
|
|
888
|
+
return false unless tier == :local && provider == :ollama && model
|
|
889
|
+
|
|
890
|
+
return true unless Discovery::Ollama.model_available?(model)
|
|
891
|
+
|
|
892
|
+
model_bytes = Discovery::Ollama.model_size(model)
|
|
893
|
+
available = Discovery::System.available_memory_mb
|
|
894
|
+
return false if model_bytes.nil? || available.nil?
|
|
895
|
+
|
|
896
|
+
floor = discovery_settings[:memory_floor_mb] || 2048
|
|
897
|
+
model_mb = model_bytes / 1024 / 1024
|
|
898
|
+
model_mb > (available - floor)
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
def discovery_enabled?
|
|
902
|
+
ds = discovery_settings
|
|
903
|
+
ds.fetch(:enabled, true)
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
def discovery_settings
|
|
907
|
+
llm = Legion::Settings[:llm]
|
|
908
|
+
return {} unless llm.is_a?(Hash)
|
|
909
|
+
|
|
910
|
+
(llm[:discovery] || {}).transform_keys(&:to_sym)
|
|
911
|
+
rescue StandardError
|
|
912
|
+
{}
|
|
913
|
+
end
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
**Step 4: Run test to verify it passes**
|
|
917
|
+
|
|
918
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/router_integration_spec.rb -v`
|
|
919
|
+
Expected: All examples pass
|
|
920
|
+
|
|
921
|
+
**Step 5: Run full suite**
|
|
922
|
+
|
|
923
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
|
|
924
|
+
Expected: All tests pass (existing router_spec stubs `tier_available?` but doesn't stub discovery — discovery checks should pass through because the rules in existing tests won't trigger the Ollama check when discovery module returns empty models, causing `model_available?` to return false. **Important:** The existing `router_spec.rb` stubs `tier_available?` but the new discovery filter will now reject local Ollama rules unless we also handle this. Existing specs use `allow(described_class).to receive(:tier_available?).and_return(true)` — we need to also stub discovery for existing specs. Add to `router_spec.rb` `before` block: `allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).and_return(true)` and `allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(65_536)`)
|
|
925
|
+
|
|
926
|
+
**Step 6: Fix existing router_spec.rb**
|
|
927
|
+
|
|
928
|
+
Edit `spec/legion/llm/router_spec.rb` — add to the existing `before` block (line 46-49):
|
|
929
|
+
|
|
930
|
+
```ruby
|
|
931
|
+
before do
|
|
932
|
+
described_class.reset!
|
|
933
|
+
allow(described_class).to receive(:tier_available?).and_return(true)
|
|
934
|
+
allow(Legion::LLM::Discovery::Ollama).to receive(:model_available?).and_return(true)
|
|
935
|
+
allow(Legion::LLM::Discovery::System).to receive(:available_memory_mb).and_return(65_536)
|
|
936
|
+
end
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
**Step 7: Run full suite again**
|
|
940
|
+
|
|
941
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
|
|
942
|
+
Expected: All tests pass
|
|
943
|
+
|
|
944
|
+
**Step 8: Commit**
|
|
945
|
+
|
|
946
|
+
```bash
|
|
947
|
+
cd /Users/miverso2/rubymine/legion/legion-llm
|
|
948
|
+
git add lib/legion/llm/router.rb lib/legion/llm/discovery/ollama.rb lib/legion/llm/discovery/system.rb \
|
|
949
|
+
spec/legion/llm/discovery/router_integration_spec.rb spec/legion/llm/router_spec.rb
|
|
950
|
+
git commit -m "integrate discovery filtering into router pipeline"
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
---
|
|
954
|
+
|
|
955
|
+
### Task 5: Add startup discovery logging
|
|
956
|
+
|
|
957
|
+
**Files:**
|
|
958
|
+
- Modify: `lib/legion/llm.rb:15-25` (add discovery warmup to `start`)
|
|
959
|
+
|
|
960
|
+
**Step 1: Write the failing test**
|
|
961
|
+
|
|
962
|
+
Add to `spec/legion/llm_spec.rb` (or create a focused spec — check existing file first to see the pattern, then add a new describe block). Create `spec/legion/llm/discovery/startup_spec.rb`:
|
|
963
|
+
|
|
964
|
+
```ruby
|
|
965
|
+
# frozen_string_literal: true
|
|
966
|
+
|
|
967
|
+
require 'spec_helper'
|
|
968
|
+
require 'legion/llm/discovery/ollama'
|
|
969
|
+
require 'legion/llm/discovery/system'
|
|
970
|
+
|
|
971
|
+
RSpec.describe 'LLM startup discovery' do
|
|
972
|
+
before do
|
|
973
|
+
Legion::LLM::Discovery::Ollama.reset!
|
|
974
|
+
Legion::LLM::Discovery::System.reset!
|
|
975
|
+
allow(RubyLLM).to receive(:configure)
|
|
976
|
+
allow(RubyLLM).to receive(:chat).and_return(double(ask: 'pong'))
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
context 'when Ollama provider is enabled' do
|
|
980
|
+
before do
|
|
981
|
+
Legion::Settings[:llm][:providers][:ollama][:enabled] = true
|
|
982
|
+
stub_request(:get, 'http://localhost:11434/api/tags')
|
|
983
|
+
.to_return(status: 200, body: { 'models' => [{ 'name' => 'llama3:latest', 'size' => 4_000_000_000 }] }.to_json)
|
|
984
|
+
allow(Legion::LLM::Discovery::System).to receive(:platform).and_return(:macos)
|
|
985
|
+
allow(Legion::LLM::Discovery::System).to receive(:`).with('sysctl -n hw.memsize').and_return("68719476736\n")
|
|
986
|
+
allow(Legion::LLM::Discovery::System).to receive(:`).with('vm_stat').and_return(
|
|
987
|
+
"Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 500000.\nPages inactive: 300000.\n"
|
|
988
|
+
)
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
it 'refreshes discovery caches during start' do
|
|
992
|
+
expect(Legion::LLM::Discovery::Ollama).to receive(:refresh!)
|
|
993
|
+
expect(Legion::LLM::Discovery::System).to receive(:refresh!)
|
|
994
|
+
Legion::LLM.start
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
it 'logs discovered models' do
|
|
998
|
+
expect(Legion::Logging).to receive(:info).with(/Ollama: 1 model/).at_least(:once)
|
|
999
|
+
Legion::LLM.start
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
context 'when Ollama provider is disabled' do
|
|
1004
|
+
before do
|
|
1005
|
+
Legion::Settings[:llm][:providers][:ollama][:enabled] = false
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
it 'does not refresh discovery caches' do
|
|
1009
|
+
expect(Legion::LLM::Discovery::Ollama).not_to receive(:refresh!)
|
|
1010
|
+
Legion::LLM.start
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
**Step 2: Run test to verify it fails**
|
|
1017
|
+
|
|
1018
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/startup_spec.rb -v`
|
|
1019
|
+
Expected: FAIL — `refresh!` not called during start
|
|
1020
|
+
|
|
1021
|
+
**Step 3: Write minimal implementation**
|
|
1022
|
+
|
|
1023
|
+
Edit `lib/legion/llm.rb`. Add require at top (after line 8):
|
|
1024
|
+
|
|
1025
|
+
```ruby
|
|
1026
|
+
require 'legion/llm/discovery/ollama'
|
|
1027
|
+
require 'legion/llm/discovery/system'
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Modify the `start` method to call discovery between `configure_providers` and `set_defaults`:
|
|
1031
|
+
|
|
1032
|
+
```ruby
|
|
1033
|
+
def start
|
|
1034
|
+
Legion::Logging.debug 'Legion::LLM is running start'
|
|
1035
|
+
|
|
1036
|
+
configure_providers
|
|
1037
|
+
run_discovery
|
|
1038
|
+
set_defaults
|
|
1039
|
+
|
|
1040
|
+
@started = true
|
|
1041
|
+
Legion::Settings[:llm][:connected] = true
|
|
1042
|
+
Legion::Logging.info 'Legion::LLM started'
|
|
1043
|
+
ping_provider
|
|
1044
|
+
end
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
Add the private method:
|
|
1048
|
+
|
|
1049
|
+
```ruby
|
|
1050
|
+
def run_discovery
|
|
1051
|
+
return unless settings.dig(:providers, :ollama, :enabled)
|
|
1052
|
+
|
|
1053
|
+
Discovery::Ollama.refresh!
|
|
1054
|
+
Discovery::System.refresh!
|
|
1055
|
+
|
|
1056
|
+
names = Discovery::Ollama.model_names
|
|
1057
|
+
count = names.size
|
|
1058
|
+
Legion::Logging.info "Ollama: #{count} model#{'s' unless count == 1} available (#{names.join(', ')})"
|
|
1059
|
+
Legion::Logging.info "System: #{Discovery::System.total_memory_mb} MB total, " \
|
|
1060
|
+
"#{Discovery::System.available_memory_mb} MB available"
|
|
1061
|
+
rescue StandardError => e
|
|
1062
|
+
Legion::Logging.warn "Discovery failed: #{e.message}"
|
|
1063
|
+
end
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
**Step 4: Run test to verify it passes**
|
|
1067
|
+
|
|
1068
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec spec/legion/llm/discovery/startup_spec.rb -v`
|
|
1069
|
+
Expected: All examples pass
|
|
1070
|
+
|
|
1071
|
+
**Step 5: Run full suite**
|
|
1072
|
+
|
|
1073
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec`
|
|
1074
|
+
Expected: All tests pass
|
|
1075
|
+
|
|
1076
|
+
**Step 6: Commit**
|
|
1077
|
+
|
|
1078
|
+
```bash
|
|
1079
|
+
cd /Users/miverso2/rubymine/legion/legion-llm
|
|
1080
|
+
git add lib/legion/llm.rb spec/legion/llm/discovery/startup_spec.rb
|
|
1081
|
+
git commit -m "add discovery warmup and logging to LLM startup"
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
---
|
|
1085
|
+
|
|
1086
|
+
### Task 6: Run RuboCop, bump version, update docs
|
|
1087
|
+
|
|
1088
|
+
**Files:**
|
|
1089
|
+
- Modify: `lib/legion/llm/version.rb` (bump 0.2.1 -> 0.2.2)
|
|
1090
|
+
- Modify: `CHANGELOG.md`
|
|
1091
|
+
|
|
1092
|
+
**Step 1: Run RuboCop**
|
|
1093
|
+
|
|
1094
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rubocop`
|
|
1095
|
+
Expected: 0 offenses (fix any that appear)
|
|
1096
|
+
|
|
1097
|
+
**Step 2: Bump version**
|
|
1098
|
+
|
|
1099
|
+
Edit `lib/legion/llm/version.rb`:
|
|
1100
|
+
|
|
1101
|
+
```ruby
|
|
1102
|
+
VERSION = '0.2.2'
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
**Step 3: Update CHANGELOG.md**
|
|
1106
|
+
|
|
1107
|
+
Add under `## [Unreleased]`:
|
|
1108
|
+
|
|
1109
|
+
```markdown
|
|
1110
|
+
## [0.2.2]
|
|
1111
|
+
|
|
1112
|
+
### Added
|
|
1113
|
+
- `Legion::LLM::Discovery::Ollama` module — queries Ollama `/api/tags` for pulled models with TTL cache
|
|
1114
|
+
- `Legion::LLM::Discovery::System` module — queries OS memory (macOS `vm_stat`/`sysctl`, Linux `/proc/meminfo`) with TTL cache
|
|
1115
|
+
- Router step 4.5: rejects Ollama rules where model is not pulled or exceeds available memory
|
|
1116
|
+
- Discovery settings: `enabled`, `refresh_seconds`, `memory_floor_mb` under `Legion::Settings[:llm][:discovery]`
|
|
1117
|
+
- Startup discovery: logs available Ollama models and system memory when Ollama provider is enabled
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
**Step 4: Run full suite one final time**
|
|
1121
|
+
|
|
1122
|
+
Run: `cd /Users/miverso2/rubymine/legion/legion-llm && bundle exec rspec && bundle exec rubocop`
|
|
1123
|
+
Expected: All tests pass, 0 offenses
|
|
1124
|
+
|
|
1125
|
+
**Step 5: Update CLAUDE.md**
|
|
1126
|
+
|
|
1127
|
+
Add the new files to the File Map table in `CLAUDE.md`:
|
|
1128
|
+
|
|
1129
|
+
```
|
|
1130
|
+
| `lib/legion/llm/discovery/ollama.rb` | Ollama /api/tags discovery with TTL cache |
|
|
1131
|
+
| `lib/legion/llm/discovery/system.rb` | OS memory introspection (macOS + Linux) with TTL cache |
|
|
1132
|
+
| `spec/legion/llm/discovery/ollama_spec.rb` | Tests: Ollama model discovery |
|
|
1133
|
+
| `spec/legion/llm/discovery/system_spec.rb` | Tests: System memory introspection |
|
|
1134
|
+
| `spec/legion/llm/discovery/router_integration_spec.rb` | Tests: Router discovery filtering |
|
|
1135
|
+
| `spec/legion/llm/discovery/startup_spec.rb` | Tests: Startup discovery warmup |
|
|
1136
|
+
| `spec/legion/llm/discovery/settings_spec.rb` | Tests: Discovery settings defaults |
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
Update the Module Structure diagram to include Discovery.
|
|
1140
|
+
|
|
1141
|
+
**Step 6: Commit**
|
|
1142
|
+
|
|
1143
|
+
```bash
|
|
1144
|
+
cd /Users/miverso2/rubymine/legion/legion-llm
|
|
1145
|
+
git add lib/legion/llm/version.rb CHANGELOG.md CLAUDE.md
|
|
1146
|
+
git commit -m "bump to 0.2.2, update changelog and docs for discovery feature"
|
|
1147
|
+
```
|