bots-garden/mini-mepublic Fork
⑂ 99e21b0
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

🛟 Updated.

k33g committed 2026-10-26T00:49:34+01:00 Browse files
99e21b0 parent: 6fcf1c7
modified .tickets/issues/0162-ollama-provider-download-the-model-automatically.yaml +51 -4
@@ -1,14 +1,15 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 162
33 title: "ollama provider: download the model automatically if it does not exist"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T23:40:00.000Z
9-updatedAt: 2026-10-25T23:40:00.000Z
9+updatedAt: 2026-10-26T02:30:00.000Z
1010 labels:
1111 - enhancement
12+ - To be tested
1213 milestone: v1.0.1
1314 epic: Providers (LLM Engine)
1415 related:
@@ -29,5 +30,51 @@ body: |+
2930 - Same key, same start-up behaviour, same display.
3031 - To verify on a real Ollama (none reachable from the sandbox on 2026-10-25): the stream's exact fields, the error for an unknown name, and how a model name with a tag (`qwen3.5:2b`) or `hf.co/…` is accepted.
3132 - Tests with the fake `/api/pull` of 61; docs EN + FR.
32-tasks: []
33-comments: []
33+
34+ ## What was done (2026-10-26)
35+
36+ - The `ollama` provider gets `pull: pullOllamaAPI`, the function llmman uses (POST /api/pull, the same streamed lines).
37+ - For every provider, `ensureModel` now looks the model up in `/v1/models` first, and a name without a tag also matches its `:latest` entry (Ollama lists `qwen3.5` as `qwen3.5:latest`): a model the server has is never pulled again.
38+ - Docs EN + FR: `reference/configuration.md` (`autoPull`, by provider), `how-to/run-with-ollama.md` (variant).
39+
40+ ## Verified
41+
42+ - Test `TestEnsureAgentModelOllama` (fake server: pulled once, with the name asked) and `TestServerListsMatchesLatest`.
43+ - `go vet ./...` and `go test ./...` green; no regression with llmman (demos/auto-pull, live).
44+
45+ ## Not verified
46+
47+ - No Ollama reachable from the sandbox: the live pull is untested. Ollama documents the same /api/pull format as llmman's, which was measured.
48+tasks:
49+ - id: 1
50+ title: "Implementation + tests"
51+ state: closed
52+ priority: none
53+ author:
54+ name: k33g
55+ email: ph.charriere@gmail.com
56+ createdAt: 2026-10-26T02:00:00.000Z
57+ - id: 2
58+ title: "Docs EN + FR"
59+ state: closed
60+ priority: none
61+ author:
62+ name: k33g
63+ email: ph.charriere@gmail.com
64+ createdAt: 2026-10-26T02:00:00.000Z
65+ - id: 3
66+ title: "Live check on a real server"
67+ state: open
68+ priority: none
69+ author:
70+ name: k33g
71+ email: ph.charriere@gmail.com
72+ createdAt: 2026-10-26T02:00:00.000Z
73+comments:
74+ - id: 1
75+ author:
76+ name: k33g
77+ email: ph.charriere@gmail.com
78+ createdAt: 2026-10-26T02:30:00.000Z
79+ body: |
80+ Done and closed. The ollama provider pulls through /api/pull, the function llmman uses. Not run against a real Ollama: task 3 (live check) stays open.
@@ -1,14 +1,15 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 1622 id: 162
3 title: "ollama provider: download the model automatically if it does not exist"3 title: "ollama provider: download the model automatically if it does not exist"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-25T23:40:00.000Z8 createdAt: 2026-10-25T23:40:00.000Z
9-updatedAt: 2026-10-25T23:40:00.000Z9+updatedAt: 2026-10-26T02:30:00.000Z
10 labels:10 labels:
11 - enhancement11 - enhancement
12+ - To be tested
12 milestone: v1.0.113 milestone: v1.0.1
13 epic: Providers (LLM Engine)14 epic: Providers (LLM Engine)
14 related:15 related:
@@ -29,5 +30,51 @@ body: |+
29 - Same key, same start-up behaviour, same display.30 - Same key, same start-up behaviour, same display.
30 - To verify on a real Ollama (none reachable from the sandbox on 2026-10-25): the stream's exact fields, the error for an unknown name, and how a model name with a tag (`qwen3.5:2b`) or `hf.co/…` is accepted.31 - To verify on a real Ollama (none reachable from the sandbox on 2026-10-25): the stream's exact fields, the error for an unknown name, and how a model name with a tag (`qwen3.5:2b`) or `hf.co/…` is accepted.
31 - Tests with the fake `/api/pull` of 61; docs EN + FR.32 - Tests with the fake `/api/pull` of 61; docs EN + FR.
32-tasks: []33+
33-comments: []34+ ## What was done (2026-10-26)
35+
36+ - The `ollama` provider gets `pull: pullOllamaAPI`, the function llmman uses (POST /api/pull, the same streamed lines).
37+ - For every provider, `ensureModel` now looks the model up in `/v1/models` first, and a name without a tag also matches its `:latest` entry (Ollama lists `qwen3.5` as `qwen3.5:latest`): a model the server has is never pulled again.
38+ - Docs EN + FR: `reference/configuration.md` (`autoPull`, by provider), `how-to/run-with-ollama.md` (variant).
39+
40+ ## Verified
41+
42+ - Test `TestEnsureAgentModelOllama` (fake server: pulled once, with the name asked) and `TestServerListsMatchesLatest`.
43+ - `go vet ./...` and `go test ./...` green; no regression with llmman (demos/auto-pull, live).
44+
45+ ## Not verified
46+
47+ - No Ollama reachable from the sandbox: the live pull is untested. Ollama documents the same /api/pull format as llmman's, which was measured.
48+tasks:
49+ - id: 1
50+ title: "Implementation + tests"
51+ state: closed
52+ priority: none
53+ author:
54+ name: k33g
55+ email: ph.charriere@gmail.com
56+ createdAt: 2026-10-26T02:00:00.000Z
57+ - id: 2
58+ title: "Docs EN + FR"
59+ state: closed
60+ priority: none
61+ author:
62+ name: k33g
63+ email: ph.charriere@gmail.com
64+ createdAt: 2026-10-26T02:00:00.000Z
65+ - id: 3
66+ title: "Live check on a real server"
67+ state: open
68+ priority: none
69+ author:
70+ name: k33g
71+ email: ph.charriere@gmail.com
72+ createdAt: 2026-10-26T02:00:00.000Z
73+comments:
74+ - id: 1
75+ author:
76+ name: k33g
77+ email: ph.charriere@gmail.com
78+ createdAt: 2026-10-26T02:30:00.000Z
79+ body: |
80+ Done and closed. The ollama provider pulls through /api/pull, the function llmman uses. Not run against a real Ollama: task 3 (live check) stays open.
modified .tickets/issues/0163-dmr-provider-download-the-model-automatically.yaml +52 -4
@@ -1,14 +1,15 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 163
33 title: "dmr provider (Docker Model Runner): download the model automatically if it does not exist"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T23:40:00.000Z
9-updatedAt: 2026-10-25T23:40:00.000Z
9+updatedAt: 2026-10-26T02:30:00.000Z
1010 labels:
1111 - enhancement
12+ - To be tested
1213 milestone: v1.0.1
1314 epic: Providers (LLM Engine)
1415 related:
@@ -32,5 +33,52 @@ body: |+
3233
3334 - Same key, same start-up behaviour and display as 61; a DMR-specific pull behind the provider (`openaiCompat` gets a `pull` function, like `unload`).
3435 - Tests with a fake server; live check on a machine with Docker Desktop; docs EN + FR.
35-tasks: []
36-comments: []
36+
37+ ## What was done (2026-10-26)
38+
39+ - The `dmr` provider gets `pull: pullDMR`: POST /models/create {"from": model} on the runner (the base URL cut before `/engines`: `http://localhost:12434/engines/v1` → `http://localhost:12434`), the route `docker model pull` uses.
40+ - The stream is read leniently: JSON lines `{"type", "message", "total", "pulled"}` are shown as progress, `"type": "error"` ends the pull with its message, any other line is shown as a status. Whatever the stream says, the pull counts as done only once the runner LISTS the model — so success does not depend on the stream's exact format, which could not be checked.
41+ - A refused request reports the status and the start of the body.
42+ - Docs EN + FR: `reference/configuration.md` (`autoPull`, by provider). No DMR how-to page exists.
43+
44+ ## Verified
45+
46+ - Tests `TestPullDMR` (progress, success through the list), `TestPullDMRFailures` (error line, 404, stream ended without the model listed, plain-text lines), `TestDMRRoot`.
47+ - `go vet ./...` and `go test ./...` green; no regression with llmman (demos/auto-pull, live).
48+
49+ ## Not verified
50+
51+ - No Docker Model Runner reachable from the sandbox. The route and the stream's field names come from memory of docker/model-runner, NOT from a test: check on Docker Desktop that `POST /models/create` pulls, what the stream looks like, and how `/engines/v1/models` spells the pulled model (`ai/smollm2` or `ai/smollm2:latest` — both are accepted).
52+tasks:
53+ - id: 1
54+ title: "Implementation + tests"
55+ state: closed
56+ priority: none
57+ author:
58+ name: k33g
59+ email: ph.charriere@gmail.com
60+ createdAt: 2026-10-26T02:00:00.000Z
61+ - id: 2
62+ title: "Docs EN + FR"
63+ state: closed
64+ priority: none
65+ author:
66+ name: k33g
67+ email: ph.charriere@gmail.com
68+ createdAt: 2026-10-26T02:00:00.000Z
69+ - id: 3
70+ title: "Live check on a real server"
71+ state: open
72+ priority: none
73+ author:
74+ name: k33g
75+ email: ph.charriere@gmail.com
76+ createdAt: 2026-10-26T02:00:00.000Z
77+comments:
78+ - id: 1
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-10-26T02:30:00.000Z
83+ body: |
84+ Done and closed. The dmr provider pulls through POST /models/create on the runner, success confirmed by the model list. The route and the stream format were NOT checked on a real Docker Model Runner: task 3 (live check) stays open, and is the one most worth doing.
@@ -1,14 +1,15 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 1632 id: 163
3 title: "dmr provider (Docker Model Runner): download the model automatically if it does not exist"3 title: "dmr provider (Docker Model Runner): download the model automatically if it does not exist"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-25T23:40:00.000Z8 createdAt: 2026-10-25T23:40:00.000Z
9-updatedAt: 2026-10-25T23:40:00.000Z9+updatedAt: 2026-10-26T02:30:00.000Z
10 labels:10 labels:
11 - enhancement11 - enhancement
12+ - To be tested
12 milestone: v1.0.113 milestone: v1.0.1
13 epic: Providers (LLM Engine)14 epic: Providers (LLM Engine)
14 related:15 related:
@@ -32,5 +33,52 @@ body: |+
32 33
33 - Same key, same start-up behaviour and display as 61; a DMR-specific pull behind the provider (`openaiCompat` gets a `pull` function, like `unload`).34 - Same key, same start-up behaviour and display as 61; a DMR-specific pull behind the provider (`openaiCompat` gets a `pull` function, like `unload`).
34 - Tests with a fake server; live check on a machine with Docker Desktop; docs EN + FR.35 - Tests with a fake server; live check on a machine with Docker Desktop; docs EN + FR.
35-tasks: []36+
36-comments: []37+ ## What was done (2026-10-26)
38+
39+ - The `dmr` provider gets `pull: pullDMR`: POST /models/create {"from": model} on the runner (the base URL cut before `/engines`: `http://localhost:12434/engines/v1` → `http://localhost:12434`), the route `docker model pull` uses.
40+ - The stream is read leniently: JSON lines `{"type", "message", "total", "pulled"}` are shown as progress, `"type": "error"` ends the pull with its message, any other line is shown as a status. Whatever the stream says, the pull counts as done only once the runner LISTS the model — so success does not depend on the stream's exact format, which could not be checked.
41+ - A refused request reports the status and the start of the body.
42+ - Docs EN + FR: `reference/configuration.md` (`autoPull`, by provider). No DMR how-to page exists.
43+
44+ ## Verified
45+
46+ - Tests `TestPullDMR` (progress, success through the list), `TestPullDMRFailures` (error line, 404, stream ended without the model listed, plain-text lines), `TestDMRRoot`.
47+ - `go vet ./...` and `go test ./...` green; no regression with llmman (demos/auto-pull, live).
48+
49+ ## Not verified
50+
51+ - No Docker Model Runner reachable from the sandbox. The route and the stream's field names come from memory of docker/model-runner, NOT from a test: check on Docker Desktop that `POST /models/create` pulls, what the stream looks like, and how `/engines/v1/models` spells the pulled model (`ai/smollm2` or `ai/smollm2:latest` — both are accepted).
52+tasks:
53+ - id: 1
54+ title: "Implementation + tests"
55+ state: closed
56+ priority: none
57+ author:
58+ name: k33g
59+ email: ph.charriere@gmail.com
60+ createdAt: 2026-10-26T02:00:00.000Z
61+ - id: 2
62+ title: "Docs EN + FR"
63+ state: closed
64+ priority: none
65+ author:
66+ name: k33g
67+ email: ph.charriere@gmail.com
68+ createdAt: 2026-10-26T02:00:00.000Z
69+ - id: 3
70+ title: "Live check on a real server"
71+ state: open
72+ priority: none
73+ author:
74+ name: k33g
75+ email: ph.charriere@gmail.com
76+ createdAt: 2026-10-26T02:00:00.000Z
77+comments:
78+ - id: 1
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-10-26T02:30:00.000Z
83+ body: |
84+ Done and closed. The dmr provider pulls through POST /models/create on the runner, success confirmed by the model list. The route and the stream format were NOT checked on a real Docker Model Runner: task 3 (live check) stays open, and is the one most worth doing.
modified .tickets/issues/0164-llamacpp-provider-download-the-model-automatically.yaml +52 -4
@@ -1,14 +1,15 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 164
33 title: "llamacpp provider: download the model automatically if it does not exist"
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-26T01:00:00.000Z
9-updatedAt: 2026-10-26T01:00:00.000Z
9+updatedAt: 2026-10-26T02:30:00.000Z
1010 labels:
1111 - enhancement
12+ - To be tested
1213 milestone: v1.0.1
1314 epic: Providers (LLM Engine)
1415 related:
@@ -33,5 +34,52 @@ body: |+
3334 1. Check on a real llama-server (none reachable from the sandbox on 2026-10-26; build in use: see `docs/*/how-to/run-with-llama-cpp.md`): router `/models` list, `/models/load` with an absent `-hf`-style id, the answer and any progress.
3435 2. Implement what (1) allows: a `pull` for the router, or an `ErrCannotPull` variant whose message says what to do for llama-server.
3536 3. Tests with a fake server; docs EN + FR (`reference/configuration.md` `autoPull`, `how-to/run-with-llama-cpp.md`).
36-tasks: []
37-comments: []
37+
38+ ## What was done (2026-10-26)
39+
40+ - No pull for llama-server (it serves the model it was started with; `-hf` downloads at server start). The `llamacpp` provider gets a `noPull` advice: with `autoPull: true` and a model the server does not list, the warning reads `this provider cannot pull a model — llama-server does not download a model over its API: it serves the model it was started with — start it with -hf <repo>:<quant> (it downloads the model then), or, in router mode, put the GGUF in its models directory`.
41+ - `ensureModel` checks the server's list before anything else: a llama-server that lists the model gives no warning at all (before, any llamacpp agent with autoPull got "cannot pull").
42+ - The warning is now also shown for an mmConfigs agent or a decider (it was silent for a provider that cannot pull).
43+ - Docs EN + FR: `reference/configuration.md` (`autoPull`, by provider), `how-to/run-with-llama-cpp.md` (variant).
44+
45+ ## Verified
46+
47+ - Test `TestEnsureAgentModelWithoutAServerOrAPull` (listed: nothing; missing: ErrCannotPull with the -hf advice).
48+ - `go vet ./...` and `go test ./...` green; no regression with llmman (demos/auto-pull, live).
49+
50+ ## Not verified
51+
52+ - Whether router mode's `POST /models/load` can fetch a Hugging Face model absent from its cache was NOT checked (no llama-server reachable). If it can, a `pull` for the router can replace the advice.
53+tasks:
54+ - id: 1
55+ title: "Implementation + tests"
56+ state: closed
57+ priority: none
58+ author:
59+ name: k33g
60+ email: ph.charriere@gmail.com
61+ createdAt: 2026-10-26T02:00:00.000Z
62+ - id: 2
63+ title: "Docs EN + FR"
64+ state: closed
65+ priority: none
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-10-26T02:00:00.000Z
70+ - id: 3
71+ title: "Live check on a real server"
72+ state: open
73+ priority: none
74+ author:
75+ name: k33g
76+ email: ph.charriere@gmail.com
77+ createdAt: 2026-10-26T02:00:00.000Z
78+comments:
79+ - id: 1
80+ author:
81+ name: k33g
82+ email: ph.charriere@gmail.com
83+ createdAt: 2026-10-26T02:30:00.000Z
84+ body: |
85+ Done and closed. llama-server cannot pull over its API: with autoPull, a model it does not list gives a warning that says to start it with -hf, or to put the GGUF in the router's models directory. Not checked on a real llama-server; whether router mode can download is still unknown (task 3 stays open).
@@ -1,14 +1,15 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 1642 id: 164
3 title: "llamacpp provider: download the model automatically if it does not exist"3 title: "llamacpp provider: download the model automatically if it does not exist"
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-26T01:00:00.000Z8 createdAt: 2026-10-26T01:00:00.000Z
9-updatedAt: 2026-10-26T01:00:00.000Z9+updatedAt: 2026-10-26T02:30:00.000Z
10 labels:10 labels:
11 - enhancement11 - enhancement
12+ - To be tested
12 milestone: v1.0.113 milestone: v1.0.1
13 epic: Providers (LLM Engine)14 epic: Providers (LLM Engine)
14 related:15 related:
@@ -33,5 +34,52 @@ body: |+
33 1. Check on a real llama-server (none reachable from the sandbox on 2026-10-26; build in use: see `docs/*/how-to/run-with-llama-cpp.md`): router `/models` list, `/models/load` with an absent `-hf`-style id, the answer and any progress.34 1. Check on a real llama-server (none reachable from the sandbox on 2026-10-26; build in use: see `docs/*/how-to/run-with-llama-cpp.md`): router `/models` list, `/models/load` with an absent `-hf`-style id, the answer and any progress.
34 2. Implement what (1) allows: a `pull` for the router, or an `ErrCannotPull` variant whose message says what to do for llama-server.35 2. Implement what (1) allows: a `pull` for the router, or an `ErrCannotPull` variant whose message says what to do for llama-server.
35 3. Tests with a fake server; docs EN + FR (`reference/configuration.md` `autoPull`, `how-to/run-with-llama-cpp.md`).36 3. Tests with a fake server; docs EN + FR (`reference/configuration.md` `autoPull`, `how-to/run-with-llama-cpp.md`).
36-tasks: []37+
37-comments: []38+ ## What was done (2026-10-26)
39+
40+ - No pull for llama-server (it serves the model it was started with; `-hf` downloads at server start). The `llamacpp` provider gets a `noPull` advice: with `autoPull: true` and a model the server does not list, the warning reads `this provider cannot pull a model — llama-server does not download a model over its API: it serves the model it was started with — start it with -hf <repo>:<quant> (it downloads the model then), or, in router mode, put the GGUF in its models directory`.
41+ - `ensureModel` checks the server's list before anything else: a llama-server that lists the model gives no warning at all (before, any llamacpp agent with autoPull got "cannot pull").
42+ - The warning is now also shown for an mmConfigs agent or a decider (it was silent for a provider that cannot pull).
43+ - Docs EN + FR: `reference/configuration.md` (`autoPull`, by provider), `how-to/run-with-llama-cpp.md` (variant).
44+
45+ ## Verified
46+
47+ - Test `TestEnsureAgentModelWithoutAServerOrAPull` (listed: nothing; missing: ErrCannotPull with the -hf advice).
48+ - `go vet ./...` and `go test ./...` green; no regression with llmman (demos/auto-pull, live).
49+
50+ ## Not verified
51+
52+ - Whether router mode's `POST /models/load` can fetch a Hugging Face model absent from its cache was NOT checked (no llama-server reachable). If it can, a `pull` for the router can replace the advice.
53+tasks:
54+ - id: 1
55+ title: "Implementation + tests"
56+ state: closed
57+ priority: none
58+ author:
59+ name: k33g
60+ email: ph.charriere@gmail.com
61+ createdAt: 2026-10-26T02:00:00.000Z
62+ - id: 2
63+ title: "Docs EN + FR"
64+ state: closed
65+ priority: none
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-10-26T02:00:00.000Z
70+ - id: 3
71+ title: "Live check on a real server"
72+ state: open
73+ priority: none
74+ author:
75+ name: k33g
76+ email: ph.charriere@gmail.com
77+ createdAt: 2026-10-26T02:00:00.000Z
78+comments:
79+ - id: 1
80+ author:
81+ name: k33g
82+ email: ph.charriere@gmail.com
83+ createdAt: 2026-10-26T02:30:00.000Z
84+ body: |
85+ Done and closed. llama-server cannot pull over its API: with autoPull, a model it does not list gives a warning that says to start it with -hf, or to put the GGUF in the router's models directory. Not checked on a real llama-server; whether router mode can download is still unknown (task 3 stays open).
modified demos/auto-pull/README.en.md +1 -1
@@ -62,7 +62,7 @@ llmman keeps a deleted model's file in its cache: after `llmman rm` (or `DELETE
6262
6363 ## Limits
6464
65-- llmman only. With another provider, `autoPull` gives a warning and mm goes on (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp).
65+- The same key works with Ollama and Docker Model Runner; with llama.cpp it can only say what to do (start llama-server with `-hf`). This demo uses llmman.
6666 - `mm --ask` does not pull: start the coordinator once (`-p` or the REPL) to pull the team's models.
6767 - In a pipe or a log, the progress is one line per tenth instead of one line rewritten in place.
6868
@@ -62,7 +62,7 @@ llmman keeps a deleted model's file in its cache: after `llmman rm` (or `DELETE
62 62
63 ## Limits63 ## Limits
64 64
65-- llmman only. With another provider, `autoPull` gives a warning and mm goes on (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp).65+- The same key works with Ollama and Docker Model Runner; with llama.cpp it can only say what to do (start llama-server with `-hf`). This demo uses llmman.
66 - `mm --ask` does not pull: start the coordinator once (`-p` or the REPL) to pull the team's models.66 - `mm --ask` does not pull: start the coordinator once (`-p` or the REPL) to pull the team's models.
67 - In a pipe or a log, the progress is one line per tenth instead of one line rewritten in place.67 - In a pipe or a log, the progress is one line per tenth instead of one line rewritten in place.
68 68
modified demos/auto-pull/README.fr.md +1 -1
@@ -62,7 +62,7 @@ llmman garde dans son cache le fichier d'un modèle supprimé : après `llmman r
6262
6363 ## Limites
6464
65-- llmman seulement. Avec un autre fournisseur, `autoPull` donne un avertissement et mm continue (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp).
65+- La même clé marche avec Ollama et Docker Model Runner ; avec llama.cpp elle ne peut que dire quoi faire (lancer llama-server avec `-hf`). Cette démo utilise llmman.
6666 - `mm --ask` ne télécharge pas : lancez une fois le coordinateur (`-p` ou le REPL) pour télécharger les modèles de l'équipe.
6767 - Dans un pipe ou un log, la progression tient en une ligne par dixième au lieu d'une ligne réécrite sur place.
6868
@@ -62,7 +62,7 @@ llmman garde dans son cache le fichier d'un modèle supprimé : après `llmman r
62 62
63 ## Limites63 ## Limites
64 64
65-- llmman seulement. Avec un autre fournisseur, `autoPull` donne un avertissement et mm continue (tickets 162 Ollama, 163 Docker Model Runner, 164 llama.cpp).65+- La même clé marche avec Ollama et Docker Model Runner ; avec llama.cpp elle ne peut que dire quoi faire (lancer llama-server avec `-hf`). Cette démo utilise llmman.
66 - `mm --ask` ne télécharge pas : lancez une fois le coordinateur (`-p` ou le REPL) pour télécharger les modèles de l'équipe.66 - `mm --ask` ne télécharge pas : lancez une fois le coordinateur (`-p` ou le REPL) pour télécharger les modèles de l'équipe.
67 - Dans un pipe ou un log, la progression tient en une ligne par dixième au lieu d'une ligne réécrite sur place.67 - Dans un pipe ou un log, la progression tient en une ligne par dixième au lieu d'une ligne réécrite sur place.
68 68
modified docs/en/how-to/run-with-llama-cpp.md +1 -0
@@ -23,6 +23,7 @@ This guide shows how to point `mm` at a `llama-server` you start yourself. It as
2323 ## Variants
2424
2525 - **Different port or host.** Set `baseUrl` in the YAML to `http://<host>:<port>/v1`, or override it for one run with `AGENT_BASE_URL=http://<host>:<port>/v1 ./mm agent.llamacpp.yaml`. An environment override is taken as-is, without the fallback probe.
26+- **`autoPull: true`.** llama-server cannot download a model over its API: it serves the one it was started with. `-hf <repo>:<quant>` (step 1) is what downloads it. With `autoPull` on and a `model:` the server does not list, mm says so at start-up and goes on. See [`autoPull`](../reference/configuration.md).
2627 - **Server started with `--api-key`.** Export the key in `LLAMA_API_KEY` (the provider's default variable) or name another variable under `apiKeyEnv`. The key itself never goes in the YAML.
2728 - **Keep `agent.yaml` and switch provider for one run.** `AGENT_PROVIDER=llamacpp ./mm` works as long as `agent.yaml` leaves `baseUrl` empty; an explicit DMR URL in the file would still be honoured and point at port 12434.
2829 - **The server hides `/props`** (a reverse proxy, for instance). Set `contextWindow` to the value you started the server with, otherwise the banner shows `ctx: unknown`.
@@ -23,6 +23,7 @@ This guide shows how to point `mm` at a `llama-server` you start yourself. It as
23 ## Variants23 ## Variants
24 24
25 - **Different port or host.** Set `baseUrl` in the YAML to `http://<host>:<port>/v1`, or override it for one run with `AGENT_BASE_URL=http://<host>:<port>/v1 ./mm agent.llamacpp.yaml`. An environment override is taken as-is, without the fallback probe.25 - **Different port or host.** Set `baseUrl` in the YAML to `http://<host>:<port>/v1`, or override it for one run with `AGENT_BASE_URL=http://<host>:<port>/v1 ./mm agent.llamacpp.yaml`. An environment override is taken as-is, without the fallback probe.
26+- **`autoPull: true`.** llama-server cannot download a model over its API: it serves the one it was started with. `-hf <repo>:<quant>` (step 1) is what downloads it. With `autoPull` on and a `model:` the server does not list, mm says so at start-up and goes on. See [`autoPull`](../reference/configuration.md).
26 - **Server started with `--api-key`.** Export the key in `LLAMA_API_KEY` (the provider's default variable) or name another variable under `apiKeyEnv`. The key itself never goes in the YAML.27 - **Server started with `--api-key`.** Export the key in `LLAMA_API_KEY` (the provider's default variable) or name another variable under `apiKeyEnv`. The key itself never goes in the YAML.
27 - **Keep `agent.yaml` and switch provider for one run.** `AGENT_PROVIDER=llamacpp ./mm` works as long as `agent.yaml` leaves `baseUrl` empty; an explicit DMR URL in the file would still be honoured and point at port 12434.28 - **Keep `agent.yaml` and switch provider for one run.** `AGENT_PROVIDER=llamacpp ./mm` works as long as `agent.yaml` leaves `baseUrl` empty; an explicit DMR URL in the file would still be honoured and point at port 12434.
28 - **The server hides `/props`** (a reverse proxy, for instance). Set `contextWindow` to the value you started the server with, otherwise the banner shows `ctx: unknown`.29 - **The server hides `/props`** (a reverse proxy, for instance). Set `contextWindow` to the value you started the server with, otherwise the banner shows `ctx: unknown`.
modified docs/en/how-to/run-with-ollama.md +1 -0
@@ -24,6 +24,7 @@ This guide shows how to point `mm` at [Ollama](https://ollama.com) through its O
2424 ## Variants
2525
2626 - **Another model.** Set `model:` to the name exactly as `ollama list` spells it (`qwen3.5:9b`, …).
27+- **Let mm pull the model.** Ollama does not pull a missing model on the first question: it fails. With `autoPull: true`, mm pulls it at start-up through `/api/pull`, with the progress (`⬇ pulling qwen3.5:2b — 0.6 GB / 1.9 GB (32 %)`). See [`autoPull`](../reference/configuration.md).
2728 - **From a Docker sandbox.** Nothing to change: when `localhost:11434` does not answer, `mm` falls back to `http://host.docker.internal:11434/v1`, the Ollama on the host.
2829 - **A larger window.** The OpenAI API cannot set `num_ctx` per request: start Ollama with `OLLAMA_CONTEXT_LENGTH`, and put the same value in `contextWindow` if you want compression to know it from the first question.
2930 - **ollama.com's API.** Set `baseUrl` to its OpenAI endpoint and export the key in `OLLAMA_API_KEY` (or name another variable under `apiKeyEnv`).
@@ -24,6 +24,7 @@ This guide shows how to point `mm` at [Ollama](https://ollama.com) through its O
24 ## Variants24 ## Variants
25 25
26 - **Another model.** Set `model:` to the name exactly as `ollama list` spells it (`qwen3.5:9b`, …).26 - **Another model.** Set `model:` to the name exactly as `ollama list` spells it (`qwen3.5:9b`, …).
27+- **Let mm pull the model.** Ollama does not pull a missing model on the first question: it fails. With `autoPull: true`, mm pulls it at start-up through `/api/pull`, with the progress (`⬇ pulling qwen3.5:2b — 0.6 GB / 1.9 GB (32 %)`). See [`autoPull`](../reference/configuration.md).
27 - **From a Docker sandbox.** Nothing to change: when `localhost:11434` does not answer, `mm` falls back to `http://host.docker.internal:11434/v1`, the Ollama on the host.28 - **From a Docker sandbox.** Nothing to change: when `localhost:11434` does not answer, `mm` falls back to `http://host.docker.internal:11434/v1`, the Ollama on the host.
28 - **A larger window.** The OpenAI API cannot set `num_ctx` per request: start Ollama with `OLLAMA_CONTEXT_LENGTH`, and put the same value in `contextWindow` if you want compression to know it from the first question.29 - **A larger window.** The OpenAI API cannot set `num_ctx` per request: start Ollama with `OLLAMA_CONTEXT_LENGTH`, and put the same value in `contextWindow` if you want compression to know it from the first question.
29 - **ollama.com's API.** Set `baseUrl` to its OpenAI endpoint and export the key in `OLLAMA_API_KEY` (or name another variable under `apiKeyEnv`).30 - **ollama.com's API.** Set `baseUrl` to its OpenAI endpoint and export the key in `OLLAMA_API_KEY` (or name another variable under `apiKeyEnv`).
modified docs/en/reference/configuration.md +1 -1
@@ -32,7 +32,7 @@
3232 | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). |
3333 | `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). |
3434 | `unloadModels` | bool | `true` | The `/unload [model]` command (terminal, window, editor) and the `unload_model` tool, which free a model from the server's memory: llmman and Ollama, llama-server in router mode. `false` removes both. The tool is never declared beside a `structuredOutput` or a `decision` block. See [tools](tools.md#unload_model) and [How to free a model's memory](../how-to/unload-a-model.md). |
35-| `autoPull` | bool | `false` | At start-up, download the models this agent will use and the server does not list: its own, its `mmConfigs` agents', its `skillRouting` / `toolRouting` deciders'. The progress goes to stderr — one line rewritten in place on a terminal (`⬇ pulling <model> — 52 MB / 145 MB (35 %)`), a line per tenth elsewhere — then `⬇ pulled <model> in 0.8s`. The agent's own model failing stops `mm` with the server's message (`[engine error: autoPull: <model>: the server says: …]`); another model failing, a server that does not answer, or a provider that cannot pull is a `[warning: autoPull: …]`. An agent started by `/ask` or `ask_agent` pulls nothing. llmman only (`POST /api/pull`); other providers: a warning. |
35+| `autoPull` | bool | `false` | At start-up, download the models this agent will use and the server does not list: its own, its `mmConfigs` agents', its `skillRouting` / `toolRouting` deciders'. The progress goes to stderr — one line rewritten in place on a terminal (`⬇ pulling <model> — 52 MB / 145 MB (35 %)`), a line per tenth elsewhere — then `⬇ pulled <model> in 0.8s`. The agent's own model failing stops `mm` with the server's message (`[engine error: autoPull: <model>: the server says: …]`); another model failing, a server that does not answer, or a provider that cannot pull is a `[warning: autoPull: …]`. An agent started by `/ask` or `ask_agent` pulls nothing; `mm --ask` pulls nothing either. A model the server already lists is never pulled (a name without a tag also matches its `:latest` entry). By provider: `llmman` and `ollama` — `POST /api/pull`; `dmr` — `POST /models/create` on the runner (the base URL without `/engines/…`), the pull counting as done once the runner lists the model; `llamacpp` — no download over the API: the warning says to start llama-server with `-hf <repo>:<quant>`, or to put the GGUF in the router's models directory; `openai` — a warning. |
3636 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
3737 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
3838 | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). |
@@ -32,7 +32,7 @@
32 | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). |32 | `askUserTool` | bool | `true` | Declare `ask_user`, through which the model asks the user questions (pick one, pick several, type a text, yes or no) and waits for the answers. `false` removes it: the model cannot ask. Never declared beside a `structuredOutput`. Overridden by `AGENT_ASK_USER`; for an agent asked by another one, also by the asker's `mmConfigs.<name>.askUser`. See [tools](tools.md#ask_user). |
33 | `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). |33 | `saveAnswerTool` | bool | `false` | Declare `save_answer`, which writes an answer the model already gave to a new file: the model sends a file name, not the text again. Off by default. The `/save` command exists either way. See [tools](tools.md#save_answer). |
34 | `unloadModels` | bool | `true` | The `/unload [model]` command (terminal, window, editor) and the `unload_model` tool, which free a model from the server's memory: llmman and Ollama, llama-server in router mode. `false` removes both. The tool is never declared beside a `structuredOutput` or a `decision` block. See [tools](tools.md#unload_model) and [How to free a model's memory](../how-to/unload-a-model.md). |34 | `unloadModels` | bool | `true` | The `/unload [model]` command (terminal, window, editor) and the `unload_model` tool, which free a model from the server's memory: llmman and Ollama, llama-server in router mode. `false` removes both. The tool is never declared beside a `structuredOutput` or a `decision` block. See [tools](tools.md#unload_model) and [How to free a model's memory](../how-to/unload-a-model.md). |
35-| `autoPull` | bool | `false` | At start-up, download the models this agent will use and the server does not list: its own, its `mmConfigs` agents', its `skillRouting` / `toolRouting` deciders'. The progress goes to stderr — one line rewritten in place on a terminal (`⬇ pulling <model> — 52 MB / 145 MB (35 %)`), a line per tenth elsewhere — then `⬇ pulled <model> in 0.8s`. The agent's own model failing stops `mm` with the server's message (`[engine error: autoPull: <model>: the server says: …]`); another model failing, a server that does not answer, or a provider that cannot pull is a `[warning: autoPull: …]`. An agent started by `/ask` or `ask_agent` pulls nothing. llmman only (`POST /api/pull`); other providers: a warning. |35+| `autoPull` | bool | `false` | At start-up, download the models this agent will use and the server does not list: its own, its `mmConfigs` agents', its `skillRouting` / `toolRouting` deciders'. The progress goes to stderr — one line rewritten in place on a terminal (`⬇ pulling <model> — 52 MB / 145 MB (35 %)`), a line per tenth elsewhere — then `⬇ pulled <model> in 0.8s`. The agent's own model failing stops `mm` with the server's message (`[engine error: autoPull: <model>: the server says: …]`); another model failing, a server that does not answer, or a provider that cannot pull is a `[warning: autoPull: …]`. An agent started by `/ask` or `ask_agent` pulls nothing; `mm --ask` pulls nothing either. A model the server already lists is never pulled (a name without a tag also matches its `:latest` entry). By provider: `llmman` and `ollama` — `POST /api/pull`; `dmr` — `POST /models/create` on the runner (the base URL without `/engines/…`), the pull counting as done once the runner lists the model; `llamacpp` — no download over the API: the warning says to start llama-server with `-hf <repo>:<quant>`, or to put the GGUF in the router's models directory; `openai` — a warning. |
36 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |36 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. A key the OpenAI API does not declare is sent as it is, at the top level of the request — for example `chat_template_kwargs: {enable_thinking: false}`, which tells a llama.cpp server (`dmr`, `llamacpp`, `llmman`) not to reason; `reasoning_effort: none` does the same on Ollama. `messages`, `tools`, `tool_choice` (and the legacy `functions`, `function_call`) are ignored: Genkit builds them from the conversation. The shipped `agent.yaml` adds `parallel_tool_calls: false`. `max_tokens` also bounds one tool call: a `write_file` of a long document (or a thinking model's reasoning plus the call) can hit it, and the call is then cut off. mm hands it back to the model twice, asking for smaller pieces, then stops with `the model hit max_tokens (N) while writing a tool call`; raise the value if that happens. With streaming, mm also asks the server for token counts (`stream_options.include_usage`). |
37 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |37 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
38 | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). |38 | `firstTokenTimeout` | duration | `3m` | Silence tolerated before a model call's first token, while the server reads the prompt (every round trip of a turn has one). Never shorter than `watchdogTimeout`; must be >= 0. With a llama.cpp server (`dmr`, `llamacpp`, `llmman` — whose local GGUF models run in llama-server) mm asks for `return_progress`: each progress report restarts this clock, and the spinner shows `Reading the prompt 12.0k/25.3k tokens`. Ollama sends no progress: this one wait covers the whole prompt (and the model's loading). On a timeout the watchdog says `no first token from the server after …` (before the first token) or `connection hang detected after …` (after). |
modified docs/fr/how-to/run-with-llama-cpp.md +1 -0
@@ -23,6 +23,7 @@ Ce guide montre comment pointer `mm` vers un `llama-server` que vous démarrez v
2323 ## Variantes
2424
2525 - **Autre port ou autre hôte.** Mettez `baseUrl` à `http://<hôte>:<port>/v1` dans le YAML, ou surchargez pour une seule exécution avec `AGENT_BASE_URL=http://<hôte>:<port>/v1 ./mm agent.llamacpp.yaml`. Une surcharge par l'environnement est prise telle quelle, sans sonde de repli.
26+- **`autoPull: true`.** llama-server ne sait pas télécharger un modèle par son API : il sert celui avec lequel il a été lancé. C'est `-hf <dépôt>:<quant>` (étape 1) qui le télécharge. Avec `autoPull` actif et un `model:` que le serveur ne liste pas, mm le dit au démarrage et continue. Voir [`autoPull`](../reference/configuration.md).
2627 - **Serveur démarré avec `--api-key`.** Exportez la clé dans `LLAMA_API_KEY` (la variable par défaut du fournisseur) ou nommez une autre variable dans `apiKeyEnv`. La clé elle-même ne va jamais dans le YAML.
2728 - **Garder `agent.yaml` et changer de fournisseur pour une exécution.** `AGENT_PROVIDER=llamacpp ./mm` fonctionne tant qu'`agent.yaml` laisse `baseUrl` vide ; une URL DMR explicite dans le fichier serait respectée et pointerait toujours sur le port 12434.
2829 - **Le serveur masque `/props`** (un reverse proxy, par exemple). Fixez `contextWindow` à la valeur avec laquelle vous avez démarré le serveur, sinon la bannière affiche `ctx: unknown`.
@@ -23,6 +23,7 @@ Ce guide montre comment pointer `mm` vers un `llama-server` que vous démarrez v
23 ## Variantes23 ## Variantes
24 24
25 - **Autre port ou autre hôte.** Mettez `baseUrl` à `http://<hôte>:<port>/v1` dans le YAML, ou surchargez pour une seule exécution avec `AGENT_BASE_URL=http://<hôte>:<port>/v1 ./mm agent.llamacpp.yaml`. Une surcharge par l'environnement est prise telle quelle, sans sonde de repli.25 - **Autre port ou autre hôte.** Mettez `baseUrl` à `http://<hôte>:<port>/v1` dans le YAML, ou surchargez pour une seule exécution avec `AGENT_BASE_URL=http://<hôte>:<port>/v1 ./mm agent.llamacpp.yaml`. Une surcharge par l'environnement est prise telle quelle, sans sonde de repli.
26+- **`autoPull: true`.** llama-server ne sait pas télécharger un modèle par son API : il sert celui avec lequel il a été lancé. C'est `-hf <dépôt>:<quant>` (étape 1) qui le télécharge. Avec `autoPull` actif et un `model:` que le serveur ne liste pas, mm le dit au démarrage et continue. Voir [`autoPull`](../reference/configuration.md).
26 - **Serveur démarré avec `--api-key`.** Exportez la clé dans `LLAMA_API_KEY` (la variable par défaut du fournisseur) ou nommez une autre variable dans `apiKeyEnv`. La clé elle-même ne va jamais dans le YAML.27 - **Serveur démarré avec `--api-key`.** Exportez la clé dans `LLAMA_API_KEY` (la variable par défaut du fournisseur) ou nommez une autre variable dans `apiKeyEnv`. La clé elle-même ne va jamais dans le YAML.
27 - **Garder `agent.yaml` et changer de fournisseur pour une exécution.** `AGENT_PROVIDER=llamacpp ./mm` fonctionne tant qu'`agent.yaml` laisse `baseUrl` vide ; une URL DMR explicite dans le fichier serait respectée et pointerait toujours sur le port 12434.28 - **Garder `agent.yaml` et changer de fournisseur pour une exécution.** `AGENT_PROVIDER=llamacpp ./mm` fonctionne tant qu'`agent.yaml` laisse `baseUrl` vide ; une URL DMR explicite dans le fichier serait respectée et pointerait toujours sur le port 12434.
28 - **Le serveur masque `/props`** (un reverse proxy, par exemple). Fixez `contextWindow` à la valeur avec laquelle vous avez démarré le serveur, sinon la bannière affiche `ctx: unknown`.29 - **Le serveur masque `/props`** (un reverse proxy, par exemple). Fixez `contextWindow` à la valeur avec laquelle vous avez démarré le serveur, sinon la bannière affiche `ctx: unknown`.
modified docs/fr/how-to/run-with-ollama.md +1 -0
@@ -24,6 +24,7 @@ Ce guide montre comment brancher `mm` sur [Ollama](https://ollama.com) via son p
2424 ## Variantes
2525
2626 - **Un autre modèle.** Mettez dans `model:` le nom exactement tel que `ollama list` l'écrit (`qwen3.5:9b`, …).
27+- **Laisser mm télécharger le modèle.** Ollama ne télécharge pas un modèle manquant à la première question : elle échoue. Avec `autoPull: true`, mm le télécharge au démarrage par `/api/pull`, avec la progression (`⬇ pulling qwen3.5:2b — 0.6 GB / 1.9 GB (32 %)`). Voir [`autoPull`](../reference/configuration.md).
2728 - **Depuis un Docker Sandbox.** Rien à changer : quand `localhost:11434` ne répond pas, `mm` se replie sur `http://host.docker.internal:11434/v1`, l'Ollama de l'hôte.
2829 - **Une fenêtre plus grande.** L'API OpenAI ne peut pas régler `num_ctx` par requête : démarrez Ollama avec `OLLAMA_CONTEXT_LENGTH`, et mettez la même valeur dans `contextWindow` si vous voulez que la compression la connaisse dès la première question.
2930 - **L'API d'ollama.com.** Mettez son point d'accès OpenAI dans `baseUrl` et exportez la clé dans `OLLAMA_API_KEY` (ou nommez une autre variable dans `apiKeyEnv`).
@@ -24,6 +24,7 @@ Ce guide montre comment brancher `mm` sur [Ollama](https://ollama.com) via son p
24 ## Variantes24 ## Variantes
25 25
26 - **Un autre modèle.** Mettez dans `model:` le nom exactement tel que `ollama list` l'écrit (`qwen3.5:9b`, …).26 - **Un autre modèle.** Mettez dans `model:` le nom exactement tel que `ollama list` l'écrit (`qwen3.5:9b`, …).
27+- **Laisser mm télécharger le modèle.** Ollama ne télécharge pas un modèle manquant à la première question : elle échoue. Avec `autoPull: true`, mm le télécharge au démarrage par `/api/pull`, avec la progression (`⬇ pulling qwen3.5:2b — 0.6 GB / 1.9 GB (32 %)`). Voir [`autoPull`](../reference/configuration.md).
27 - **Depuis un Docker Sandbox.** Rien à changer : quand `localhost:11434` ne répond pas, `mm` se replie sur `http://host.docker.internal:11434/v1`, l'Ollama de l'hôte.28 - **Depuis un Docker Sandbox.** Rien à changer : quand `localhost:11434` ne répond pas, `mm` se replie sur `http://host.docker.internal:11434/v1`, l'Ollama de l'hôte.
28 - **Une fenêtre plus grande.** L'API OpenAI ne peut pas régler `num_ctx` par requête : démarrez Ollama avec `OLLAMA_CONTEXT_LENGTH`, et mettez la même valeur dans `contextWindow` si vous voulez que la compression la connaisse dès la première question.29 - **Une fenêtre plus grande.** L'API OpenAI ne peut pas régler `num_ctx` par requête : démarrez Ollama avec `OLLAMA_CONTEXT_LENGTH`, et mettez la même valeur dans `contextWindow` si vous voulez que la compression la connaisse dès la première question.
29 - **L'API d'ollama.com.** Mettez son point d'accès OpenAI dans `baseUrl` et exportez la clé dans `OLLAMA_API_KEY` (ou nommez une autre variable dans `apiKeyEnv`).30 - **L'API d'ollama.com.** Mettez son point d'accès OpenAI dans `baseUrl` et exportez la clé dans `OLLAMA_API_KEY` (ou nommez une autre variable dans `apiKeyEnv`).
modified docs/fr/reference/configuration.md +1 -1
@@ -32,7 +32,7 @@
3232 | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). |
3333 | `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). |
3434 | `unloadModels` | bool | `true` | La commande `/unload [modèle]` (terminal, fenêtre, éditeur) et l'outil `unload_model`, qui libèrent un modèle de la mémoire du serveur : llmman et Ollama, llama-server en mode router. `false` retire les deux. L'outil n'est jamais déclaré à côté d'une `structuredOutput` ni d'un bloc `decision`. Voir les [outils](tools.md#unload_model) et [Libérer la mémoire d'un modèle](../how-to/unload-a-model.md). |
35-| `autoPull` | booléen | `false` | Au démarrage, télécharge les modèles que cet agent utilisera et que le serveur ne liste pas : le sien, ceux de ses agents `mmConfigs`, ceux de ses deciders `skillRouting` / `toolRouting`. La progression va sur stderr — une ligne réécrite sur place dans un terminal (`⬇ pulling <modèle> — 52 MB / 145 MB (35 %)`), une ligne par dixième ailleurs — puis `⬇ pulled <modèle> in 0.8s`. Un échec sur le modèle de l'agent arrête `mm` avec le message du serveur (`[engine error: autoPull: <modèle>: the server says: …]`) ; un échec sur un autre modèle, un serveur qui ne répond pas ou un fournisseur qui ne sait pas télécharger donne un `[warning: autoPull: …]`. Un agent lancé par `/ask` ou `ask_agent` ne télécharge rien. llmman seulement (`POST /api/pull`) ; les autres fournisseurs : un avertissement. |
35+| `autoPull` | booléen | `false` | Au démarrage, télécharge les modèles que cet agent utilisera et que le serveur ne liste pas : le sien, ceux de ses agents `mmConfigs`, ceux de ses deciders `skillRouting` / `toolRouting`. La progression va sur stderr — une ligne réécrite sur place dans un terminal (`⬇ pulling <modèle> — 52 MB / 145 MB (35 %)`), une ligne par dixième ailleurs — puis `⬇ pulled <modèle> in 0.8s`. Un échec sur le modèle de l'agent arrête `mm` avec le message du serveur (`[engine error: autoPull: <modèle>: the server says: …]`) ; un échec sur un autre modèle, un serveur qui ne répond pas ou un fournisseur qui ne sait pas télécharger donne un `[warning: autoPull: …]`. Un agent lancé par `/ask` ou `ask_agent` ne télécharge rien ; `mm --ask` non plus. Un modèle que le serveur liste déjà n'est jamais téléchargé (un nom sans tag correspond aussi à son entrée `:latest`). Par fournisseur : `llmman` et `ollama` — `POST /api/pull` ; `dmr` — `POST /models/create` sur le runner (l'URL de base sans `/engines/…`), le téléchargement ne comptant comme fait qu'une fois le modèle listé par le runner ; `llamacpp` — pas de téléchargement par l'API : l'avertissement dit de lancer llama-server avec `-hf <dépôt>:<quant>`, ou de mettre le GGUF dans le dossier de modèles du router ; `openai` — un avertissement. |
3636 | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
3737 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
3838 | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). |
@@ -32,7 +32,7 @@
32 | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). |32 | `askUserTool` | booléen | `true` | Déclare `ask_user`, par lequel le modèle pose des questions à l'utilisateur (choisir une option, en choisir plusieurs, taper un texte, oui ou non) et attend les réponses. `false` le retire : le modèle ne peut pas demander. Jamais déclaré à côté d'un `structuredOutput`. Surchargé par `AGENT_ASK_USER` ; pour un agent interrogé par un autre, aussi par le `mmConfigs.<nom>.askUser` de celui qui l'interroge. Voir [outils](tools.md#ask_user). |
33 | `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). |33 | `saveAnswerTool` | bool | `false` | Déclare `save_answer`, qui écrit dans un nouveau fichier une réponse que le modèle a déjà donnée : le modèle envoie un nom de fichier, pas le texte une seconde fois. Désactivé par défaut. La commande `/save` existe dans tous les cas. Voir les [outils](tools.md#save_answer). |
34 | `unloadModels` | bool | `true` | La commande `/unload [modèle]` (terminal, fenêtre, éditeur) et l'outil `unload_model`, qui libèrent un modèle de la mémoire du serveur : llmman et Ollama, llama-server en mode router. `false` retire les deux. L'outil n'est jamais déclaré à côté d'une `structuredOutput` ni d'un bloc `decision`. Voir les [outils](tools.md#unload_model) et [Libérer la mémoire d'un modèle](../how-to/unload-a-model.md). |34 | `unloadModels` | bool | `true` | La commande `/unload [modèle]` (terminal, fenêtre, éditeur) et l'outil `unload_model`, qui libèrent un modèle de la mémoire du serveur : llmman et Ollama, llama-server en mode router. `false` retire les deux. L'outil n'est jamais déclaré à côté d'une `structuredOutput` ni d'un bloc `decision`. Voir les [outils](tools.md#unload_model) et [Libérer la mémoire d'un modèle](../how-to/unload-a-model.md). |
35-| `autoPull` | booléen | `false` | Au démarrage, télécharge les modèles que cet agent utilisera et que le serveur ne liste pas : le sien, ceux de ses agents `mmConfigs`, ceux de ses deciders `skillRouting` / `toolRouting`. La progression va sur stderr — une ligne réécrite sur place dans un terminal (`⬇ pulling <modèle> — 52 MB / 145 MB (35 %)`), une ligne par dixième ailleurs — puis `⬇ pulled <modèle> in 0.8s`. Un échec sur le modèle de l'agent arrête `mm` avec le message du serveur (`[engine error: autoPull: <modèle>: the server says: …]`) ; un échec sur un autre modèle, un serveur qui ne répond pas ou un fournisseur qui ne sait pas télécharger donne un `[warning: autoPull: …]`. Un agent lancé par `/ask` ou `ask_agent` ne télécharge rien. llmman seulement (`POST /api/pull`) ; les autres fournisseurs : un avertissement. |35+| `autoPull` | booléen | `false` | Au démarrage, télécharge les modèles que cet agent utilisera et que le serveur ne liste pas : le sien, ceux de ses agents `mmConfigs`, ceux de ses deciders `skillRouting` / `toolRouting`. La progression va sur stderr — une ligne réécrite sur place dans un terminal (`⬇ pulling <modèle> — 52 MB / 145 MB (35 %)`), une ligne par dixième ailleurs — puis `⬇ pulled <modèle> in 0.8s`. Un échec sur le modèle de l'agent arrête `mm` avec le message du serveur (`[engine error: autoPull: <modèle>: the server says: …]`) ; un échec sur un autre modèle, un serveur qui ne répond pas ou un fournisseur qui ne sait pas télécharger donne un `[warning: autoPull: …]`. Un agent lancé par `/ask` ou `ask_agent` ne télécharge rien ; `mm --ask` non plus. Un modèle que le serveur liste déjà n'est jamais téléchargé (un nom sans tag correspond aussi à son entrée `:latest`). Par fournisseur : `llmman` et `ollama` — `POST /api/pull` ; `dmr` — `POST /models/create` sur le runner (l'URL de base sans `/engines/…`), le téléchargement ne comptant comme fait qu'une fois le modèle listé par le runner ; `llamacpp` — pas de téléchargement par l'API : l'avertissement dit de lancer llama-server avec `-hf <dépôt>:<quant>`, ou de mettre le GGUF dans le dossier de modèles du router ; `openai` — un avertissement. |
36 | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |36 | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. Une clé que l'API OpenAI ne déclare pas est envoyée telle quelle, au premier niveau de la requête — par exemple `chat_template_kwargs: {enable_thinking: false}`, qui demande à un serveur llama.cpp (`dmr`, `llamacpp`, `llmman`) de ne pas raisonner ; `reasoning_effort: none` fait de même avec Ollama. `messages`, `tools`, `tool_choice` (et les anciens `functions`, `function_call`) sont ignorées : Genkit les construit à partir de la conversation. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. `max_tokens` borne aussi un appel d'outil : un `write_file` de long document (ou le raisonnement d'un modèle « thinking » plus l'appel) peut l'atteindre, et l'appel est alors coupé. mm le rend deux fois au modèle en lui demandant de procéder par morceaux, puis s'arrête avec `the model hit max_tokens (N) while writing a tool call` ; augmentez la valeur si cela arrive. En streaming, mm demande aussi au serveur le compte des tokens (`stream_options.include_usage`). |
37 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |37 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
38 | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). |38 | `firstTokenTimeout` | durée | `3m` | Silence toléré avant le premier token d'un appel au modèle, pendant que le serveur lit le prompt (chaque aller-retour d'un tour en a un). Jamais plus court que `watchdogTimeout` ; doit être >= 0. Avec un serveur llama.cpp (`dmr`, `llamacpp`, `llmman` — dont les modèles GGUF locaux tournent dans llama-server), mm demande `return_progress` : chaque rapport d'avancement relance ce délai, et le spinner affiche `Reading the prompt 12.0k/25.3k tokens`. Ollama n'envoie pas d'avancement : cette seule attente couvre tout le prompt (et le chargement du modèle). À l'expiration, le watchdog dit `no first token from the server after …` (avant le premier token) ou `connection hang detected after …` (après). |
modified internal/config/config.go +3 -2
@@ -90,8 +90,9 @@ type Config struct {
9090 // AutoPull: at start-up, download the models this mm will use and the
9191 // server does not have yet — its own, its mmConfigs agents', its routing
9292 // deciders' — showing the progress (ticket 61). Off by default: a
93- // download of several GB is not something to start unasked. llmman only
94- // for now (tickets 162, 163 for Ollama and Docker Model Runner).
93+ // download of several GB is not something to start unasked. llmman,
94+ // Ollama and Docker Model Runner pull; llama-server cannot (tickets 61,
95+ // 162, 163, 164 — engine/pull.go).
9596 AutoPull bool `yaml:"autoPull"`
9697
9798 // System: the system prompt — what the agent is, and what it may do.
@@ -90,8 +90,9 @@ type Config struct {
90 // AutoPull: at start-up, download the models this mm will use and the90 // AutoPull: at start-up, download the models this mm will use and the
91 // server does not have yet — its own, its mmConfigs agents', its routing91 // server does not have yet — its own, its mmConfigs agents', its routing
92 // deciders' — showing the progress (ticket 61). Off by default: a92 // deciders' — showing the progress (ticket 61). Off by default: a
93- // download of several GB is not something to start unasked. llmman only93+ // download of several GB is not something to start unasked. llmman,
94- // for now (tickets 162, 163 for Ollama and Docker Model Runner).94+ // Ollama and Docker Model Runner pull; llama-server cannot (tickets 61,
95+ // 162, 163, 164 — engine/pull.go).
95 AutoPull bool `yaml:"autoPull"`96 AutoPull bool `yaml:"autoPull"`
96 97
97 // System: the system prompt — what the agent is, and what it may do.98 // System: the system prompt — what the agent is, and what it may do.
modified internal/engine/openai_compat.go +7 -2
@@ -64,8 +64,10 @@ type openaiCompat struct {
6464 unload unloadFunc
6565
6666 // pull: how this server downloads a model it does not have (pull.go,
67- // `autoPull`); nil = mm cannot ask it to.
68- pull pullFunc
67+ // `autoPull`); nil = mm cannot ask it to. noPull is then the advice the
68+ // warning gives instead ("" = none).
69+ pull pullFunc
70+ noPull string
6971 }
7072
7173 // DefaultURLs returns the address a provider uses when the config gives no
@@ -96,6 +98,7 @@ func init() {
9698 probe: probeModels,
9799 progress: true,
98100 thinkingOff: thinkingOffTemplate,
101+ pull: pullDMR,
99102 })
100103 register(&openaiCompat{
101104 name: "llamacpp",
@@ -110,6 +113,7 @@ func init() {
110113 progress: true,
111114 thinkingOff: thinkingOffTemplate,
112115 unload: unloadRouter,
116+ noPull: "llama-server does not download a model over its API: it serves the model it was started with — start it with -hf <repo>:<quant> (it downloads the model then), or, in router mode, put the GGUF in its models directory",
113117 })
114118 register(&openaiCompat{
115119 name: "llmman",
@@ -147,6 +151,7 @@ func init() {
147151 progress: false,
148152 thinkingOff: thinkingOffEffort,
149153 unload: unloadKeepAlive,
154+ pull: pullOllamaAPI,
150155 })
151156 }
152157
@@ -64,8 +64,10 @@ type openaiCompat struct {
64 unload unloadFunc64 unload unloadFunc
65 65
66 // pull: how this server downloads a model it does not have (pull.go,66 // pull: how this server downloads a model it does not have (pull.go,
67- // `autoPull`); nil = mm cannot ask it to.67+ // `autoPull`); nil = mm cannot ask it to. noPull is then the advice the
68- pull pullFunc68+ // warning gives instead ("" = none).
69+ pull pullFunc
70+ noPull string
69 }71 }
70 72
71 // DefaultURLs returns the address a provider uses when the config gives no73 // DefaultURLs returns the address a provider uses when the config gives no
@@ -96,6 +98,7 @@ func init() {
96 probe: probeModels,98 probe: probeModels,
97 progress: true,99 progress: true,
98 thinkingOff: thinkingOffTemplate,100 thinkingOff: thinkingOffTemplate,
101+ pull: pullDMR,
99 })102 })
100 register(&openaiCompat{103 register(&openaiCompat{
101 name: "llamacpp",104 name: "llamacpp",
@@ -110,6 +113,7 @@ func init() {
110 progress: true,113 progress: true,
111 thinkingOff: thinkingOffTemplate,114 thinkingOff: thinkingOffTemplate,
112 unload: unloadRouter,115 unload: unloadRouter,
116+ noPull: "llama-server does not download a model over its API: it serves the model it was started with — start it with -hf <repo>:<quant> (it downloads the model then), or, in router mode, put the GGUF in its models directory",
113 })117 })
114 register(&openaiCompat{118 register(&openaiCompat{
115 name: "llmman",119 name: "llmman",
@@ -147,6 +151,7 @@ func init() {
147 progress: false,151 progress: false,
148 thinkingOff: thinkingOffEffort,152 thinkingOff: thinkingOffEffort,
149 unload: unloadKeepAlive,153 unload: unloadKeepAlive,
154+ pull: pullOllamaAPI,
150 })155 })
151 }156 }
152 157
modified internal/engine/pull.go +129 -7
@@ -18,13 +18,17 @@ import (
1818 // first question that waits minutes on a silent download (llmman pulls on
1919 // the first request by itself) — or that fails on a misspelt name.
2020 //
21-// - llmman: POST /api/pull {"model": m}, Ollama's streamed format, one JSON
22-// object per line (measured on llmman 0.1.571, 2026-10-25):
21+// - llmman and Ollama (ticket 162): POST /api/pull {"model": m}, Ollama's
22+// streamed format, one JSON object per line (measured on llmman 0.1.571,
23+// 2026-10-25; Ollama documents the same):
2324 // {"status":"pulling manifest"}
2425 // {"completed":0,"status":"pulling","total":105454144}
2526 // …
2627 // {"status":"success"} — or {"error":"HF model info: … HTTP 401"}
27-// - the other servers: no pull here yet (tickets 162 Ollama, 163 DMR).
28+// - Docker Model Runner (ticket 163): POST <runner>/models/create
29+// {"from": m}, the route `docker model pull` uses (pullDMR).
30+// - llama-server (ticket 164): no download over its API; the warning says
31+// what to do instead (noPull).
2832
2933 // PullProgress is one step of a download, as the server reports it.
3034 // Completed and Total are bytes; Total is 0 while the size is unknown.
@@ -83,21 +87,48 @@ func EnsureAgentModel(ctx context.Context, configPath string, progress func(Pull
8387 return cfg.Model, pulled, err
8488 }
8589
90+// ensureModel looks the model up in the server's list FIRST: a server that
91+// has it needs nothing, whatever it can or cannot pull. A missing model is
92+// then pulled, or reported as ErrCannotPull with the provider's advice.
8693 func ensureModel(ctx context.Context, p Provider, b Backend, progress func(PullProgress)) (bool, error) {
8794 compat, ok := p.(*openaiCompat)
88- if !ok || compat.pull == nil {
95+ if !ok {
8996 return false, ErrCannotPull
9097 }
91- var list modelList
92- if err := getWithKey(ctx, b, strings.TrimSuffix(b.BaseURL, "/")+"/models", &list); err != nil {
98+ listed, err := serverLists(ctx, b, b.Model)
99+ if err != nil {
93100 return false, ErrServerDown
94101 }
95- if list.has(b.Model) {
102+ if listed {
96103 return false, nil
97104 }
105+ if compat.pull == nil {
106+ if compat.noPull != "" {
107+ return false, fmt.Errorf("%w — %s", ErrCannotPull, compat.noPull)
108+ }
109+ return false, ErrCannotPull
110+ }
98111 return true, compat.pull(ctx, b, b.Model, progress)
99112 }
100113
114+// serverLists reports whether GET <base>/models lists model. A name without
115+// a tag also matches its ":latest" entry: Ollama and Docker Model Runner
116+// list `qwen3.5` as `qwen3.5:latest`, and pulling it again would be a no-op
117+// at best.
118+func serverLists(ctx context.Context, b Backend, model string) (bool, error) {
119+ var list modelList
120+ if err := getWithKey(ctx, b, strings.TrimSuffix(b.BaseURL, "/")+"/models", &list); err != nil {
121+ return false, err
122+ }
123+ return list.has(model) || (!hasTag(model) && list.has(model+":latest")), nil
124+}
125+
126+// hasTag reports whether the last path element of a model name carries a
127+// ":tag" (`qwen3.5:2b`, `hf.co/…:Q4_K_M`; not `ai/smollm2`).
128+func hasTag(model string) bool {
129+ return strings.Contains(model[strings.LastIndex(model, "/")+1:], ":")
130+}
131+
101132 // pullOllamaAPI is the llmman (and Ollama) way: POST /api/pull, then one
102133 // JSON object per line until "success" or an "error".
103134 func pullOllamaAPI(ctx context.Context, b Backend, model string, progress func(PullProgress)) error {
@@ -160,3 +191,94 @@ func readPullStream(r io.Reader, model string, progress func(PullProgress)) erro
160191 }
161192 return errors.New("the download ended without success")
162193 }
194+
195+// pullDMR is Docker Model Runner's way (ticket 163): POST /models/create
196+// {"from": m} on the runner itself — the base URL without its
197+// /engines/… OpenAI prefix — the route `docker model pull` uses. Its stream
198+// is read leniently (dmrStep): JSON progress lines are shown, an "error"
199+// line ends the pull, anything else is shown as a status. Whatever the
200+// stream said, the pull counts as done only when the runner then LISTS the
201+// model: the confirmation does not depend on the stream's exact format.
202+func pullDMR(ctx context.Context, b Backend, model string, progress func(PullProgress)) error {
203+ body, err := json.Marshal(map[string]string{"from": model})
204+ if err != nil {
205+ return err
206+ }
207+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, dmrRoot(b.BaseURL)+"/models/create", strings.NewReader(string(body)))
208+ if err != nil {
209+ return err
210+ }
211+ req.Header.Set("Content-Type", "application/json")
212+ resp, err := pullClient.Do(req)
213+ if err != nil {
214+ return fmt.Errorf("the server cannot be reached: %w", err)
215+ }
216+ defer func() { _ = resp.Body.Close() }()
217+ if resp.StatusCode/100 != 2 {
218+ text, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
219+ return &httpStatusError{code: resp.StatusCode, body: strings.TrimSpace(string(text))}
220+ }
221+ last, err := readDMRStream(resp.Body, model, progress)
222+ if err != nil {
223+ return err
224+ }
225+ listed, err := serverLists(ctx, b, model)
226+ switch {
227+ case err != nil:
228+ return fmt.Errorf("the download ended, but the model list does not answer: %w", err)
229+ case !listed:
230+ return fmt.Errorf("the download ended (%q), but the server does not list %s", last, model)
231+ }
232+ if progress != nil {
233+ progress(PullProgress{Model: model, Status: "success"})
234+ }
235+ return nil
236+}
237+
238+// readDMRStream reads the runner's progress until the end of the stream and
239+// returns the last status seen. A JSON line {"type": …, "message": …,
240+// "total": …, "pulled": …} is a step; "type": "error" ends the pull with its
241+// message; a line that is not JSON is passed on as a status.
242+func readDMRStream(r io.Reader, model string, progress func(PullProgress)) (string, error) {
243+ scanner := bufio.NewScanner(r)
244+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
245+ last := ""
246+ for scanner.Scan() {
247+ line := strings.TrimSpace(scanner.Text())
248+ if line == "" {
249+ continue
250+ }
251+ step := PullProgress{Model: model, Status: line}
252+ var msg struct {
253+ Type string `json:"type"`
254+ Message string `json:"message"`
255+ Total int64 `json:"total"`
256+ Pulled int64 `json:"pulled"`
257+ }
258+ if json.Unmarshal([]byte(line), &msg) == nil {
259+ if msg.Type == "error" {
260+ return last, fmt.Errorf("the server says: %s", msg.Message)
261+ }
262+ step.Status, step.Completed, step.Total = msg.Message, msg.Pulled, msg.Total
263+ }
264+ last = step.Status
265+ if progress != nil {
266+ progress(step)
267+ }
268+ }
269+ if err := scanner.Err(); err != nil {
270+ return last, fmt.Errorf("the download was cut: %w", err)
271+ }
272+ return last, nil
273+}
274+
275+// dmrRoot is the runner's own address: the base URL cut before "/engines"
276+// (http://localhost:12434/engines/v1 → http://localhost:12434), or without
277+// its "/v1" when there is no "/engines".
278+func dmrRoot(baseURL string) string {
279+ u := strings.TrimSuffix(baseURL, "/")
280+ if i := strings.Index(u, "/engines"); i >= 0 {
281+ return u[:i]
282+ }
283+ return strings.TrimSuffix(u, "/v1")
284+}
@@ -18,13 +18,17 @@ import (
18 // first question that waits minutes on a silent download (llmman pulls on18 // first question that waits minutes on a silent download (llmman pulls on
19 // the first request by itself) — or that fails on a misspelt name.19 // the first request by itself) — or that fails on a misspelt name.
20 //20 //
21-// - llmman: POST /api/pull {"model": m}, Ollama's streamed format, one JSON21+// - llmman and Ollama (ticket 162): POST /api/pull {"model": m}, Ollama's
22-// object per line (measured on llmman 0.1.571, 2026-10-25):22+// streamed format, one JSON object per line (measured on llmman 0.1.571,
23+// 2026-10-25; Ollama documents the same):
23 // {"status":"pulling manifest"}24 // {"status":"pulling manifest"}
24 // {"completed":0,"status":"pulling","total":105454144}25 // {"completed":0,"status":"pulling","total":105454144}
25 // …26 // …
26 // {"status":"success"} — or {"error":"HF model info: … HTTP 401"}27 // {"status":"success"} — or {"error":"HF model info: … HTTP 401"}
27-// - the other servers: no pull here yet (tickets 162 Ollama, 163 DMR).28+// - Docker Model Runner (ticket 163): POST <runner>/models/create
29+// {"from": m}, the route `docker model pull` uses (pullDMR).
30+// - llama-server (ticket 164): no download over its API; the warning says
31+// what to do instead (noPull).
28 32
29 // PullProgress is one step of a download, as the server reports it.33 // PullProgress is one step of a download, as the server reports it.
30 // Completed and Total are bytes; Total is 0 while the size is unknown.34 // Completed and Total are bytes; Total is 0 while the size is unknown.
@@ -83,21 +87,48 @@ func EnsureAgentModel(ctx context.Context, configPath string, progress func(Pull
83 return cfg.Model, pulled, err87 return cfg.Model, pulled, err
84 }88 }
85 89
90+// ensureModel looks the model up in the server's list FIRST: a server that
91+// has it needs nothing, whatever it can or cannot pull. A missing model is
92+// then pulled, or reported as ErrCannotPull with the provider's advice.
86 func ensureModel(ctx context.Context, p Provider, b Backend, progress func(PullProgress)) (bool, error) {93 func ensureModel(ctx context.Context, p Provider, b Backend, progress func(PullProgress)) (bool, error) {
87 compat, ok := p.(*openaiCompat)94 compat, ok := p.(*openaiCompat)
88- if !ok || compat.pull == nil {95+ if !ok {
89 return false, ErrCannotPull96 return false, ErrCannotPull
90 }97 }
91- var list modelList98+ listed, err := serverLists(ctx, b, b.Model)
92- if err := getWithKey(ctx, b, strings.TrimSuffix(b.BaseURL, "/")+"/models", &list); err != nil {99+ if err != nil {
93 return false, ErrServerDown100 return false, ErrServerDown
94 }101 }
95- if list.has(b.Model) {102+ if listed {
96 return false, nil103 return false, nil
97 }104 }
105+ if compat.pull == nil {
106+ if compat.noPull != "" {
107+ return false, fmt.Errorf("%w — %s", ErrCannotPull, compat.noPull)
108+ }
109+ return false, ErrCannotPull
110+ }
98 return true, compat.pull(ctx, b, b.Model, progress)111 return true, compat.pull(ctx, b, b.Model, progress)
99 }112 }
100 113
114+// serverLists reports whether GET <base>/models lists model. A name without
115+// a tag also matches its ":latest" entry: Ollama and Docker Model Runner
116+// list `qwen3.5` as `qwen3.5:latest`, and pulling it again would be a no-op
117+// at best.
118+func serverLists(ctx context.Context, b Backend, model string) (bool, error) {
119+ var list modelList
120+ if err := getWithKey(ctx, b, strings.TrimSuffix(b.BaseURL, "/")+"/models", &list); err != nil {
121+ return false, err
122+ }
123+ return list.has(model) || (!hasTag(model) && list.has(model+":latest")), nil
124+}
125+
126+// hasTag reports whether the last path element of a model name carries a
127+// ":tag" (`qwen3.5:2b`, `hf.co/…:Q4_K_M`; not `ai/smollm2`).
128+func hasTag(model string) bool {
129+ return strings.Contains(model[strings.LastIndex(model, "/")+1:], ":")
130+}
131+
101 // pullOllamaAPI is the llmman (and Ollama) way: POST /api/pull, then one132 // pullOllamaAPI is the llmman (and Ollama) way: POST /api/pull, then one
102 // JSON object per line until "success" or an "error".133 // JSON object per line until "success" or an "error".
103 func pullOllamaAPI(ctx context.Context, b Backend, model string, progress func(PullProgress)) error {134 func pullOllamaAPI(ctx context.Context, b Backend, model string, progress func(PullProgress)) error {
@@ -160,3 +191,94 @@ func readPullStream(r io.Reader, model string, progress func(PullProgress)) erro
160 }191 }
161 return errors.New("the download ended without success")192 return errors.New("the download ended without success")
162 }193 }
194+
195+// pullDMR is Docker Model Runner's way (ticket 163): POST /models/create
196+// {"from": m} on the runner itself — the base URL without its
197+// /engines/… OpenAI prefix — the route `docker model pull` uses. Its stream
198+// is read leniently (dmrStep): JSON progress lines are shown, an "error"
199+// line ends the pull, anything else is shown as a status. Whatever the
200+// stream said, the pull counts as done only when the runner then LISTS the
201+// model: the confirmation does not depend on the stream's exact format.
202+func pullDMR(ctx context.Context, b Backend, model string, progress func(PullProgress)) error {
203+ body, err := json.Marshal(map[string]string{"from": model})
204+ if err != nil {
205+ return err
206+ }
207+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, dmrRoot(b.BaseURL)+"/models/create", strings.NewReader(string(body)))
208+ if err != nil {
209+ return err
210+ }
211+ req.Header.Set("Content-Type", "application/json")
212+ resp, err := pullClient.Do(req)
213+ if err != nil {
214+ return fmt.Errorf("the server cannot be reached: %w", err)
215+ }
216+ defer func() { _ = resp.Body.Close() }()
217+ if resp.StatusCode/100 != 2 {
218+ text, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
219+ return &httpStatusError{code: resp.StatusCode, body: strings.TrimSpace(string(text))}
220+ }
221+ last, err := readDMRStream(resp.Body, model, progress)
222+ if err != nil {
223+ return err
224+ }
225+ listed, err := serverLists(ctx, b, model)
226+ switch {
227+ case err != nil:
228+ return fmt.Errorf("the download ended, but the model list does not answer: %w", err)
229+ case !listed:
230+ return fmt.Errorf("the download ended (%q), but the server does not list %s", last, model)
231+ }
232+ if progress != nil {
233+ progress(PullProgress{Model: model, Status: "success"})
234+ }
235+ return nil
236+}
237+
238+// readDMRStream reads the runner's progress until the end of the stream and
239+// returns the last status seen. A JSON line {"type": …, "message": …,
240+// "total": …, "pulled": …} is a step; "type": "error" ends the pull with its
241+// message; a line that is not JSON is passed on as a status.
242+func readDMRStream(r io.Reader, model string, progress func(PullProgress)) (string, error) {
243+ scanner := bufio.NewScanner(r)
244+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
245+ last := ""
246+ for scanner.Scan() {
247+ line := strings.TrimSpace(scanner.Text())
248+ if line == "" {
249+ continue
250+ }
251+ step := PullProgress{Model: model, Status: line}
252+ var msg struct {
253+ Type string `json:"type"`
254+ Message string `json:"message"`
255+ Total int64 `json:"total"`
256+ Pulled int64 `json:"pulled"`
257+ }
258+ if json.Unmarshal([]byte(line), &msg) == nil {
259+ if msg.Type == "error" {
260+ return last, fmt.Errorf("the server says: %s", msg.Message)
261+ }
262+ step.Status, step.Completed, step.Total = msg.Message, msg.Pulled, msg.Total
263+ }
264+ last = step.Status
265+ if progress != nil {
266+ progress(step)
267+ }
268+ }
269+ if err := scanner.Err(); err != nil {
270+ return last, fmt.Errorf("the download was cut: %w", err)
271+ }
272+ return last, nil
273+}
274+
275+// dmrRoot is the runner's own address: the base URL cut before "/engines"
276+// (http://localhost:12434/engines/v1 → http://localhost:12434), or without
277+// its "/v1" when there is no "/engines".
278+func dmrRoot(baseURL string) string {
279+ u := strings.TrimSuffix(baseURL, "/")
280+ if i := strings.Index(u, "/engines"); i >= 0 {
281+ return u[:i]
282+ }
283+ return strings.TrimSuffix(u, "/v1")
284+}
added internal/engine/pull_dmr_test.go +114 -0
new file mode 100644
@@ -0,0 +1,114 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "sync"
10+ "testing"
11+)
12+
13+// fakeRunner is a Docker Model Runner: /engines/v1/models lists its models,
14+// POST /models/create streams `lines` and, when `adds`, adds the model.
15+type fakeRunner struct {
16+ mu sync.Mutex
17+ models []string
18+ lines []string
19+ status int // the create answer's status; 0 = 200
20+ adds bool
21+ froms []string
22+}
23+
24+func (f *fakeRunner) server(t *testing.T) string {
25+ t.Helper()
26+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ f.mu.Lock()
28+ defer f.mu.Unlock()
29+ switch r.URL.Path {
30+ case "/engines/v1/models":
31+ data := []map[string]string{}
32+ for _, m := range f.models {
33+ data = append(data, map[string]string{"id": m})
34+ }
35+ _ = json.NewEncoder(w).Encode(map[string]any{"data": data})
36+ case "/models/create":
37+ var body map[string]string
38+ _ = json.NewDecoder(r.Body).Decode(&body)
39+ f.froms = append(f.froms, body["from"])
40+ if f.status != 0 {
41+ w.WriteHeader(f.status)
42+ }
43+ for _, l := range f.lines {
44+ _, _ = w.Write([]byte(l + "\n"))
45+ }
46+ if f.adds {
47+ f.models = append(f.models, body["from"])
48+ }
49+ default:
50+ http.NotFound(w, r)
51+ }
52+ }))
53+ t.Cleanup(srv.Close)
54+ return srv.URL + "/engines/v1"
55+}
56+
57+// The runner's progress is shown, and the pull counts as done once the
58+// runner lists the model.
59+func TestPullDMR(t *testing.T) {
60+ f := &fakeRunner{adds: true, lines: []string{
61+ `{"type":"progress","message":"Downloaded: 50.00 MB","total":100000000,"pulled":50000000}`,
62+ `{"type":"success","message":"Model pulled successfully"}`,
63+ }}
64+ base := f.server(t)
65+ var steps []PullProgress
66+ _, pulled, err := EnsureAgentModel(context.Background(), agentFile(t, "dmr", "ai/smollm2", base), func(p PullProgress) { steps = append(steps, p) })
67+ if err != nil || !pulled || len(f.froms) != 1 || f.froms[0] != "ai/smollm2" {
68+ t.Fatalf("pulled %v, err %v, froms %v", pulled, err, f.froms)
69+ }
70+ if len(steps) != 3 || steps[0].Completed != 50000000 || steps[0].Total != 100000000 || steps[2].Status != "success" {
71+ t.Errorf("steps = %+v", steps)
72+ }
73+}
74+
75+// An error line, a refused request, or a stream that ends without the model
76+// appearing: each is a failure with the runner's words.
77+func TestPullDMRFailures(t *testing.T) {
78+ for name, tc := range map[string]struct {
79+ runner *fakeRunner
80+ want string
81+ }{
82+ "error line": {&fakeRunner{lines: []string{`{"type":"error","message":"model not found"}`}}, "the server says: model not found"},
83+ "refused": {&fakeRunner{status: http.StatusNotFound, lines: []string{"no such model"}}, "the server answered 404: no such model"},
84+ "not listed": {&fakeRunner{lines: []string{"pulling…"}}, `the download ended ("pulling…"), but the server does not list ai/nope`},
85+ "plain lines": {&fakeRunner{adds: true, lines: []string{"pulling…", "done"}}, ""},
86+ } {
87+ t.Run(name, func(t *testing.T) {
88+ base := tc.runner.server(t)
89+ _, _, err := EnsureAgentModel(context.Background(), agentFile(t, "dmr", "ai/nope", base), nil)
90+ if tc.want == "" {
91+ if err != nil {
92+ t.Errorf("err = %v", err)
93+ }
94+ return
95+ }
96+ if err == nil || !strings.Contains(err.Error(), tc.want) {
97+ t.Errorf("err = %v, want %q", err, tc.want)
98+ }
99+ })
100+ }
101+}
102+
103+func TestDMRRoot(t *testing.T) {
104+ for in, want := range map[string]string{
105+ "http://localhost:12434/engines/v1": "http://localhost:12434",
106+ "http://localhost:12434/engines/llama.cpp/v1": "http://localhost:12434",
107+ "http://model-runner.docker.internal/engines/v1/": "http://model-runner.docker.internal",
108+ "http://localhost:12434/v1": "http://localhost:12434",
109+ } {
110+ if got := dmrRoot(in); got != want {
111+ t.Errorf("dmrRoot(%q) = %q, want %q", in, got, want)
112+ }
113+ }
114+}
new file mode 100644
@@ -0,0 +1,114 @@
1+package engine
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "sync"
10+ "testing"
11+)
12+
13+// fakeRunner is a Docker Model Runner: /engines/v1/models lists its models,
14+// POST /models/create streams `lines` and, when `adds`, adds the model.
15+type fakeRunner struct {
16+ mu sync.Mutex
17+ models []string
18+ lines []string
19+ status int // the create answer's status; 0 = 200
20+ adds bool
21+ froms []string
22+}
23+
24+func (f *fakeRunner) server(t *testing.T) string {
25+ t.Helper()
26+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+ f.mu.Lock()
28+ defer f.mu.Unlock()
29+ switch r.URL.Path {
30+ case "/engines/v1/models":
31+ data := []map[string]string{}
32+ for _, m := range f.models {
33+ data = append(data, map[string]string{"id": m})
34+ }
35+ _ = json.NewEncoder(w).Encode(map[string]any{"data": data})
36+ case "/models/create":
37+ var body map[string]string
38+ _ = json.NewDecoder(r.Body).Decode(&body)
39+ f.froms = append(f.froms, body["from"])
40+ if f.status != 0 {
41+ w.WriteHeader(f.status)
42+ }
43+ for _, l := range f.lines {
44+ _, _ = w.Write([]byte(l + "\n"))
45+ }
46+ if f.adds {
47+ f.models = append(f.models, body["from"])
48+ }
49+ default:
50+ http.NotFound(w, r)
51+ }
52+ }))
53+ t.Cleanup(srv.Close)
54+ return srv.URL + "/engines/v1"
55+}
56+
57+// The runner's progress is shown, and the pull counts as done once the
58+// runner lists the model.
59+func TestPullDMR(t *testing.T) {
60+ f := &fakeRunner{adds: true, lines: []string{
61+ `{"type":"progress","message":"Downloaded: 50.00 MB","total":100000000,"pulled":50000000}`,
62+ `{"type":"success","message":"Model pulled successfully"}`,
63+ }}
64+ base := f.server(t)
65+ var steps []PullProgress
66+ _, pulled, err := EnsureAgentModel(context.Background(), agentFile(t, "dmr", "ai/smollm2", base), func(p PullProgress) { steps = append(steps, p) })
67+ if err != nil || !pulled || len(f.froms) != 1 || f.froms[0] != "ai/smollm2" {
68+ t.Fatalf("pulled %v, err %v, froms %v", pulled, err, f.froms)
69+ }
70+ if len(steps) != 3 || steps[0].Completed != 50000000 || steps[0].Total != 100000000 || steps[2].Status != "success" {
71+ t.Errorf("steps = %+v", steps)
72+ }
73+}
74+
75+// An error line, a refused request, or a stream that ends without the model
76+// appearing: each is a failure with the runner's words.
77+func TestPullDMRFailures(t *testing.T) {
78+ for name, tc := range map[string]struct {
79+ runner *fakeRunner
80+ want string
81+ }{
82+ "error line": {&fakeRunner{lines: []string{`{"type":"error","message":"model not found"}`}}, "the server says: model not found"},
83+ "refused": {&fakeRunner{status: http.StatusNotFound, lines: []string{"no such model"}}, "the server answered 404: no such model"},
84+ "not listed": {&fakeRunner{lines: []string{"pulling…"}}, `the download ended ("pulling…"), but the server does not list ai/nope`},
85+ "plain lines": {&fakeRunner{adds: true, lines: []string{"pulling…", "done"}}, ""},
86+ } {
87+ t.Run(name, func(t *testing.T) {
88+ base := tc.runner.server(t)
89+ _, _, err := EnsureAgentModel(context.Background(), agentFile(t, "dmr", "ai/nope", base), nil)
90+ if tc.want == "" {
91+ if err != nil {
92+ t.Errorf("err = %v", err)
93+ }
94+ return
95+ }
96+ if err == nil || !strings.Contains(err.Error(), tc.want) {
97+ t.Errorf("err = %v, want %q", err, tc.want)
98+ }
99+ })
100+ }
101+}
102+
103+func TestDMRRoot(t *testing.T) {
104+ for in, want := range map[string]string{
105+ "http://localhost:12434/engines/v1": "http://localhost:12434",
106+ "http://localhost:12434/engines/llama.cpp/v1": "http://localhost:12434",
107+ "http://model-runner.docker.internal/engines/v1/": "http://model-runner.docker.internal",
108+ "http://localhost:12434/v1": "http://localhost:12434",
109+ } {
110+ if got := dmrRoot(in); got != want {
111+ t.Errorf("dmrRoot(%q) = %q, want %q", in, got, want)
112+ }
113+ }
114+}
modified internal/engine/pull_test.go +39 -6
@@ -112,16 +112,49 @@ func TestEnsureAgentModelReportsAFailedPull(t *testing.T) {
112112 }
113113 }
114114
115-// Nothing to check on a server that does not answer, nothing to do for a
116-// provider with no pull.
115+// Nothing to check on a server that does not answer. A model the server
116+// lists needs nothing, even from a provider that cannot pull; a missing one
117+// there is ErrCannotPull with the provider's advice.
117118 func TestEnsureAgentModelWithoutAServerOrAPull(t *testing.T) {
118- srv := (&fakeStore{}).server(t)
119119 down := agentFile(t, "llmman", "m", "http://127.0.0.1:1/v1")
120120 if _, _, err := EnsureAgentModel(context.Background(), down, nil); !errors.Is(err, ErrServerDown) {
121121 t.Errorf("down: %v", err)
122122 }
123- ollama := agentFile(t, "ollama", "m", srv.URL+"/v1")
124- if _, _, err := EnsureAgentModel(context.Background(), ollama, nil); !errors.Is(err, ErrCannotPull) {
125- t.Errorf("ollama: %v", err)
123+ srv := (&fakeStore{models: []string{"served"}}).server(t)
124+ served := agentFile(t, "llamacpp", "served", srv.URL+"/v1")
125+ if _, pulled, err := EnsureAgentModel(context.Background(), served, nil); err != nil || pulled {
126+ t.Errorf("llamacpp, listed: pulled %v, err %v", pulled, err)
127+ }
128+ missing := agentFile(t, "llamacpp", "other", srv.URL+"/v1")
129+ _, _, err := EnsureAgentModel(context.Background(), missing, nil)
130+ if !errors.Is(err, ErrCannotPull) || !strings.Contains(err.Error(), "start it with -hf <repo>:<quant>") {
131+ t.Errorf("llamacpp, missing: %v", err)
132+ }
133+}
134+
135+// Ollama pulls through the same /api/pull as llmman (ticket 162).
136+func TestEnsureAgentModelOllama(t *testing.T) {
137+ f := &fakeStore{lines: measuredPull}
138+ srv := f.server(t)
139+ if _, pulled, err := EnsureAgentModel(context.Background(), agentFile(t, "ollama", "qwen3.5:2b", srv.URL+"/v1"), nil); err != nil || !pulled {
140+ t.Fatalf("pulled %v, err %v", pulled, err)
141+ }
142+ if len(f.pulls) != 1 || f.pulls[0] != "qwen3.5:2b" {
143+ t.Errorf("pulls = %v", f.pulls)
144+ }
145+}
146+
147+// A name without a tag matches its ":latest" entry; a tagged name must match
148+// exactly.
149+func TestServerListsMatchesLatest(t *testing.T) {
150+ srv := (&fakeStore{models: []string{"qwen3.5:latest", "ai/smollm2:latest", "hf.co/x/y:Q4_K_M"}}).server(t)
151+ b := Backend{BaseURL: srv.URL + "/v1"}
152+ for model, want := range map[string]bool{
153+ "qwen3.5": true, "ai/smollm2": true, "hf.co/x/y:Q4_K_M": true,
154+ "qwen3.5:2b": false, "hf.co/x/y:Q8_0": false, "hf.co/x/y": false,
155+ } {
156+ if got, err := serverLists(context.Background(), b, model); err != nil || got != want {
157+ t.Errorf("serverLists(%q) = %v, %v; want %v", model, got, err, want)
158+ }
126159 }
127160 }
@@ -112,16 +112,49 @@ func TestEnsureAgentModelReportsAFailedPull(t *testing.T) {
112 }112 }
113 }113 }
114 114
115-// Nothing to check on a server that does not answer, nothing to do for a115+// Nothing to check on a server that does not answer. A model the server
116-// provider with no pull.116+// lists needs nothing, even from a provider that cannot pull; a missing one
117+// there is ErrCannotPull with the provider's advice.
117 func TestEnsureAgentModelWithoutAServerOrAPull(t *testing.T) {118 func TestEnsureAgentModelWithoutAServerOrAPull(t *testing.T) {
118- srv := (&fakeStore{}).server(t)
119 down := agentFile(t, "llmman", "m", "http://127.0.0.1:1/v1")119 down := agentFile(t, "llmman", "m", "http://127.0.0.1:1/v1")
120 if _, _, err := EnsureAgentModel(context.Background(), down, nil); !errors.Is(err, ErrServerDown) {120 if _, _, err := EnsureAgentModel(context.Background(), down, nil); !errors.Is(err, ErrServerDown) {
121 t.Errorf("down: %v", err)121 t.Errorf("down: %v", err)
122 }122 }
123- ollama := agentFile(t, "ollama", "m", srv.URL+"/v1")123+ srv := (&fakeStore{models: []string{"served"}}).server(t)
124- if _, _, err := EnsureAgentModel(context.Background(), ollama, nil); !errors.Is(err, ErrCannotPull) {124+ served := agentFile(t, "llamacpp", "served", srv.URL+"/v1")
125- t.Errorf("ollama: %v", err)125+ if _, pulled, err := EnsureAgentModel(context.Background(), served, nil); err != nil || pulled {
126+ t.Errorf("llamacpp, listed: pulled %v, err %v", pulled, err)
127+ }
128+ missing := agentFile(t, "llamacpp", "other", srv.URL+"/v1")
129+ _, _, err := EnsureAgentModel(context.Background(), missing, nil)
130+ if !errors.Is(err, ErrCannotPull) || !strings.Contains(err.Error(), "start it with -hf <repo>:<quant>") {
131+ t.Errorf("llamacpp, missing: %v", err)
132+ }
133+}
134+
135+// Ollama pulls through the same /api/pull as llmman (ticket 162).
136+func TestEnsureAgentModelOllama(t *testing.T) {
137+ f := &fakeStore{lines: measuredPull}
138+ srv := f.server(t)
139+ if _, pulled, err := EnsureAgentModel(context.Background(), agentFile(t, "ollama", "qwen3.5:2b", srv.URL+"/v1"), nil); err != nil || !pulled {
140+ t.Fatalf("pulled %v, err %v", pulled, err)
141+ }
142+ if len(f.pulls) != 1 || f.pulls[0] != "qwen3.5:2b" {
143+ t.Errorf("pulls = %v", f.pulls)
144+ }
145+}
146+
147+// A name without a tag matches its ":latest" entry; a tagged name must match
148+// exactly.
149+func TestServerListsMatchesLatest(t *testing.T) {
150+ srv := (&fakeStore{models: []string{"qwen3.5:latest", "ai/smollm2:latest", "hf.co/x/y:Q4_K_M"}}).server(t)
151+ b := Backend{BaseURL: srv.URL + "/v1"}
152+ for model, want := range map[string]bool{
153+ "qwen3.5": true, "ai/smollm2": true, "hf.co/x/y:Q4_K_M": true,
154+ "qwen3.5:2b": false, "hf.co/x/y:Q8_0": false, "hf.co/x/y": false,
155+ } {
156+ if got, err := serverLists(context.Background(), b, model); err != nil || got != want {
157+ t.Errorf("serverLists(%q) = %v, %v; want %v", model, got, err, want)
158+ }
126 }159 }
127 }160 }
modified pull_wiring.go +4 -1
@@ -39,7 +39,10 @@ func autoPull(ctx context.Context, e *engine.Engine, w io.Writer, tty bool) erro
3939 for _, path := range otherModelFiles() {
4040 model, pulled, err := engine.EnsureAgentModel(ctx, path, report.step)
4141 report.done(model, pulled, err)
42- if err != nil && !errors.Is(err, engine.ErrCannotPull) {
42+ // Every failure is worth a line here: the model is NOT on its
43+ // server (a listed one returns no error), and for a provider that
44+ // cannot pull, the line says what to do instead.
45+ if err != nil {
4346 fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", model, path, err)
4447 }
4548 }
@@ -39,7 +39,10 @@ func autoPull(ctx context.Context, e *engine.Engine, w io.Writer, tty bool) erro
39 for _, path := range otherModelFiles() {39 for _, path := range otherModelFiles() {
40 model, pulled, err := engine.EnsureAgentModel(ctx, path, report.step)40 model, pulled, err := engine.EnsureAgentModel(ctx, path, report.step)
41 report.done(model, pulled, err)41 report.done(model, pulled, err)
42- if err != nil && !errors.Is(err, engine.ErrCannotPull) {42+ // Every failure is worth a line here: the model is NOT on its
43+ // server (a listed one returns no error), and for a provider that
44+ // cannot pull, the line says what to do instead.
45+ if err != nil {
43 fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", model, path, err)46 fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", model, path, err)
44 }47 }
45 }48 }