lex-ollama 0.2.0 → 0.3.0

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: 7f82aeecea946b03e08e2dc80a8ec66504276a2bb28aaaca5528d02105328166
4
- data.tar.gz: 6b7b392634ec069693a0b0b030b1619a0a5ae1d3cbb34c2440124c1c52d15e4a
3
+ metadata.gz: 7477574f919b18b85c79afba3a1f65c8540d9eff9ca02b9e0c807b3740fed452
4
+ data.tar.gz: a5c69878c8518caf02c2e238c94243fd49c320f20bfaede00252bfdc87be5cbb
5
5
  SHA512:
6
- metadata.gz: 25b18ed44dbad71930004a3384dc897e37b245d3cdafa98122e0210a22ac5d5e6be343ab0133e519f8228f026d254e4993ee597f13368590c2fa81971329c6a8
7
- data.tar.gz: 39b9f4ed1e8a7ccd447b03770a9757c7e2cdbcea03341f07c2dda561db72e3556029152c76256e6f8b85b6d0cb858773e2d7ee9989d5559ffdc9daccfcf6b966
6
+ metadata.gz: 31566bf77244dd3cfc097531a3af1da186e8d0e7e0ec675be0b7471f8b7654649fa666d4c3d2f6bb34c46d73d29aa72a64dfa07f7beb35ae01d23c8f2bc6c797
7
+ data.tar.gz: f900e723d2db75dbdb266fcf33d01be56d7614b992be9e0b6d29345a85012be0d226ff9a2f42cb2d5a9f932cb1e641e6ecbfc033ccd3c6c98bbe4d2a7207ad13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-04-01
4
+
5
+ ### Added
6
+ - S3 model distribution via new `Runners::S3Models` module
7
+ - `list_s3_models` to discover models available in an S3 mirror
8
+ - `import_from_s3` for direct filesystem model import (works without Ollama running)
9
+ - `sync_from_s3` for Ollama API-based model import (push_blob + manifest write)
10
+ - `import_default_models` convenience method for fleet provisioning
11
+ - Runtime dependency on `lex-s3` for S3 operations
12
+ - Streaming S3 downloads via `response_target` to avoid loading multi-GB blobs into memory
13
+ - Error propagation in `sync_from_s3` — returns failure with error details when blob push fails
14
+ - SHA256 digest verification for all downloaded blobs (import and sync paths)
15
+ - Atomic blob writes via temp file + rename (prevents partial/corrupt blobs on failure)
16
+ - Cache hits verified by SHA256 digest, not just file size — corrupted local blobs are re-downloaded
17
+ - `DigestMismatchError` raised when S3 blob content does not match manifest digest
18
+
3
19
  ## [0.2.0] - 2026-03-31
4
20
 
5
21
  ### Added
data/Gemfile CHANGED
@@ -8,5 +8,6 @@ group :test do
8
8
  gem 'rspec'
9
9
  gem 'rspec_junit_formatter'
10
10
  gem 'rubocop'
11
+ gem 'rubocop-legion'
11
12
  gem 'simplecov'
12
13
  end
data/README.md CHANGED
@@ -35,6 +35,12 @@ gem install lex-ollama
35
35
  - `check_blob` - Check if a blob exists on the server (HEAD /api/blobs/:digest)
36
36
  - `push_blob` - Upload a binary blob to the server (POST /api/blobs/:digest)
37
37
 
38
+ ### S3 Model Distribution
39
+ - `list_s3_models` - List models available in an S3 mirror
40
+ - `import_from_s3` - Download model from S3 directly to Ollama's filesystem (works before Ollama starts)
41
+ - `sync_from_s3` - Download model from S3, push blobs through Ollama's API, write manifest to filesystem
42
+ - `import_default_models` - Import a list of models from S3 (fleet provisioning)
43
+
38
44
  ### Version
39
45
  - `server_version` - Retrieve the Ollama server version (GET /api/version)
40
46
 
@@ -71,6 +77,34 @@ client.chat_stream(model: 'llama3.2', messages: [{ role: 'user', content: 'Hello
71
77
  end
72
78
  ```
73
79
 
80
+ ## S3 Model Distribution
81
+
82
+ Pull models from an internal S3 mirror instead of the public Ollama registry:
83
+
84
+ ```ruby
85
+ client = Legion::Extensions::Ollama::Client.new
86
+
87
+ # List available models in S3
88
+ client.list_s3_models(bucket: 'legion', endpoint: 'https://mesh.s3api-core.optum.com')
89
+
90
+ # Import directly to filesystem (works without Ollama running)
91
+ client.import_from_s3(model: 'llama3:latest', bucket: 'legion',
92
+ endpoint: 'https://mesh.s3api-core.optum.com')
93
+
94
+ # Push through Ollama API (requires Ollama running)
95
+ client.sync_from_s3(model: 'llama3:latest', bucket: 'legion',
96
+ endpoint: 'https://mesh.s3api-core.optum.com')
97
+
98
+ # Provision fleet with default models
99
+ client.import_default_models(
100
+ default_models: %w[llama3:latest nomic-embed-text:latest],
101
+ bucket: 'legion',
102
+ endpoint: 'https://mesh.s3api-core.optum.com'
103
+ )
104
+ ```
105
+
106
+ S3 operations use [lex-s3](https://github.com/LegionIO/lex-s3). The S3 bucket should mirror the Ollama models directory structure (`manifests/` and `blobs/` under the configured prefix).
107
+
74
108
  All API calls include automatic retry with exponential backoff on connection failures and timeouts.
75
109
 
76
110
  Generate and chat responses include standardized `usage:` data:
@@ -0,0 +1,131 @@
1
+ # S3 Model Distribution for lex-ollama
2
+
3
+ ## Problem
4
+
5
+ Thousands of engineers pulling models from the public Ollama registry is wasteful and unreliable. Models should be cached in internal S3 and distributed from there. Fleet-wide model updates should be broadcast via RabbitMQ.
6
+
7
+ ## Design
8
+
9
+ ### New Runner: `Runners::S3Models`
10
+
11
+ A new runner module alongside the existing `Models` runner. Three primary methods plus one convenience method.
12
+
13
+ #### `import_from_s3` (filesystem write)
14
+
15
+ Downloads manifest + blobs from S3, writes directly to `~/.ollama/models/`.
16
+
17
+ ```ruby
18
+ import_from_s3(
19
+ model:, # e.g. "llama3:latest"
20
+ bucket:, # S3 bucket name
21
+ prefix: "ollama/models", # S3 key prefix
22
+ models_path: nil, # local Ollama models dir, defaults to ~/.ollama/models
23
+ **s3_opts # passed through to lex-s3 (endpoint:, region:, access_key_id:, etc.)
24
+ )
25
+ ```
26
+
27
+ Flow:
28
+ 1. Parse `model` into `name` + `tag` (default tag: `latest`)
29
+ 2. Download manifest from S3: `{prefix}/manifests/registry.ollama.ai/library/{name}/{tag}`
30
+ 3. Parse manifest JSON to get the list of blob digests
31
+ 4. For each blob, check if it already exists locally with matching SHA256 digest (skip if valid)
32
+ 5. Stream blob from S3 to `.tmp` file, verify SHA256, atomic rename to final path
33
+ 6. Raise `DigestMismatchError` if any blob fails verification (temp file cleaned up)
34
+ 7. Write the manifest file
35
+ 8. Return `{ result: true, model:, blobs_downloaded:, blobs_skipped:, status: 200 }`
36
+
37
+ Best for: provisioning, bootstrapping, when Ollama is not yet running.
38
+
39
+ #### `sync_from_s3` (Ollama API + filesystem manifest)
40
+
41
+ Downloads from S3, pushes blobs through Ollama's API, writes manifest to filesystem.
42
+
43
+ ```ruby
44
+ sync_from_s3(
45
+ model:,
46
+ bucket:,
47
+ prefix: "ollama/models",
48
+ host: nil, # Ollama server host
49
+ models_path: nil, # local models dir for manifest write
50
+ **s3_opts # passed to lex-s3
51
+ )
52
+ ```
53
+
54
+ Flow:
55
+ 1. Parse model, download manifest from S3
56
+ 2. For each blob digest, `check_blob` via Ollama API -- skip if already present
57
+ 3. Stream blob from S3 to tempfile, verify SHA256 digest
58
+ 4. `push_blob` to Ollama API, check return value for success
59
+ 5. If any blob fails: return `{ result: false, errors: [...], status: 500 }`
60
+ 6. Write manifest to `{models_path}/manifests/registry.ollama.ai/library/{name}/{tag}`
61
+ 7. Return `{ result: true, model:, blobs_pushed:, blobs_skipped:, status: 200 }`
62
+
63
+ Best for: when Ollama is running and you want blob validation through the API.
64
+
65
+ #### `list_s3_models`
66
+
67
+ Lists available models in the S3 mirror.
68
+
69
+ ```ruby
70
+ list_s3_models(
71
+ bucket:,
72
+ prefix: "ollama/models",
73
+ **s3_opts
74
+ )
75
+ ```
76
+
77
+ Lists manifest keys under the prefix and parses them into model name/tag pairs.
78
+
79
+ #### `import_default_models`
80
+
81
+ Convenience method that reads `default_models` from settings and calls `import_from_s3` for each.
82
+
83
+ ### Settings
84
+
85
+ ```yaml
86
+ legion:
87
+ ollama:
88
+ s3:
89
+ bucket: "legion"
90
+ prefix: "ollama/models"
91
+ endpoint: "https://mesh.s3api-core.optum.com"
92
+ region: "us-east-2"
93
+ default_models:
94
+ - "llama3:latest"
95
+ - "nomic-embed-text:latest"
96
+ models_path: null # defaults to ~/.ollama/models, respects OLLAMA_MODELS env var
97
+ ```
98
+
99
+ ### Dependency
100
+
101
+ `lex-ollama.gemspec` adds a runtime dependency on `lex-s3` (`>= 0.1`). The `S3Models` runner uses `Legion::Extensions::S3::Client` for all S3 operations.
102
+
103
+ ### Data Flow
104
+
105
+ ```
106
+ S3 (mesh.s3api-core.optum.com)
107
+ |
108
+ | HTTPS (direct, no AMQP)
109
+ v
110
+ Node: S3Models runner
111
+ |
112
+ |-- import_from_s3 --> filesystem write to ~/.ollama/models/
113
+ |-- sync_from_s3 --> Ollama HTTP API (push_blob + create_model)
114
+ ```
115
+
116
+ Fleet broadcast: publish a message to the `ollama.s3_models` queue (natural LEX runner behavior). Each node picks it up and runs the download independently from S3.
117
+
118
+ ### File Layout
119
+
120
+ ```
121
+ lib/legion/extensions/ollama/
122
+ runners/
123
+ models.rb # existing, unchanged
124
+ s3_models.rb # NEW
125
+ client.rb # updated to include Runners::S3Models
126
+
127
+ spec/legion/extensions/ollama/runners/
128
+ s3_models_spec.rb # NEW
129
+ ```
130
+
131
+ No changes to existing runner methods or the Helpers::Client module.
@@ -0,0 +1,655 @@
1
+ # S3 Model Distribution Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add S3-based model distribution to lex-ollama so fleet nodes pull models from internal S3 instead of the public Ollama registry.
6
+
7
+ **Architecture:** New `Runners::S3Models` module uses `lex-s3` to download Ollama model manifests and blobs from S3. Two pull strategies: filesystem write (fast, works offline) and Ollama API (push_blob + create_model). Fleet broadcast is free via the LEX runner queue.
8
+
9
+ **Tech Stack:** Ruby, lex-s3 (aws-sdk-s3), Faraday, Ollama REST API
10
+
11
+ **Design doc:** `docs/plans/2026-04-01-s3-model-distribution-design.md`
12
+
13
+ ---
14
+
15
+ ### Task 1: Add lex-s3 dependency
16
+
17
+ **Files:**
18
+ - Modify: `lex-ollama.gemspec:29`
19
+ - Modify: `Gemfile` (if present, for local dev)
20
+
21
+ **Step 1: Add runtime dependency to gemspec**
22
+
23
+ In `lex-ollama.gemspec`, after the existing `faraday` dependency (line 29), add:
24
+
25
+ ```ruby
26
+ spec.add_dependency 'lex-s3', '>= 0.2'
27
+ ```
28
+
29
+ **Step 2: Run bundle install**
30
+
31
+ Run: `bundle install`
32
+ Expected: resolves lex-s3 and aws-sdk-s3 successfully
33
+
34
+ **Step 3: Commit**
35
+
36
+ ```bash
37
+ git add lex-ollama.gemspec Gemfile.lock
38
+ git commit -m "add lex-s3 dependency for s3 model distribution"
39
+ ```
40
+
41
+ ---
42
+
43
+ ### Task 2: Create S3Models runner with `list_s3_models`
44
+
45
+ **Files:**
46
+ - Create: `lib/legion/extensions/ollama/runners/s3_models.rb`
47
+ - Test: `spec/legion/extensions/ollama/runners/s3_models_spec.rb`
48
+
49
+ **Step 1: Write the failing test for `list_s3_models`**
50
+
51
+ ```ruby
52
+ # spec/legion/extensions/ollama/runners/s3_models_spec.rb
53
+ # frozen_string_literal: true
54
+
55
+ require 'legion/extensions/s3/client'
56
+
57
+ RSpec.describe Legion::Extensions::Ollama::Runners::S3Models do
58
+ let(:client_instance) { Legion::Extensions::Ollama::Client.new }
59
+ let(:s3_client) { instance_double(Legion::Extensions::S3::Client) }
60
+
61
+ before do
62
+ allow(Legion::Extensions::S3::Client).to receive(:new).and_return(s3_client)
63
+ end
64
+
65
+ describe '#list_s3_models' do
66
+ it 'lists models from S3 manifest keys' do
67
+ allow(s3_client).to receive(:list_objects).with(
68
+ bucket: 'legion',
69
+ prefix: 'ollama/models/manifests/registry.ollama.ai/library/',
70
+ max_keys: 1000
71
+ ).and_return({
72
+ objects: [
73
+ { key: 'ollama/models/manifests/registry.ollama.ai/library/llama3/latest', size: 512, last_modified: '2026-04-01' },
74
+ { key: 'ollama/models/manifests/registry.ollama.ai/library/nomic-embed-text/latest', size: 256, last_modified: '2026-04-01' }
75
+ ],
76
+ count: 2
77
+ })
78
+
79
+ result = client_instance.list_s3_models(bucket: 'legion', prefix: 'ollama/models')
80
+ expect(result[:models]).to eq([
81
+ { name: 'llama3', tag: 'latest' },
82
+ { name: 'nomic-embed-text', tag: 'latest' }
83
+ ])
84
+ expect(result[:status]).to eq(200)
85
+ end
86
+
87
+ it 'returns empty list when no models exist' do
88
+ allow(s3_client).to receive(:list_objects).and_return({ objects: [], count: 0 })
89
+
90
+ result = client_instance.list_s3_models(bucket: 'legion', prefix: 'ollama/models')
91
+ expect(result[:models]).to eq([])
92
+ end
93
+ end
94
+ end
95
+ ```
96
+
97
+ **Step 2: Run test to verify it fails**
98
+
99
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
100
+ Expected: FAIL — `NoMethodError: undefined method 'list_s3_models'`
101
+
102
+ **Step 3: Write the runner module**
103
+
104
+ ```ruby
105
+ # lib/legion/extensions/ollama/runners/s3_models.rb
106
+ # frozen_string_literal: true
107
+
108
+ require 'legion/extensions/s3/client'
109
+ require 'legion/extensions/ollama/helpers/client'
110
+
111
+ module Legion
112
+ module Extensions
113
+ module Ollama
114
+ module Runners
115
+ module S3Models
116
+ extend Legion::Extensions::Ollama::Helpers::Client
117
+
118
+ OLLAMA_REGISTRY_PREFIX = 'manifests/registry.ollama.ai/library'
119
+
120
+ def default_models_path
121
+ ENV.fetch('OLLAMA_MODELS', File.join(Dir.home, '.ollama', 'models'))
122
+ end
123
+
124
+ def s3_model_client(**s3_opts)
125
+ Legion::Extensions::S3::Client.new(**s3_opts)
126
+ end
127
+
128
+ def parse_model_ref(model)
129
+ parts = model.split(':')
130
+ { name: parts[0], tag: parts[1] || 'latest' }
131
+ end
132
+
133
+ def list_s3_models(bucket:, prefix: 'ollama/models', **s3_opts)
134
+ s3 = s3_model_client(**s3_opts)
135
+ manifest_prefix = "#{prefix}/#{OLLAMA_REGISTRY_PREFIX}/"
136
+ resp = s3.list_objects(bucket: bucket, prefix: manifest_prefix, max_keys: 1000)
137
+
138
+ models = resp[:objects].filter_map do |obj|
139
+ relative = obj[:key].delete_prefix(manifest_prefix)
140
+ parts = relative.split('/')
141
+ next unless parts.length == 2
142
+
143
+ { name: parts[0], tag: parts[1] }
144
+ end
145
+
146
+ { models: models, status: 200 }
147
+ end
148
+
149
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
150
+ Legion::Extensions::Helpers.const_defined?(:Lex)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ ```
157
+
158
+ **Step 4: Wire into the module loader and Client class**
159
+
160
+ In `lib/legion/extensions/ollama.rb`, add after the blobs require (line 11):
161
+
162
+ ```ruby
163
+ require 'legion/extensions/ollama/runners/s3_models'
164
+ ```
165
+
166
+ In `lib/legion/extensions/ollama/client.rb`, add after `include Runners::Blobs` (line 21):
167
+
168
+ ```ruby
169
+ include Runners::S3Models
170
+ ```
171
+
172
+ **Step 5: Run test to verify it passes**
173
+
174
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
175
+ Expected: 2 examples, 0 failures
176
+
177
+ **Step 6: Commit**
178
+
179
+ ```bash
180
+ git add lib/legion/extensions/ollama/runners/s3_models.rb \
181
+ lib/legion/extensions/ollama.rb \
182
+ lib/legion/extensions/ollama/client.rb \
183
+ spec/legion/extensions/ollama/runners/s3_models_spec.rb
184
+ git commit -m "add list_s3_models runner for s3 model distribution"
185
+ ```
186
+
187
+ ---
188
+
189
+ ### Task 3: Add `import_from_s3` (filesystem write)
190
+
191
+ **Files:**
192
+ - Modify: `lib/legion/extensions/ollama/runners/s3_models.rb`
193
+ - Modify: `spec/legion/extensions/ollama/runners/s3_models_spec.rb`
194
+
195
+ **Step 1: Write the failing test**
196
+
197
+ Add to the s3_models_spec.rb, inside the main describe block:
198
+
199
+ ```ruby
200
+ describe '#import_from_s3' do
201
+ let(:models_path) { Dir.mktmpdir('ollama_models') }
202
+ let(:manifest_body) do
203
+ {
204
+ 'schemaVersion' => 2,
205
+ 'mediaType' => 'application/vnd.docker.distribution.manifest.v2+json',
206
+ 'config' => { 'digest' => 'sha256:aaa111', 'size' => 100 },
207
+ 'layers' => [
208
+ { 'digest' => 'sha256:bbb222', 'size' => 4_000_000_000 },
209
+ { 'digest' => 'sha256:ccc333', 'size' => 512 }
210
+ ]
211
+ }
212
+ end
213
+ let(:manifest_json) { JSON.generate(manifest_body) }
214
+
215
+ after { FileUtils.remove_entry(models_path) }
216
+
217
+ it 'downloads manifest and blobs to local filesystem' do
218
+ allow(s3_client).to receive(:get_object)
219
+ .with(bucket: 'legion', key: 'ollama/models/manifests/registry.ollama.ai/library/llama3/latest')
220
+ .and_return({ body: manifest_json, content_type: 'application/json', content_length: manifest_json.length, key: '' })
221
+
222
+ %w[sha256:aaa111 sha256:bbb222 sha256:ccc333].each do |digest|
223
+ file_digest = digest.tr(':', '-')
224
+ allow(s3_client).to receive(:get_object)
225
+ .with(bucket: 'legion', key: "ollama/models/blobs/#{file_digest}")
226
+ .and_return({ body: "blob_data_#{digest}", content_type: 'application/octet-stream', content_length: 10, key: '' })
227
+ end
228
+
229
+ result = client_instance.import_from_s3(model: 'llama3:latest', bucket: 'legion', models_path: models_path)
230
+
231
+ expect(result[:result]).to be(true)
232
+ expect(result[:blobs_downloaded]).to eq(3)
233
+ expect(result[:status]).to eq(200)
234
+
235
+ manifest_file = File.join(models_path, 'manifests', 'registry.ollama.ai', 'library', 'llama3', 'latest')
236
+ expect(File.exist?(manifest_file)).to be(true)
237
+ expect(File.read(manifest_file)).to eq(manifest_json)
238
+
239
+ expect(File.exist?(File.join(models_path, 'blobs', 'sha256-bbb222'))).to be(true)
240
+ end
241
+
242
+ it 'skips blobs that already exist with matching size' do
243
+ allow(s3_client).to receive(:get_object)
244
+ .with(bucket: 'legion', key: 'ollama/models/manifests/registry.ollama.ai/library/llama3/latest')
245
+ .and_return({ body: manifest_json, content_type: 'application/json', content_length: manifest_json.length, key: '' })
246
+
247
+ # Pre-create one blob with matching size
248
+ blob_dir = File.join(models_path, 'blobs')
249
+ FileUtils.mkdir_p(blob_dir)
250
+ File.write(File.join(blob_dir, 'sha256-aaa111'), 'x' * 100)
251
+
252
+ # Only the other two blobs should be downloaded
253
+ %w[sha256:bbb222 sha256:ccc333].each do |digest|
254
+ file_digest = digest.tr(':', '-')
255
+ allow(s3_client).to receive(:get_object)
256
+ .with(bucket: 'legion', key: "ollama/models/blobs/#{file_digest}")
257
+ .and_return({ body: "blob_data_#{digest}", content_type: 'application/octet-stream', content_length: 10, key: '' })
258
+ end
259
+
260
+ result = client_instance.import_from_s3(model: 'llama3:latest', bucket: 'legion', models_path: models_path)
261
+
262
+ expect(result[:blobs_downloaded]).to eq(2)
263
+ expect(result[:blobs_skipped]).to eq(1)
264
+ end
265
+
266
+ it 'defaults tag to latest when not specified' do
267
+ allow(s3_client).to receive(:get_object)
268
+ .with(bucket: 'legion', key: 'ollama/models/manifests/registry.ollama.ai/library/llama3/latest')
269
+ .and_return({ body: manifest_json, content_type: 'application/json', content_length: manifest_json.length, key: '' })
270
+
271
+ %w[sha256:aaa111 sha256:bbb222 sha256:ccc333].each do |digest|
272
+ file_digest = digest.tr(':', '-')
273
+ allow(s3_client).to receive(:get_object)
274
+ .with(bucket: 'legion', key: "ollama/models/blobs/#{file_digest}")
275
+ .and_return({ body: "blob_data_#{digest}", content_type: 'application/octet-stream', content_length: 10, key: '' })
276
+ end
277
+
278
+ result = client_instance.import_from_s3(model: 'llama3', bucket: 'legion', models_path: models_path)
279
+ expect(result[:result]).to be(true)
280
+ end
281
+ end
282
+ ```
283
+
284
+ **Step 2: Run test to verify it fails**
285
+
286
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
287
+ Expected: FAIL — `NoMethodError: undefined method 'import_from_s3'`
288
+
289
+ **Step 3: Implement `import_from_s3`**
290
+
291
+ Add to `lib/legion/extensions/ollama/runners/s3_models.rb`, inside the `S3Models` module, before the `include` guard:
292
+
293
+ ```ruby
294
+ def import_from_s3(model:, bucket:, prefix: 'ollama/models', models_path: nil, **s3_opts)
295
+ models_path ||= default_models_path
296
+ ref = parse_model_ref(model)
297
+ s3 = s3_model_client(**s3_opts)
298
+
299
+ manifest_key = "#{prefix}/#{OLLAMA_REGISTRY_PREFIX}/#{ref[:name]}/#{ref[:tag]}"
300
+ manifest_resp = s3.get_object(bucket: bucket, key: manifest_key)
301
+ manifest = JSON.parse(manifest_resp[:body])
302
+
303
+ all_digests = [manifest['config'], *manifest['layers']].compact
304
+ downloaded = 0
305
+ skipped = 0
306
+
307
+ all_digests.each do |layer|
308
+ digest = layer['digest']
309
+ size = layer['size']
310
+ file_digest = digest.tr(':', '-')
311
+ local_path = File.join(models_path, 'blobs', file_digest)
312
+
313
+ if File.exist?(local_path) && File.size(local_path) == size
314
+ skipped += 1
315
+ next
316
+ end
317
+
318
+ FileUtils.mkdir_p(File.dirname(local_path))
319
+ blob_resp = s3.get_object(bucket: bucket, key: "#{prefix}/blobs/#{file_digest}")
320
+ File.binwrite(local_path, blob_resp[:body])
321
+ downloaded += 1
322
+ end
323
+
324
+ manifest_dir = File.join(models_path, 'manifests', 'registry.ollama.ai', 'library', ref[:name])
325
+ FileUtils.mkdir_p(manifest_dir)
326
+ File.write(File.join(manifest_dir, ref[:tag]), manifest_resp[:body])
327
+
328
+ { result: true, model: model, blobs_downloaded: downloaded, blobs_skipped: skipped, status: 200 }
329
+ end
330
+ ```
331
+
332
+ Also add `require 'json'` and `require 'fileutils'` at the top of the file.
333
+
334
+ **Step 4: Run test to verify it passes**
335
+
336
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
337
+ Expected: 5 examples, 0 failures
338
+
339
+ **Step 5: Commit**
340
+
341
+ ```bash
342
+ git add lib/legion/extensions/ollama/runners/s3_models.rb \
343
+ spec/legion/extensions/ollama/runners/s3_models_spec.rb
344
+ git commit -m "add import_from_s3 for filesystem-based model distribution"
345
+ ```
346
+
347
+ ---
348
+
349
+ ### Task 4: Add `sync_from_s3` (Ollama API)
350
+
351
+ **Files:**
352
+ - Modify: `lib/legion/extensions/ollama/runners/s3_models.rb`
353
+ - Modify: `spec/legion/extensions/ollama/runners/s3_models_spec.rb`
354
+
355
+ **Step 1: Write the failing test**
356
+
357
+ Add to the s3_models_spec.rb, inside the main describe block:
358
+
359
+ ```ruby
360
+ describe '#sync_from_s3' do
361
+ let(:faraday_conn) { instance_double(Faraday::Connection) }
362
+ let(:manifest_body) do
363
+ {
364
+ 'schemaVersion' => 2,
365
+ 'mediaType' => 'application/vnd.docker.distribution.manifest.v2+json',
366
+ 'config' => { 'digest' => 'sha256:aaa111', 'size' => 100 },
367
+ 'layers' => [
368
+ { 'digest' => 'sha256:bbb222', 'size' => 4_000_000_000, 'mediaType' => 'application/vnd.ollama.image.model' },
369
+ { 'digest' => 'sha256:ccc333', 'size' => 512, 'mediaType' => 'application/vnd.ollama.image.template' }
370
+ ]
371
+ }
372
+ end
373
+ let(:manifest_json) { JSON.generate(manifest_body) }
374
+
375
+ before do
376
+ allow(client_instance).to receive(:client).and_return(faraday_conn)
377
+ end
378
+
379
+ it 'pushes blobs through Ollama API and creates model' do
380
+ allow(s3_client).to receive(:get_object)
381
+ .with(bucket: 'legion', key: 'ollama/models/manifests/registry.ollama.ai/library/llama3/latest')
382
+ .and_return({ body: manifest_json, content_type: 'application/json', content_length: manifest_json.length, key: '' })
383
+
384
+ # check_blob returns false for all (none cached)
385
+ %w[sha256:aaa111 sha256:bbb222 sha256:ccc333].each do |digest|
386
+ allow(faraday_conn).to receive(:head).with("/api/blobs/#{digest}")
387
+ .and_return(instance_double(Faraday::Response, status: 404))
388
+
389
+ file_digest = digest.tr(':', '-')
390
+ allow(s3_client).to receive(:get_object)
391
+ .with(bucket: 'legion', key: "ollama/models/blobs/#{file_digest}")
392
+ .and_return({ body: "blob_data_#{digest}", content_type: 'application/octet-stream', content_length: 10, key: '' })
393
+
394
+ allow(faraday_conn).to receive(:post).with("/api/blobs/#{digest}")
395
+ .and_yield(instance_double(Faraday::Request, headers: {}).tap do |req|
396
+ allow(req).to receive(:body=)
397
+ allow(req).to receive(:headers).and_return({})
398
+ end).and_return(instance_double(Faraday::Response, status: 201))
399
+ end
400
+
401
+ allow(faraday_conn).to receive(:post).with('/api/create', hash_including(model: 'llama3:latest'))
402
+ .and_return(instance_double(Faraday::Response, body: { 'status' => 'success' }, status: 200))
403
+
404
+ result = client_instance.sync_from_s3(model: 'llama3:latest', bucket: 'legion')
405
+ expect(result[:result]).to be(true)
406
+ expect(result[:status]).to eq(200)
407
+ end
408
+
409
+ it 'skips blobs already present in Ollama' do
410
+ allow(s3_client).to receive(:get_object)
411
+ .with(bucket: 'legion', key: 'ollama/models/manifests/registry.ollama.ai/library/llama3/latest')
412
+ .and_return({ body: manifest_json, content_type: 'application/json', content_length: manifest_json.length, key: '' })
413
+
414
+ # aaa111 already exists, others do not
415
+ allow(faraday_conn).to receive(:head).with('/api/blobs/sha256:aaa111')
416
+ .and_return(instance_double(Faraday::Response, status: 200))
417
+
418
+ %w[sha256:bbb222 sha256:ccc333].each do |digest|
419
+ allow(faraday_conn).to receive(:head).with("/api/blobs/#{digest}")
420
+ .and_return(instance_double(Faraday::Response, status: 404))
421
+
422
+ file_digest = digest.tr(':', '-')
423
+ allow(s3_client).to receive(:get_object)
424
+ .with(bucket: 'legion', key: "ollama/models/blobs/#{file_digest}")
425
+ .and_return({ body: "blob_data_#{digest}", content_type: 'application/octet-stream', content_length: 10, key: '' })
426
+
427
+ allow(faraday_conn).to receive(:post).with("/api/blobs/#{digest}")
428
+ .and_yield(instance_double(Faraday::Request, headers: {}).tap do |req|
429
+ allow(req).to receive(:body=)
430
+ allow(req).to receive(:headers).and_return({})
431
+ end).and_return(instance_double(Faraday::Response, status: 201))
432
+ end
433
+
434
+ allow(faraday_conn).to receive(:post).with('/api/create', hash_including(model: 'llama3:latest'))
435
+ .and_return(instance_double(Faraday::Response, body: { 'status' => 'success' }, status: 200))
436
+
437
+ result = client_instance.sync_from_s3(model: 'llama3:latest', bucket: 'legion')
438
+ expect(result[:result]).to be(true)
439
+
440
+ expect(s3_client).not_to have_received(:get_object)
441
+ .with(bucket: 'legion', key: 'ollama/models/blobs/sha256-aaa111')
442
+ end
443
+ end
444
+ ```
445
+
446
+ **Step 2: Run test to verify it fails**
447
+
448
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
449
+ Expected: FAIL — `NoMethodError: undefined method 'sync_from_s3'`
450
+
451
+ **Step 3: Implement `sync_from_s3`**
452
+
453
+ Add to `lib/legion/extensions/ollama/runners/s3_models.rb`, inside the `S3Models` module:
454
+
455
+ ```ruby
456
+ def sync_from_s3(model:, bucket:, prefix: 'ollama/models', **opts)
457
+ ref = parse_model_ref(model)
458
+ s3_opts = opts.reject { |k, _| k == :host }
459
+ s3 = s3_model_client(**s3_opts)
460
+
461
+ manifest_key = "#{prefix}/#{OLLAMA_REGISTRY_PREFIX}/#{ref[:name]}/#{ref[:tag]}"
462
+ manifest_resp = s3.get_object(bucket: bucket, key: manifest_key)
463
+ manifest = JSON.parse(manifest_resp[:body])
464
+
465
+ all_digests = [manifest['config'], *manifest['layers']].compact
466
+
467
+ all_digests.each do |layer|
468
+ digest = layer['digest']
469
+ existing = check_blob(digest: digest, **opts)
470
+ next if existing[:result]
471
+
472
+ file_digest = digest.tr(':', '-')
473
+ blob_resp = s3.get_object(bucket: bucket, key: "#{prefix}/blobs/#{file_digest}")
474
+ push_blob(digest: digest, body: blob_resp[:body], **opts)
475
+ end
476
+
477
+ model_name = "#{ref[:name]}:#{ref[:tag]}"
478
+ create_model(model: model_name, from: model_name, **opts)
479
+
480
+ { result: true, model: model_name, status: 200 }
481
+ end
482
+ ```
483
+
484
+ **Step 4: Run test to verify it passes**
485
+
486
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
487
+ Expected: 7 examples, 0 failures
488
+
489
+ **Step 5: Commit**
490
+
491
+ ```bash
492
+ git add lib/legion/extensions/ollama/runners/s3_models.rb \
493
+ spec/legion/extensions/ollama/runners/s3_models_spec.rb
494
+ git commit -m "add sync_from_s3 for api-based model distribution"
495
+ ```
496
+
497
+ ---
498
+
499
+ ### Task 5: Add `import_default_models` convenience method
500
+
501
+ **Files:**
502
+ - Modify: `lib/legion/extensions/ollama/runners/s3_models.rb`
503
+ - Modify: `spec/legion/extensions/ollama/runners/s3_models_spec.rb`
504
+
505
+ **Step 1: Write the failing test**
506
+
507
+ Add to the s3_models_spec.rb:
508
+
509
+ ```ruby
510
+ describe '#import_default_models' do
511
+ let(:models_path) { Dir.mktmpdir('ollama_models') }
512
+ let(:manifest_body) do
513
+ {
514
+ 'schemaVersion' => 2,
515
+ 'config' => { 'digest' => 'sha256:aaa111', 'size' => 100 },
516
+ 'layers' => []
517
+ }
518
+ end
519
+ let(:manifest_json) { JSON.generate(manifest_body) }
520
+
521
+ after { FileUtils.remove_entry(models_path) }
522
+
523
+ it 'imports each model from the default_models list' do
524
+ allow(s3_client).to receive(:get_object).and_return({
525
+ body: manifest_json, content_type: 'application/json', content_length: manifest_json.length, key: ''
526
+ })
527
+ allow(s3_client).to receive(:get_object)
528
+ .with(hash_including(key: /blobs/))
529
+ .and_return({ body: 'data', content_type: 'application/octet-stream', content_length: 4, key: '' })
530
+
531
+ result = client_instance.import_default_models(
532
+ default_models: ['llama3:latest', 'nomic-embed-text:latest'],
533
+ bucket: 'legion',
534
+ models_path: models_path
535
+ )
536
+
537
+ expect(result[:result].length).to eq(2)
538
+ expect(result[:result]).to all(include(result: true))
539
+ expect(result[:status]).to eq(200)
540
+ end
541
+ end
542
+ ```
543
+
544
+ **Step 2: Run test to verify it fails**
545
+
546
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
547
+ Expected: FAIL — `NoMethodError: undefined method 'import_default_models'`
548
+
549
+ **Step 3: Implement `import_default_models`**
550
+
551
+ Add to the `S3Models` module:
552
+
553
+ ```ruby
554
+ def import_default_models(default_models:, bucket:, **opts)
555
+ results = default_models.map do |model|
556
+ import_from_s3(model: model, bucket: bucket, **opts)
557
+ end
558
+
559
+ { result: results, status: 200 }
560
+ end
561
+ ```
562
+
563
+ **Step 4: Run test to verify it passes**
564
+
565
+ Run: `bundle exec rspec spec/legion/extensions/ollama/runners/s3_models_spec.rb -v`
566
+ Expected: 8 examples, 0 failures
567
+
568
+ **Step 5: Commit**
569
+
570
+ ```bash
571
+ git add lib/legion/extensions/ollama/runners/s3_models.rb \
572
+ spec/legion/extensions/ollama/runners/s3_models_spec.rb
573
+ git commit -m "add import_default_models convenience method"
574
+ ```
575
+
576
+ ---
577
+
578
+ ### Task 6: Update Client spec for new runner methods
579
+
580
+ **Files:**
581
+ - Modify: `spec/legion/extensions/ollama/client_spec.rb`
582
+
583
+ **Step 1: Add respond_to expectations**
584
+
585
+ Add to the `'runner inclusion'` describe block in `client_spec.rb`:
586
+
587
+ ```ruby
588
+ it { is_expected.to respond_to(:list_s3_models) }
589
+ it { is_expected.to respond_to(:import_from_s3) }
590
+ it { is_expected.to respond_to(:sync_from_s3) }
591
+ it { is_expected.to respond_to(:import_default_models) }
592
+ ```
593
+
594
+ **Step 2: Run full test suite**
595
+
596
+ Run: `bundle exec rspec -v`
597
+ Expected: all examples pass, 0 failures
598
+
599
+ **Step 3: Commit**
600
+
601
+ ```bash
602
+ git add spec/legion/extensions/ollama/client_spec.rb
603
+ git commit -m "add s3_models runner inclusion tests to client spec"
604
+ ```
605
+
606
+ ---
607
+
608
+ ### Task 7: Run pre-push pipeline
609
+
610
+ **Step 1: Run full test suite**
611
+
612
+ Run: `bundle exec rspec`
613
+ Expected: 0 failures
614
+
615
+ **Step 2: Run rubocop auto-fix**
616
+
617
+ Run: `bundle exec rubocop -A`
618
+ Stage all modified files.
619
+
620
+ **Step 3: Run rubocop lint check**
621
+
622
+ Run: `bundle exec rubocop`
623
+ Expected: 0 offenses
624
+
625
+ **Step 4: Bump version**
626
+
627
+ In `lib/legion/extensions/ollama/version.rb`, bump `VERSION` from `'0.2.0'` to `'0.3.0'` (minor bump — new feature).
628
+
629
+ **Step 5: Update CHANGELOG.md**
630
+
631
+ Add entry:
632
+
633
+ ```markdown
634
+ ## [0.3.0] - 2026-04-01
635
+
636
+ ### Added
637
+ - S3 model distribution via new `Runners::S3Models` module
638
+ - `list_s3_models` to discover models in S3 mirror
639
+ - `import_from_s3` for direct filesystem model import (works without Ollama running)
640
+ - `sync_from_s3` for Ollama API-based model import (push_blob + create_model)
641
+ - `import_default_models` convenience method for fleet provisioning
642
+ - Runtime dependency on `lex-s3` for S3 operations
643
+ ```
644
+
645
+ **Step 6: Update README.md**
646
+
647
+ Add S3 model distribution section documenting the four new methods and settings.
648
+
649
+ **Step 7: Commit and push**
650
+
651
+ ```bash
652
+ git add -A
653
+ git commit -m "bump version to 0.3.0, update changelog and readme for s3 model distribution"
654
+ git push # pipeline-complete
655
+ ```
data/lex-ollama.gemspec CHANGED
@@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.require_paths = ['lib']
28
28
 
29
29
  spec.add_dependency 'faraday', '>= 2.0'
30
+ spec.add_dependency 'lex-s3', '>= 0.2'
30
31
  end
@@ -6,6 +6,7 @@ require_relative 'runners/chat'
6
6
  require_relative 'runners/models'
7
7
  require_relative 'runners/embeddings'
8
8
  require_relative 'runners/blobs'
9
+ require_relative 'runners/s3_models'
9
10
  require_relative 'runners/version'
10
11
 
11
12
  module Legion
@@ -18,6 +19,7 @@ module Legion
18
19
  include Runners::Models
19
20
  include Runners::Embeddings
20
21
  include Runners::Blobs
22
+ include Runners::S3Models
21
23
  include Runners::Version
22
24
 
23
25
  attr_reader :opts
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require 'tempfile'
7
+ require 'legion/extensions/s3/client'
8
+ require 'legion/extensions/ollama/helpers/client'
9
+ require 'legion/extensions/ollama/helpers/errors'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Ollama
14
+ module Runners
15
+ module S3Models
16
+ extend Legion::Extensions::Ollama::Helpers::Client
17
+
18
+ OLLAMA_REGISTRY_PREFIX = 'manifests/registry.ollama.ai/library'
19
+
20
+ class DigestMismatchError < StandardError; end
21
+
22
+ def list_s3_models(bucket:, prefix: 'ollama/models', **s3_opts)
23
+ s3 = s3_model_client(**s3_opts)
24
+ manifest_prefix = "#{prefix}/#{OLLAMA_REGISTRY_PREFIX}/"
25
+ resp = s3.list_objects(bucket: bucket, prefix: manifest_prefix, max_keys: 1000)
26
+
27
+ models = resp[:objects].filter_map do |obj|
28
+ relative = obj[:key].delete_prefix(manifest_prefix)
29
+ parts = relative.split('/')
30
+ next unless parts.length == 2
31
+
32
+ { name: parts[0], tag: parts[1] }
33
+ end
34
+
35
+ { models: models, status: 200 }
36
+ end
37
+
38
+ def import_from_s3(model:, bucket:, prefix: 'ollama/models', models_path: nil, **s3_opts)
39
+ s3 = s3_model_client(**s3_opts)
40
+ path = models_path || default_models_path
41
+ ref = parse_model_ref(model)
42
+ name = ref[:name]
43
+ tag = ref[:tag]
44
+
45
+ manifest_key = "#{prefix}/#{OLLAMA_REGISTRY_PREFIX}/#{name}/#{tag}"
46
+ manifest_resp = s3.get_object(bucket: bucket, key: manifest_key)
47
+ manifest_body = manifest_resp[:body]
48
+ manifest_data = JSON.parse(manifest_body)
49
+
50
+ digests = []
51
+ digests << manifest_data['config'].slice('digest', 'size')
52
+ digests.concat(manifest_data['layers'].map { |l| l.slice('digest', 'size') })
53
+
54
+ blobs_downloaded = 0
55
+ blobs_skipped = 0
56
+
57
+ digests.each do |entry|
58
+ digest = entry['digest']
59
+ expected_hex = extract_hex(digest)
60
+ blob_filename = digest.sub(':', '-')
61
+ local_path = File.join(path, 'blobs', blob_filename)
62
+
63
+ if File.exist?(local_path) && verify_digest(local_path, expected_hex)
64
+ blobs_skipped += 1
65
+ next
66
+ end
67
+
68
+ FileUtils.mkdir_p(File.dirname(local_path))
69
+ download_and_verify_blob(s3, bucket: bucket, key: "#{prefix}/blobs/#{blob_filename}",
70
+ target: local_path, expected_hex: expected_hex)
71
+ blobs_downloaded += 1
72
+ end
73
+
74
+ manifest_path = File.join(path, 'manifests', 'registry.ollama.ai', 'library', name, tag)
75
+ FileUtils.mkdir_p(File.dirname(manifest_path))
76
+ File.binwrite(manifest_path, manifest_body)
77
+
78
+ { result: true, model: model, blobs_downloaded: blobs_downloaded, blobs_skipped: blobs_skipped,
79
+ status: 200 }
80
+ end
81
+
82
+ def sync_from_s3(model:, bucket:, prefix: 'ollama/models', host: nil, models_path: nil, **s3_opts)
83
+ ollama_opts = host ? { host: host } : {}
84
+ path = models_path || default_models_path
85
+ s3 = s3_model_client(**s3_opts)
86
+ ref = parse_model_ref(model)
87
+ name = ref[:name]
88
+ tag = ref[:tag]
89
+ model_ref = "#{name}:#{tag}"
90
+
91
+ manifest_key = "#{prefix}/#{OLLAMA_REGISTRY_PREFIX}/#{name}/#{tag}"
92
+ manifest_resp = s3.get_object(bucket: bucket, key: manifest_key)
93
+ manifest_data = JSON.parse(manifest_resp[:body])
94
+
95
+ digests = []
96
+ digests << manifest_data['config']['digest']
97
+ digests.concat(manifest_data['layers'].map { |l| l['digest'] })
98
+
99
+ blobs_pushed = 0
100
+ blobs_skipped = 0
101
+ errors = []
102
+
103
+ digests.each do |digest|
104
+ if check_blob(digest: digest, **ollama_opts)[:result]
105
+ blobs_skipped += 1
106
+ next
107
+ end
108
+
109
+ expected_hex = extract_hex(digest)
110
+ blob_filename = digest.sub(':', '-')
111
+ tempfile = Tempfile.new(['ollama_blob_', blob_filename], binmode: true)
112
+ begin
113
+ stream_s3_to_file(s3, bucket: bucket, key: "#{prefix}/blobs/#{blob_filename}", target: tempfile.path)
114
+ unless verify_digest(tempfile.path, expected_hex)
115
+ errors << { digest: digest, error: 'digest mismatch' }
116
+ next
117
+ end
118
+ result = push_blob(digest: digest, body: File.binread(tempfile.path), **ollama_opts)
119
+ unless result[:result]
120
+ errors << { digest: digest, status: result[:status] }
121
+ next
122
+ end
123
+ blobs_pushed += 1
124
+ ensure
125
+ tempfile.close
126
+ tempfile.unlink
127
+ end
128
+ end
129
+
130
+ return { result: false, model: model_ref, errors: errors, status: 500 } if errors.any?
131
+
132
+ manifest_path = File.join(path, 'manifests', 'registry.ollama.ai', 'library', name, tag)
133
+ FileUtils.mkdir_p(File.dirname(manifest_path))
134
+ File.binwrite(manifest_path, manifest_resp[:body])
135
+
136
+ { result: true, model: model_ref, blobs_pushed: blobs_pushed, blobs_skipped: blobs_skipped,
137
+ status: 200 }
138
+ end
139
+
140
+ def import_default_models(default_models:, bucket:, **)
141
+ results = default_models.map do |model|
142
+ import_from_s3(model: model, bucket: bucket, **)
143
+ end
144
+
145
+ { result: results, status: 200 }
146
+ end
147
+
148
+ private
149
+
150
+ def default_models_path
151
+ ENV.fetch('OLLAMA_MODELS', File.join(Dir.home, '.ollama', 'models'))
152
+ end
153
+
154
+ def s3_model_client(**s3_opts)
155
+ Legion::Extensions::S3::Client.new(**s3_opts)
156
+ end
157
+
158
+ def parse_model_ref(model)
159
+ parts = model.split(':')
160
+ { name: parts[0], tag: parts[1] || 'latest' }
161
+ end
162
+
163
+ def extract_hex(digest)
164
+ digest.split(':').last
165
+ end
166
+
167
+ def verify_digest(file_path, expected_hex)
168
+ Digest::SHA256.file(file_path).hexdigest == expected_hex
169
+ end
170
+
171
+ def stream_s3_to_file(s3_inst, bucket:, key:, target:)
172
+ s3_inst.s3_client.get_object(bucket: bucket, key: key, response_target: target)
173
+ end
174
+
175
+ def download_and_verify_blob(s3_inst, bucket:, key:, target:, expected_hex:)
176
+ tmpfile = "#{target}.tmp"
177
+ begin
178
+ stream_s3_to_file(s3_inst, bucket: bucket, key: key, target: tmpfile)
179
+ raise DigestMismatchError, "digest mismatch for #{key}: expected sha256:#{expected_hex}" unless verify_digest(tmpfile, expected_hex)
180
+
181
+ File.rename(tmpfile, target)
182
+ rescue StandardError
183
+ FileUtils.rm_f(tmpfile)
184
+ raise
185
+ end
186
+ end
187
+
188
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
189
+ Legion::Extensions::Helpers.const_defined?(:Lex)
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Ollama
6
- VERSION = '0.2.0'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
9
9
  end
@@ -9,6 +9,7 @@ require 'legion/extensions/ollama/runners/chat'
9
9
  require 'legion/extensions/ollama/runners/models'
10
10
  require 'legion/extensions/ollama/runners/embeddings'
11
11
  require 'legion/extensions/ollama/runners/blobs'
12
+ require 'legion/extensions/ollama/runners/s3_models'
12
13
  require 'legion/extensions/ollama/runners/version'
13
14
  require 'legion/extensions/ollama/client'
14
15
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-ollama
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lex-s3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
26
40
  description: Connects LegionIO to Ollama local LLM server
27
41
  email:
28
42
  - matthewdiverson@gmail.com
@@ -40,6 +54,8 @@ files:
40
54
  - Gemfile
41
55
  - LICENSE
42
56
  - README.md
57
+ - docs/plans/2026-04-01-s3-model-distribution-design.md
58
+ - docs/plans/2026-04-01-s3-model-distribution-plan.md
43
59
  - lex-ollama.gemspec
44
60
  - lib/legion/extensions/ollama.rb
45
61
  - lib/legion/extensions/ollama/client.rb
@@ -51,6 +67,7 @@ files:
51
67
  - lib/legion/extensions/ollama/runners/completions.rb
52
68
  - lib/legion/extensions/ollama/runners/embeddings.rb
53
69
  - lib/legion/extensions/ollama/runners/models.rb
70
+ - lib/legion/extensions/ollama/runners/s3_models.rb
54
71
  - lib/legion/extensions/ollama/runners/version.rb
55
72
  - lib/legion/extensions/ollama/version.rb
56
73
  homepage: https://github.com/LegionIO/lex-ollama