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 +4 -4
- data/CHANGELOG.md +16 -0
- data/Gemfile +1 -0
- data/README.md +34 -0
- data/docs/plans/2026-04-01-s3-model-distribution-design.md +131 -0
- data/docs/plans/2026-04-01-s3-model-distribution-plan.md +655 -0
- data/lex-ollama.gemspec +1 -0
- data/lib/legion/extensions/ollama/client.rb +2 -0
- data/lib/legion/extensions/ollama/runners/s3_models.rb +194 -0
- data/lib/legion/extensions/ollama/version.rb +1 -1
- data/lib/legion/extensions/ollama.rb +1 -0
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7477574f919b18b85c79afba3a1f65c8540d9eff9ca02b9e0c807b3740fed452
|
|
4
|
+
data.tar.gz: a5c69878c8518caf02c2e238c94243fd49c320f20bfaede00252bfdc87be5cbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
@@ -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
|
|
@@ -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.
|
|
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
|