🛟 Updated.
99e21b0 parent: 6fcf1c7 modified
.tickets/issues/0162-ollama-provider-download-the-model-automatically.yaml +51 -4 | @@ -1,14 +1,15 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 162 |
| 3 | 3 | title: "ollama provider: download the model automatically if it does not exist" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-25T23:40:00.000Z |
| 9 | -updatedAt: 2026-10-25T23:40:00.000Z | |
| 9 | +updatedAt: 2026-10-26T02:30:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - enhancement |
| 12 | + - To be tested | |
| 12 | 13 | milestone: v1.0.1 |
| 13 | 14 | epic: Providers (LLM Engine) |
| 14 | 15 | related: |
| @@ -29,5 +30,51 @@ body: |+ | ||
| 29 | 30 | - Same key, same start-up behaviour, same display. |
| 30 | 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 | 32 | - 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: 162 | 2 | 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: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-25T23:40:00.000Z | 8 | createdAt: 2026-10-25T23:40:00.000Z |
| 9 | -updatedAt: 2026-10-25T23:40:00.000Z | 9 | +updatedAt: 2026-10-26T02:30:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - enhancement | 11 | - enhancement |
| 12 | + - To be tested | ||
| 12 | milestone: v1.0.1 | 13 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 163 |
| 3 | 3 | title: "dmr provider (Docker Model Runner): download the model automatically if it does not exist" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-25T23:40:00.000Z |
| 9 | -updatedAt: 2026-10-25T23:40:00.000Z | |
| 9 | +updatedAt: 2026-10-26T02:30:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - enhancement |
| 12 | + - To be tested | |
| 12 | 13 | milestone: v1.0.1 |
| 13 | 14 | epic: Providers (LLM Engine) |
| 14 | 15 | related: |
| @@ -32,5 +33,52 @@ body: |+ | ||
| 32 | 33 | |
| 33 | 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 | 35 | - 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: 163 | 2 | 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: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-25T23:40:00.000Z | 8 | createdAt: 2026-10-25T23:40:00.000Z |
| 9 | -updatedAt: 2026-10-25T23:40:00.000Z | 9 | +updatedAt: 2026-10-26T02:30:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - enhancement | 11 | - enhancement |
| 12 | + - To be tested | ||
| 12 | milestone: v1.0.1 | 13 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 164 |
| 3 | 3 | title: "llamacpp provider: download the model automatically if it does not exist" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-26T01:00:00.000Z |
| 9 | -updatedAt: 2026-10-26T01:00:00.000Z | |
| 9 | +updatedAt: 2026-10-26T02:30:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - enhancement |
| 12 | + - To be tested | |
| 12 | 13 | milestone: v1.0.1 |
| 13 | 14 | epic: Providers (LLM Engine) |
| 14 | 15 | related: |
| @@ -33,5 +34,52 @@ body: |+ | ||
| 33 | 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 | 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 | 36 | 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: 164 | 2 | 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: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-26T01:00:00.000Z | 8 | createdAt: 2026-10-26T01:00:00.000Z |
| 9 | -updatedAt: 2026-10-26T01:00:00.000Z | 9 | +updatedAt: 2026-10-26T02:30:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - enhancement | 11 | - enhancement |
| 12 | + - To be tested | ||
| 12 | milestone: v1.0.1 | 13 | 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 | ||
| 62 | 62 | |
| 63 | 63 | ## 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 | 66 | - `mm --ask` does not pull: start the coordinator once (`-p` or the REPL) to pull the team's models. |
| 67 | 67 | - In a pipe or a log, the progress is one line per tenth instead of one line rewritten in place. |
| 68 | 68 | |
| @@ -62,7 +62,7 @@ llmman keeps a deleted model's file in its cache: after `llmman rm` (or `DELETE | |||
| 62 | 62 | ||
| 63 | ## Limits | 63 | ## 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 | ||
| 62 | 62 | |
| 63 | 63 | ## 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 | 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 | 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 | |
| @@ -62,7 +62,7 @@ llmman garde dans son cache le fichier d'un modèle supprimé : après `llmman r | |||
| 62 | 62 | ||
| 63 | ## Limites | 63 | ## 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 | ||
| 23 | 23 | ## Variants |
| 24 | 24 | |
| 25 | 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 | 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 | 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 | 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`. |
| @@ -23,6 +23,7 @@ This guide shows how to point `mm` at a `llama-server` you start yourself. It as | |||
| 23 | ## Variants | 23 | ## 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 | ||
| 24 | 24 | ## Variants |
| 25 | 25 | |
| 26 | 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 | 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 | 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 | 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`). |
| @@ -24,6 +24,7 @@ This guide shows how to point `mm` at [Ollama](https://ollama.com) through its O | |||
| 24 | ## Variants | 24 | ## 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 @@ | ||
| 32 | 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 | 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 | 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 | 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 | 37 | | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | |
| 38 | 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). | |
| @@ -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 | ||
| 23 | 23 | ## Variantes |
| 24 | 24 | |
| 25 | 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 | 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 | 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 | 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`. |
| @@ -23,6 +23,7 @@ Ce guide montre comment pointer `mm` vers un `llama-server` que vous démarrez v | |||
| 23 | ## Variantes | 23 | ## 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 | ||
| 24 | 24 | ## Variantes |
| 25 | 25 | |
| 26 | 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 | 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 | 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 | 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`). |
| @@ -24,6 +24,7 @@ Ce guide montre comment brancher `mm` sur [Ollama](https://ollama.com) via son p | |||
| 24 | ## Variantes | 24 | ## 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 @@ | ||
| 32 | 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 | 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 | 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 | 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 | 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 | 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). | |
| @@ -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 { | ||
| 90 | 90 | // AutoPull: at start-up, download the models this mm will use and the |
| 91 | 91 | // server does not have yet — its own, its mmConfigs agents', its routing |
| 92 | 92 | // 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). | |
| 95 | 96 | AutoPull bool `yaml:"autoPull"` |
| 96 | 97 | |
| 97 | 98 | // 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 the | 90 | // 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 routing | 91 | // server does not have yet — its own, its mmConfigs agents', its routing |
| 92 | // deciders' — showing the progress (ticket 61). Off by default: a | 92 | // deciders' — showing the progress (ticket 61). Off by default: a |
| 93 | - // download of several GB is not something to start unasked. llmman only | 93 | + // 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 { | ||
| 64 | 64 | unload unloadFunc |
| 65 | 65 | |
| 66 | 66 | // 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 | |
| 69 | 71 | } |
| 70 | 72 | |
| 71 | 73 | // DefaultURLs returns the address a provider uses when the config gives no |
| @@ -96,6 +98,7 @@ func init() { | ||
| 96 | 98 | probe: probeModels, |
| 97 | 99 | progress: true, |
| 98 | 100 | thinkingOff: thinkingOffTemplate, |
| 101 | + pull: pullDMR, | |
| 99 | 102 | }) |
| 100 | 103 | register(&openaiCompat{ |
| 101 | 104 | name: "llamacpp", |
| @@ -110,6 +113,7 @@ func init() { | ||
| 110 | 113 | progress: true, |
| 111 | 114 | thinkingOff: thinkingOffTemplate, |
| 112 | 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 | 118 | register(&openaiCompat{ |
| 115 | 119 | name: "llmman", |
| @@ -147,6 +151,7 @@ func init() { | ||
| 147 | 151 | progress: false, |
| 148 | 152 | thinkingOff: thinkingOffEffort, |
| 149 | 153 | unload: unloadKeepAlive, |
| 154 | + pull: pullOllamaAPI, | |
| 150 | 155 | }) |
| 151 | 156 | } |
| 152 | 157 | |
| @@ -64,8 +64,10 @@ type openaiCompat struct { | |||
| 64 | unload unloadFunc | 64 | 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 pullFunc | 68 | + // 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 no | 73 | // 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 ( | ||
| 18 | 18 | // first question that waits minutes on a silent download (llmman pulls on |
| 19 | 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 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): | |
| 23 | 24 | // {"status":"pulling manifest"} |
| 24 | 25 | // {"completed":0,"status":"pulling","total":105454144} |
| 25 | 26 | // … |
| 26 | 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 | 33 | // PullProgress is one step of a download, as the server reports it. |
| 30 | 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 | 87 | 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 | 93 | func ensureModel(ctx context.Context, p Provider, b Backend, progress func(PullProgress)) (bool, error) { |
| 87 | 94 | compat, ok := p.(*openaiCompat) |
| 88 | - if !ok || compat.pull == nil { | |
| 95 | + if !ok { | |
| 89 | 96 | return false, ErrCannotPull |
| 90 | 97 | } |
| 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 { | |
| 93 | 100 | return false, ErrServerDown |
| 94 | 101 | } |
| 95 | - if list.has(b.Model) { | |
| 102 | + if listed { | |
| 96 | 103 | 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 | 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 | 132 | // pullOllamaAPI is the llmman (and Ollama) way: POST /api/pull, then one |
| 102 | 133 | // JSON object per line until "success" or an "error". |
| 103 | 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 | 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 | +} | |
| @@ -18,13 +18,17 @@ import ( | |||
| 18 | // first question that waits minutes on a silent download (llmman pulls on | 18 | // 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 JSON | 21 | +// - 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, err | 87 | 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, ErrCannotPull | 96 | return false, ErrCannotPull |
| 90 | } | 97 | } |
| 91 | - var list modelList | 98 | + 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, ErrServerDown | 100 | return false, ErrServerDown |
| 94 | } | 101 | } |
| 95 | - if list.has(b.Model) { | 102 | + if listed { |
| 96 | return false, nil | 103 | 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 one | 132 | // 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) { | ||
| 112 | 112 | } |
| 113 | 113 | } |
| 114 | 114 | |
| 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. | |
| 117 | 118 | func TestEnsureAgentModelWithoutAServerOrAPull(t *testing.T) { |
| 118 | - srv := (&fakeStore{}).server(t) | |
| 119 | 119 | down := agentFile(t, "llmman", "m", "http://127.0.0.1:1/v1") |
| 120 | 120 | if _, _, err := EnsureAgentModel(context.Background(), down, nil); !errors.Is(err, ErrServerDown) { |
| 121 | 121 | t.Errorf("down: %v", err) |
| 122 | 122 | } |
| 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 | + } | |
| 126 | 159 | } |
| 127 | 160 | } |
| @@ -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 a | 115 | +// 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 | ||
| 39 | 39 | for _, path := range otherModelFiles() { |
| 40 | 40 | model, pulled, err := engine.EnsureAgentModel(ctx, path, report.step) |
| 41 | 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 | 46 | fmt.Fprintf(w, "[warning: autoPull: %s (%s): %v]\n", model, path, err) |
| 44 | 47 | } |
| 45 | 48 | } |
| @@ -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 | } |