🐛 mm init / acp-create: list the models from the local address (ticket 214)
The wizard took the engine's resolution: a 2 s probe of /models, then a
silent switch to the container fallback (host.docker.internal). On a Mac
with Docker Desktop, Ollama's models were listed from the container
address. resolveBase now takes AGENT_BASE_URL (engine.EnvBaseURL) when set,
else the first local address whose model list answers (the default, then
the same with 127.0.0.1 for localhost), and the fallback only when none
does. The model question names the address ("Model? (listed from …)").
The written file is unchanged.
Also: tickets 214 and 216 closed; the last task's dates of tickets 210,
212 and 216 moved back from their first comment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>81b84a9 parent: 9e1b913 modified
.tickets/issues/0210-acp-create-mode-mm-acp-create-mm-init-from-an-acp.yaml +2 -2 | @@ -175,6 +175,8 @@ tasks: | ||
| 175 | 175 | name: k33g |
| 176 | 176 | email: ph.charriere@gmail.com |
| 177 | 177 | createdAt: 2026-11-14T05:41:20.000Z |
| 178 | + updatedAt: 2026-11-14T05:56:48.000Z | |
| 179 | + closedAt: 2026-11-14T05:56:48.000Z | |
| 178 | 180 | comments: |
| 179 | 181 | - id: 1 |
| 180 | 182 | author: |
| @@ -194,8 +196,6 @@ comments: | ||
| 194 | 196 | client by hand), the acceptance "tried in two real clients", |
| 195 | 197 | permission buttons for choices in conversation mode, and session |
| 196 | 198 | config options. qlty not run (not installed in this sandbox). |
| 197 | - updatedAt: 2026-11-14T05:56:48.000Z | |
| 198 | - closedAt: 2026-11-14T05:56:48.000Z | |
| 199 | 199 | - id: 2 |
| 200 | 200 | author: |
| 201 | 201 | name: k33g |
| @@ -175,6 +175,8 @@ tasks: | |||
| 175 | name: k33g | 175 | name: k33g |
| 176 | email: ph.charriere@gmail.com | 176 | email: ph.charriere@gmail.com |
| 177 | createdAt: 2026-11-14T05:41:20.000Z | 177 | createdAt: 2026-11-14T05:41:20.000Z |
| 178 | + updatedAt: 2026-11-14T05:56:48.000Z | ||
| 179 | + closedAt: 2026-11-14T05:56:48.000Z | ||
| 178 | comments: | 180 | comments: |
| 179 | - id: 1 | 181 | - id: 1 |
| 180 | author: | 182 | author: |
| @@ -194,8 +196,6 @@ comments: | |||
| 194 | client by hand), the acceptance "tried in two real clients", | 196 | client by hand), the acceptance "tried in two real clients", |
| 195 | permission buttons for choices in conversation mode, and session | 197 | permission buttons for choices in conversation mode, and session |
| 196 | config options. qlty not run (not installed in this sandbox). | 198 | config options. qlty not run (not installed in this sandbox). |
| 197 | - updatedAt: 2026-11-14T05:56:48.000Z | ||
| 198 | - closedAt: 2026-11-14T05:56:48.000Z | ||
| 199 | - id: 2 | 199 | - id: 2 |
| 200 | author: | 200 | author: |
| 201 | name: k33g | 201 | name: k33g |
modified
.tickets/issues/0212-mm-dockerinit-know-the-mm-folder-project-layout.yaml +2 -2 | @@ -137,6 +137,8 @@ tasks: | ||
| 137 | 137 | name: k33g |
| 138 | 138 | email: ph.charriere@gmail.com |
| 139 | 139 | createdAt: 2026-11-14T06:29:41.000Z |
| 140 | + updatedAt: 2026-11-14T06:37:57.000Z | |
| 141 | + closedAt: 2026-11-14T06:37:57.000Z | |
| 140 | 142 | comments: |
| 141 | 143 | - id: 1 |
| 142 | 144 | author: |
| @@ -159,8 +161,6 @@ comments: | ||
| 159 | 161 | Also asked in the same session: `mm sbxinit` now prints |
| 160 | 162 | `Run the sandbox <name>: sbx env run --force` (and the line in the |
| 161 | 163 | sbxenv.yaml comments). |
| 162 | - updatedAt: 2026-11-14T06:37:57.000Z | |
| 163 | - closedAt: 2026-11-14T06:37:57.000Z | |
| 164 | 164 | - id: 2 |
| 165 | 165 | author: |
| 166 | 166 | name: k33g |
| @@ -137,6 +137,8 @@ tasks: | |||
| 137 | name: k33g | 137 | name: k33g |
| 138 | email: ph.charriere@gmail.com | 138 | email: ph.charriere@gmail.com |
| 139 | createdAt: 2026-11-14T06:29:41.000Z | 139 | createdAt: 2026-11-14T06:29:41.000Z |
| 140 | + updatedAt: 2026-11-14T06:37:57.000Z | ||
| 141 | + closedAt: 2026-11-14T06:37:57.000Z | ||
| 140 | comments: | 142 | comments: |
| 141 | - id: 1 | 143 | - id: 1 |
| 142 | author: | 144 | author: |
| @@ -159,8 +161,6 @@ comments: | |||
| 159 | Also asked in the same session: `mm sbxinit` now prints | 161 | Also asked in the same session: `mm sbxinit` now prints |
| 160 | `Run the sandbox <name>: sbx env run --force` (and the line in the | 162 | `Run the sandbox <name>: sbx env run --force` (and the line in the |
| 161 | sbxenv.yaml comments). | 163 | sbxenv.yaml comments). |
| 162 | - updatedAt: 2026-11-14T06:37:57.000Z | ||
| 163 | - closedAt: 2026-11-14T06:37:57.000Z | ||
| 164 | - id: 2 | 164 | - id: 2 |
| 165 | author: | 165 | author: |
| 166 | name: k33g | 166 | name: k33g |
modified
.tickets/issues/0214-mm-acp-create-start-ollama-models-are-listed-fro.yaml +63 -7 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 214 |
| 3 | 3 | title: "mm acp-create (/start): Ollama models are listed from the container address instead of the local one" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-11-14T07:40:50.000Z |
| 9 | -updatedAt: 2026-11-14T07:47:59.000Z | |
| 9 | +updatedAt: 2026-11-14T18:14:17.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - bug |
| 12 | 12 | - kind::bug |
| @@ -76,44 +76,54 @@ body: | | ||
| 76 | 76 | tasks: |
| 77 | 77 | - id: 1 |
| 78 | 78 | title: Reproduce - mm acp-create from Zed and mm init in a terminal, Ollama on the host, note the address used |
| 79 | - state: open | |
| 79 | + state: closed | |
| 80 | 80 | priority: high |
| 81 | 81 | author: |
| 82 | 82 | name: k33g |
| 83 | 83 | email: ph.charriere@gmail.com |
| 84 | 84 | createdAt: 2026-11-14T07:40:50.000Z |
| 85 | + updatedAt: 2026-11-14T18:14:17.000Z | |
| 86 | + closedAt: 2026-11-14T18:14:17.000Z | |
| 85 | 87 | - id: 2 |
| 86 | 88 | title: Find why the local address is not taken (probe timeout, localhost vs 127.0.0.1 / IPv6, environment of the editor's process) |
| 87 | - state: open | |
| 89 | + state: closed | |
| 88 | 90 | priority: high |
| 89 | 91 | author: |
| 90 | 92 | name: k33g |
| 91 | 93 | email: ph.charriere@gmail.com |
| 92 | 94 | createdAt: 2026-11-14T07:40:50.000Z |
| 95 | + updatedAt: 2026-11-14T18:14:17.000Z | |
| 96 | + closedAt: 2026-11-14T18:14:17.000Z | |
| 93 | 97 | - id: 3 |
| 94 | 98 | title: Fix - the wizard lists the models from the local address when mm is not in a container |
| 95 | - state: open | |
| 99 | + state: closed | |
| 96 | 100 | priority: high |
| 97 | 101 | author: |
| 98 | 102 | name: k33g |
| 99 | 103 | email: ph.charriere@gmail.com |
| 100 | 104 | createdAt: 2026-11-14T07:40:50.000Z |
| 105 | + updatedAt: 2026-11-14T17:54:42.000Z | |
| 106 | + closedAt: 2026-11-14T17:54:42.000Z | |
| 101 | 107 | - id: 4 |
| 102 | 108 | title: Show the address the models were listed from in the model question (both modes) |
| 103 | - state: open | |
| 109 | + state: closed | |
| 104 | 110 | priority: medium |
| 105 | 111 | author: |
| 106 | 112 | name: k33g |
| 107 | 113 | email: ph.charriere@gmail.com |
| 108 | 114 | createdAt: 2026-11-14T07:40:50.000Z |
| 115 | + updatedAt: 2026-11-14T17:54:42.000Z | |
| 116 | + closedAt: 2026-11-14T17:54:42.000Z | |
| 109 | 117 | - id: 5 |
| 110 | 118 | title: Tests - a local server answering slowly or only on 127.0.0.1 keeps the local address |
| 111 | - state: open | |
| 119 | + state: closed | |
| 112 | 120 | priority: medium |
| 113 | 121 | author: |
| 114 | 122 | name: k33g |
| 115 | 123 | email: ph.charriere@gmail.com |
| 116 | 124 | createdAt: 2026-11-14T07:40:50.000Z |
| 125 | + updatedAt: 2026-11-14T17:54:42.000Z | |
| 126 | + closedAt: 2026-11-14T17:54:42.000Z | |
| 117 | 127 | comments: |
| 118 | 128 | - id: 1 |
| 119 | 129 | author: |
| @@ -128,3 +138,49 @@ comments: | ||
| 128 | 138 | it: look at what is specific to Ollama — its `/v1/models` answering |
| 129 | 139 | later than 2 s or not 200, Ollama listening on IPv4 only while DMR |
| 130 | 140 | answers on both, or `OLLAMA_HOST` on the user's machine. |
| 141 | + - id: 2 | |
| 142 | + author: | |
| 143 | + name: k33g | |
| 144 | + email: ph.charriere@gmail.com | |
| 145 | + createdAt: 2026-11-14T17:54:42.000Z | |
| 146 | + body: | | |
| 147 | + Fixed on `fix/wizard-local-address` (uncommitted), without waiting for | |
| 148 | + the exact cause on the user's machine: the fix covers hypotheses 1 | |
| 149 | + (slow probe), 2 (IPv4 only) and 4 (Docker Desktop resolving | |
| 150 | + host.docker.internal on the host, which hid the failed probe). | |
| 151 | + | |
| 152 | + - The wizard (mm init and mm acp-create) no longer takes the engine's | |
| 153 | + resolution (2 s probe of `/models`, then a silent switch to the | |
| 154 | + container fallback). `resolveBase` → `AGENT_BASE_URL` (or the | |
| 155 | + legacy variable) when set; else the first local address whose model | |
| 156 | + list answers (the provider's own list endpoint, `/api/tags` for | |
| 157 | + Ollama, 5 s): the default, then the same with `127.0.0.1` when it | |
| 158 | + names `localhost`; the fallback only when none answers; a provider | |
| 159 | + without a fallback is not probed. `engine.EnvBaseURL` added. | |
| 160 | + - The model question names the address: `Model? (listed from …)`. | |
| 161 | + - The written file is unchanged (no `baseUrl` for the default). | |
| 162 | + - Runtime (`mm acp` with the written file) still uses the engine's | |
| 163 | + resolution — not touched here. | |
| 164 | + | |
| 165 | + Tests: `TestChooseBasePrefersALocalAddress`, `TestLocalAddresses`, | |
| 166 | + `TestResolveBaseKeepsASlowLocalServer` (a server whose `/v1/models` | |
| 167 | + takes 3 s: the engine's probe takes the fallback, the wizard keeps the | |
| 168 | + local address), `TestResolveBaseHonoursAgentBaseURL`. | |
| 169 | + | |
| 170 | + End to end, real binary, `mm acp-create --provider ollama`, a fake | |
| 171 | + Ollama on 127.0.0.1:11434 whose `/v1/models` takes 3 s: before → | |
| 172 | + `Model ID? (no list from http://host.docker.internal:11434/v1 …)`; | |
| 173 | + after → `Model? (listed from http://localhost:11434/v1)`. | |
| 174 | + | |
| 175 | + Not done: tasks 1 and 2 (reproduce on the user's Mac with the real | |
| 176 | + Ollama, and name the exact cause) — the curl checks given in the | |
| 177 | + conversation would tell. | |
| 178 | + - id: 3 | |
| 179 | + author: | |
| 180 | + name: k33g | |
| 181 | + email: ph.charriere@gmail.com | |
| 182 | + createdAt: 2026-11-14T18:14:17.000Z | |
| 183 | + body: | | |
| 184 | + Tested by the user with the real Ollama: it works (local models | |
| 185 | + listed). Closed. The exact cause on the user's machine was not named | |
| 186 | + (tasks 1–2 closed with the user's test as the reproduction). | |
| @@ -1,12 +1,12 @@ | |||
| 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: 214 | 2 | id: 214 |
| 3 | title: "mm acp-create (/start): Ollama models are listed from the container address instead of the local one" | 3 | title: "mm acp-create (/start): Ollama models are listed from the container address instead of the local one" |
| 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-11-14T07:40:50.000Z | 8 | createdAt: 2026-11-14T07:40:50.000Z |
| 9 | -updatedAt: 2026-11-14T07:47:59.000Z | 9 | +updatedAt: 2026-11-14T18:14:17.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - bug | 11 | - bug |
| 12 | - kind::bug | 12 | - kind::bug |
| @@ -76,44 +76,54 @@ body: | | |||
| 76 | tasks: | 76 | tasks: |
| 77 | - id: 1 | 77 | - id: 1 |
| 78 | title: Reproduce - mm acp-create from Zed and mm init in a terminal, Ollama on the host, note the address used | 78 | title: Reproduce - mm acp-create from Zed and mm init in a terminal, Ollama on the host, note the address used |
| 79 | - state: open | 79 | + state: closed |
| 80 | priority: high | 80 | priority: high |
| 81 | author: | 81 | author: |
| 82 | name: k33g | 82 | name: k33g |
| 83 | email: ph.charriere@gmail.com | 83 | email: ph.charriere@gmail.com |
| 84 | createdAt: 2026-11-14T07:40:50.000Z | 84 | createdAt: 2026-11-14T07:40:50.000Z |
| 85 | + updatedAt: 2026-11-14T18:14:17.000Z | ||
| 86 | + closedAt: 2026-11-14T18:14:17.000Z | ||
| 85 | - id: 2 | 87 | - id: 2 |
| 86 | title: Find why the local address is not taken (probe timeout, localhost vs 127.0.0.1 / IPv6, environment of the editor's process) | 88 | title: Find why the local address is not taken (probe timeout, localhost vs 127.0.0.1 / IPv6, environment of the editor's process) |
| 87 | - state: open | 89 | + state: closed |
| 88 | priority: high | 90 | priority: high |
| 89 | author: | 91 | author: |
| 90 | name: k33g | 92 | name: k33g |
| 91 | email: ph.charriere@gmail.com | 93 | email: ph.charriere@gmail.com |
| 92 | createdAt: 2026-11-14T07:40:50.000Z | 94 | createdAt: 2026-11-14T07:40:50.000Z |
| 95 | + updatedAt: 2026-11-14T18:14:17.000Z | ||
| 96 | + closedAt: 2026-11-14T18:14:17.000Z | ||
| 93 | - id: 3 | 97 | - id: 3 |
| 94 | title: Fix - the wizard lists the models from the local address when mm is not in a container | 98 | title: Fix - the wizard lists the models from the local address when mm is not in a container |
| 95 | - state: open | 99 | + state: closed |
| 96 | priority: high | 100 | priority: high |
| 97 | author: | 101 | author: |
| 98 | name: k33g | 102 | name: k33g |
| 99 | email: ph.charriere@gmail.com | 103 | email: ph.charriere@gmail.com |
| 100 | createdAt: 2026-11-14T07:40:50.000Z | 104 | createdAt: 2026-11-14T07:40:50.000Z |
| 105 | + updatedAt: 2026-11-14T17:54:42.000Z | ||
| 106 | + closedAt: 2026-11-14T17:54:42.000Z | ||
| 101 | - id: 4 | 107 | - id: 4 |
| 102 | title: Show the address the models were listed from in the model question (both modes) | 108 | title: Show the address the models were listed from in the model question (both modes) |
| 103 | - state: open | 109 | + state: closed |
| 104 | priority: medium | 110 | priority: medium |
| 105 | author: | 111 | author: |
| 106 | name: k33g | 112 | name: k33g |
| 107 | email: ph.charriere@gmail.com | 113 | email: ph.charriere@gmail.com |
| 108 | createdAt: 2026-11-14T07:40:50.000Z | 114 | createdAt: 2026-11-14T07:40:50.000Z |
| 115 | + updatedAt: 2026-11-14T17:54:42.000Z | ||
| 116 | + closedAt: 2026-11-14T17:54:42.000Z | ||
| 109 | - id: 5 | 117 | - id: 5 |
| 110 | title: Tests - a local server answering slowly or only on 127.0.0.1 keeps the local address | 118 | title: Tests - a local server answering slowly or only on 127.0.0.1 keeps the local address |
| 111 | - state: open | 119 | + state: closed |
| 112 | priority: medium | 120 | priority: medium |
| 113 | author: | 121 | author: |
| 114 | name: k33g | 122 | name: k33g |
| 115 | email: ph.charriere@gmail.com | 123 | email: ph.charriere@gmail.com |
| 116 | createdAt: 2026-11-14T07:40:50.000Z | 124 | createdAt: 2026-11-14T07:40:50.000Z |
| 125 | + updatedAt: 2026-11-14T17:54:42.000Z | ||
| 126 | + closedAt: 2026-11-14T17:54:42.000Z | ||
| 117 | comments: | 127 | comments: |
| 118 | - id: 1 | 128 | - id: 1 |
| 119 | author: | 129 | author: |
| @@ -128,3 +138,49 @@ comments: | |||
| 128 | it: look at what is specific to Ollama — its `/v1/models` answering | 138 | it: look at what is specific to Ollama — its `/v1/models` answering |
| 129 | later than 2 s or not 200, Ollama listening on IPv4 only while DMR | 139 | later than 2 s or not 200, Ollama listening on IPv4 only while DMR |
| 130 | answers on both, or `OLLAMA_HOST` on the user's machine. | 140 | answers on both, or `OLLAMA_HOST` on the user's machine. |
| 141 | + - id: 2 | ||
| 142 | + author: | ||
| 143 | + name: k33g | ||
| 144 | + email: ph.charriere@gmail.com | ||
| 145 | + createdAt: 2026-11-14T17:54:42.000Z | ||
| 146 | + body: | | ||
| 147 | + Fixed on `fix/wizard-local-address` (uncommitted), without waiting for | ||
| 148 | + the exact cause on the user's machine: the fix covers hypotheses 1 | ||
| 149 | + (slow probe), 2 (IPv4 only) and 4 (Docker Desktop resolving | ||
| 150 | + host.docker.internal on the host, which hid the failed probe). | ||
| 151 | + | ||
| 152 | + - The wizard (mm init and mm acp-create) no longer takes the engine's | ||
| 153 | + resolution (2 s probe of `/models`, then a silent switch to the | ||
| 154 | + container fallback). `resolveBase` → `AGENT_BASE_URL` (or the | ||
| 155 | + legacy variable) when set; else the first local address whose model | ||
| 156 | + list answers (the provider's own list endpoint, `/api/tags` for | ||
| 157 | + Ollama, 5 s): the default, then the same with `127.0.0.1` when it | ||
| 158 | + names `localhost`; the fallback only when none answers; a provider | ||
| 159 | + without a fallback is not probed. `engine.EnvBaseURL` added. | ||
| 160 | + - The model question names the address: `Model? (listed from …)`. | ||
| 161 | + - The written file is unchanged (no `baseUrl` for the default). | ||
| 162 | + - Runtime (`mm acp` with the written file) still uses the engine's | ||
| 163 | + resolution — not touched here. | ||
| 164 | + | ||
| 165 | + Tests: `TestChooseBasePrefersALocalAddress`, `TestLocalAddresses`, | ||
| 166 | + `TestResolveBaseKeepsASlowLocalServer` (a server whose `/v1/models` | ||
| 167 | + takes 3 s: the engine's probe takes the fallback, the wizard keeps the | ||
| 168 | + local address), `TestResolveBaseHonoursAgentBaseURL`. | ||
| 169 | + | ||
| 170 | + End to end, real binary, `mm acp-create --provider ollama`, a fake | ||
| 171 | + Ollama on 127.0.0.1:11434 whose `/v1/models` takes 3 s: before → | ||
| 172 | + `Model ID? (no list from http://host.docker.internal:11434/v1 …)`; | ||
| 173 | + after → `Model? (listed from http://localhost:11434/v1)`. | ||
| 174 | + | ||
| 175 | + Not done: tasks 1 and 2 (reproduce on the user's Mac with the real | ||
| 176 | + Ollama, and name the exact cause) — the curl checks given in the | ||
| 177 | + conversation would tell. | ||
| 178 | + - id: 3 | ||
| 179 | + author: | ||
| 180 | + name: k33g | ||
| 181 | + email: ph.charriere@gmail.com | ||
| 182 | + createdAt: 2026-11-14T18:14:17.000Z | ||
| 183 | + body: | | ||
| 184 | + Tested by the user with the real Ollama: it works (local models | ||
| 185 | + listed). Closed. The exact cause on the user's machine was not named | ||
| 186 | + (tasks 1–2 closed with the user's test as the reproduction). | ||
modified
.tickets/issues/0216-mm-dockerinit-i-have-no-name-in-the-container-th.yaml +11 -4 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 216 |
| 3 | 3 | title: "mm dockerinit: \"I have no name!\" in the container - the mm account has uid 1000, compose runs as the user's uid" |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-11-14T17:31:27.000Z |
| 9 | -updatedAt: 2026-11-14T17:35:14.000Z | |
| 9 | +updatedAt: 2026-11-14T17:50:32.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - bug |
| 12 | 12 | - kind::bug |
| @@ -143,6 +143,8 @@ tasks: | ||
| 143 | 143 | name: k33g |
| 144 | 144 | email: ph.charriere@gmail.com |
| 145 | 145 | createdAt: 2026-11-14T17:31:27.000Z |
| 146 | + updatedAt: 2026-11-14T17:35:14.000Z | |
| 147 | + closedAt: 2026-11-14T17:35:14.000Z | |
| 146 | 148 | comments: |
| 147 | 149 | - id: 1 |
| 148 | 150 | author: |
| @@ -170,5 +172,10 @@ comments: | ||
| 170 | 172 | identity. Unrelated to the fix — the docs say to set `user.name` / |
| 171 | 173 | `user.email` or the `GIT_*` variables. Not tried: `mm tui` in the |
| 172 | 174 | container (no terminal here), ssh, Docker Desktop on macOS. |
| 173 | - updatedAt: 2026-11-14T17:35:14.000Z | |
| 174 | - closedAt: 2026-11-14T17:35:14.000Z | |
| 175 | + - id: 2 | |
| 176 | + author: | |
| 177 | + name: k33g | |
| 178 | + email: ph.charriere@gmail.com | |
| 179 | + createdAt: 2026-11-14T17:50:32.000Z | |
| 180 | + body: | | |
| 181 | + Closed at the user's request. | |
| @@ -1,12 +1,12 @@ | |||
| 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: 216 | 2 | id: 216 |
| 3 | title: "mm dockerinit: \"I have no name!\" in the container - the mm account has uid 1000, compose runs as the user's uid" | 3 | title: "mm dockerinit: \"I have no name!\" in the container - the mm account has uid 1000, compose runs as the user's uid" |
| 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-11-14T17:31:27.000Z | 8 | createdAt: 2026-11-14T17:31:27.000Z |
| 9 | -updatedAt: 2026-11-14T17:35:14.000Z | 9 | +updatedAt: 2026-11-14T17:50:32.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - bug | 11 | - bug |
| 12 | - kind::bug | 12 | - kind::bug |
| @@ -143,6 +143,8 @@ tasks: | |||
| 143 | name: k33g | 143 | name: k33g |
| 144 | email: ph.charriere@gmail.com | 144 | email: ph.charriere@gmail.com |
| 145 | createdAt: 2026-11-14T17:31:27.000Z | 145 | createdAt: 2026-11-14T17:31:27.000Z |
| 146 | + updatedAt: 2026-11-14T17:35:14.000Z | ||
| 147 | + closedAt: 2026-11-14T17:35:14.000Z | ||
| 146 | comments: | 148 | comments: |
| 147 | - id: 1 | 149 | - id: 1 |
| 148 | author: | 150 | author: |
| @@ -170,5 +172,10 @@ comments: | |||
| 170 | identity. Unrelated to the fix — the docs say to set `user.name` / | 172 | identity. Unrelated to the fix — the docs say to set `user.name` / |
| 171 | `user.email` or the `GIT_*` variables. Not tried: `mm tui` in the | 173 | `user.email` or the `GIT_*` variables. Not tried: `mm tui` in the |
| 172 | container (no terminal here), ssh, Docker Desktop on macOS. | 174 | container (no terminal here), ssh, Docker Desktop on macOS. |
| 173 | - updatedAt: 2026-11-14T17:35:14.000Z | 175 | + - id: 2 |
| 174 | - closedAt: 2026-11-14T17:35:14.000Z | 176 | + author: |
| 177 | + name: k33g | ||
| 178 | + email: ph.charriere@gmail.com | ||
| 179 | + createdAt: 2026-11-14T17:50:32.000Z | ||
| 180 | + body: | | ||
| 181 | + Closed at the user's request. | ||
modified
docs/en/reference/cli.md +1 -1 | @@ -240,7 +240,7 @@ An agent file outside the workspace only gives a warning: see [when the agent fi | ||
| 240 | 240 | |------|----------------------|---------| |
| 241 | 241 | | `--project yes\|no` | Keep everything in a `.mm` folder? — asked first. `yes`: the file is `.mm/agent.yaml` (the folder is created), its paths are written from the project root (`skillsDir: .mm/skills`), and `mm` / `mm tui` started there find it with no argument | `no` | |
| 242 | 242 | | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) | |
| 243 | -| `--base-url` | `openai` only: Endpoint — the service's OpenAI-compatible address (OpenAI, Cerebras, Hugging Face…); for the other providers, not asked | `https://api.openai.com/v1` for `openai`; otherwise the provider's address, or its fallback when it does not answer. Written as `baseUrl` unless it is the default | | |
| 243 | +| `--base-url` | `openai` only: Endpoint — the service's OpenAI-compatible address (OpenAI, Cerebras, Hugging Face…); for the other providers, not asked | `https://api.openai.com/v1` for `openai`; otherwise `AGENT_BASE_URL` when set, else the first of the provider's local addresses whose model list answers — its default, then the same with `127.0.0.1` when the default names `localhost` — and its container fallback (`host.docker.internal`) only when none does. The model question names the address its list comes from (`Model? (listed from …)`). Written as `baseUrl` unless it is the default | | |
| 244 | 244 | | `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default | |
| 245 | 245 | | `--model` | Model: a list of the server's models (`/api/tags` for llmman and Ollama, `/models` for the others; 5 s for a local server, 20 s for `openai`), or a name to type when there is no list — the question says why (`<VARIABLE> is not set: no list`, or the server's error). For `openai` the list is asked with the key read from the variable named above, and not asked at all when that variable is empty | the first of the list | |
| 246 | 246 | | `--family` | Model family | detected from the model's name, then from the server's family name | |
| @@ -240,7 +240,7 @@ An agent file outside the workspace only gives a warning: see [when the agent fi | |||
| 240 | |------|----------------------|---------| | 240 | |------|----------------------|---------| |
| 241 | | `--project yes\|no` | Keep everything in a `.mm` folder? — asked first. `yes`: the file is `.mm/agent.yaml` (the folder is created), its paths are written from the project root (`skillsDir: .mm/skills`), and `mm` / `mm tui` started there find it with no argument | `no` | | 241 | | `--project yes\|no` | Keep everything in a `.mm` folder? — asked first. `yes`: the file is `.mm/agent.yaml` (the folder is created), its paths are written from the project root (`skillsDir: .mm/skills`), and `mm` / `mm tui` started there find it with no argument | `no` | |
| 242 | | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) | | 242 | | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) | |
| 243 | -| `--base-url` | `openai` only: Endpoint — the service's OpenAI-compatible address (OpenAI, Cerebras, Hugging Face…); for the other providers, not asked | `https://api.openai.com/v1` for `openai`; otherwise the provider's address, or its fallback when it does not answer. Written as `baseUrl` unless it is the default | | 243 | +| `--base-url` | `openai` only: Endpoint — the service's OpenAI-compatible address (OpenAI, Cerebras, Hugging Face…); for the other providers, not asked | `https://api.openai.com/v1` for `openai`; otherwise `AGENT_BASE_URL` when set, else the first of the provider's local addresses whose model list answers — its default, then the same with `127.0.0.1` when the default names `localhost` — and its container fallback (`host.docker.internal`) only when none does. The model question names the address its list comes from (`Model? (listed from …)`). Written as `baseUrl` unless it is the default | |
| 244 | | `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default | | 244 | | `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default | |
| 245 | | `--model` | Model: a list of the server's models (`/api/tags` for llmman and Ollama, `/models` for the others; 5 s for a local server, 20 s for `openai`), or a name to type when there is no list — the question says why (`<VARIABLE> is not set: no list`, or the server's error). For `openai` the list is asked with the key read from the variable named above, and not asked at all when that variable is empty | the first of the list | | 245 | | `--model` | Model: a list of the server's models (`/api/tags` for llmman and Ollama, `/models` for the others; 5 s for a local server, 20 s for `openai`), or a name to type when there is no list — the question says why (`<VARIABLE> is not set: no list`, or the server's error). For `openai` the list is asked with the key read from the variable named above, and not asked at all when that variable is empty | the first of the list | |
| 246 | | `--family` | Model family | detected from the model's name, then from the server's family name | | 246 | | `--family` | Model family | detected from the model's name, then from the server's family name | |
modified
docs/fr/reference/cli.md +1 -1 | @@ -240,7 +240,7 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand | ||
| 240 | 240 | |--------|---------------------------------|--------| |
| 241 | 241 | | `--project yes\|no` | Tout ranger dans un dossier `.mm` ? — posée en premier. `yes` : le fichier est `.mm/agent.yaml` (le dossier est créé), ses chemins sont écrits depuis la racine du projet (`skillsDir: .mm/skills`), et `mm` / `mm tui` lancés là le trouvent sans argument | `no` | |
| 242 | 242 | | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) | |
| 243 | -| `--base-url` | `openai` seulement : Endpoint — l'adresse compatible OpenAI du service (OpenAI, Cerebras, Hugging Face…) ; pour les autres fournisseurs, pas posée | `https://api.openai.com/v1` pour `openai` ; sinon l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas. Écrite comme `baseUrl` sauf si c'est la valeur par défaut | | |
| 243 | +| `--base-url` | `openai` seulement : Endpoint — l'adresse compatible OpenAI du service (OpenAI, Cerebras, Hugging Face…) ; pour les autres fournisseurs, pas posée | `https://api.openai.com/v1` pour `openai` ; sinon `AGENT_BASE_URL` s'il est défini, sinon la première des adresses locales du fournisseur dont la liste de modèles répond — son adresse par défaut, puis la même avec `127.0.0.1` quand elle nomme `localhost` — et son adresse de secours pour conteneur (`host.docker.internal`) seulement quand aucune ne répond. La question du modèle nomme l'adresse d'où vient sa liste (`Model? (listed from …)`). Écrite comme `baseUrl` sauf si c'est la valeur par défaut | | |
| 244 | 244 | | `--api-key-env` | `openai` seulement : variable d'environnement qui contient la clé d'API | `OPENAI_API_KEY`. Écrite comme `apiKeyEnv` sauf si c'est la valeur par défaut | |
| 245 | 245 | | `--model` | Modèle : la liste des modèles du serveur (`/api/tags` pour llmman et Ollama, `/models` pour les autres ; 5 s pour un serveur local, 20 s pour `openai`), ou un nom à taper quand il n'y a pas de liste — la question dit pourquoi (`<VARIABLE> is not set: no list`, ou l'erreur du serveur). Pour `openai`, la liste est demandée avec la clé lue dans la variable nommée plus haut, et pas demandée du tout quand cette variable est vide | le premier de la liste | |
| 246 | 246 | | `--family` | Famille du modèle | détectée d'après le nom du modèle, puis d'après le nom de famille donné par le serveur | |
| @@ -240,7 +240,7 @@ Un fichier d'agent hors du workspace ne donne qu'un avertissement : voir [quand | |||
| 240 | |--------|---------------------------------|--------| | 240 | |--------|---------------------------------|--------| |
| 241 | | `--project yes\|no` | Tout ranger dans un dossier `.mm` ? — posée en premier. `yes` : le fichier est `.mm/agent.yaml` (le dossier est créé), ses chemins sont écrits depuis la racine du projet (`skillsDir: .mm/skills`), et `mm` / `mm tui` lancés là le trouvent sans argument | `no` | | 241 | | `--project yes\|no` | Tout ranger dans un dossier `.mm` ? — posée en premier. `yes` : le fichier est `.mm/agent.yaml` (le dossier est créé), ses chemins sont écrits depuis la racine du projet (`skillsDir: .mm/skills`), et `mm` / `mm tui` lancés là le trouvent sans argument | `no` | |
| 242 | | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) | | 242 | | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `kronk`, `openai` | le premier serveur local qui répond, sinon `llmman` (`openai` n'est jamais sondé : son adresse par défaut est l'API d'OpenAI) | |
| 243 | -| `--base-url` | `openai` seulement : Endpoint — l'adresse compatible OpenAI du service (OpenAI, Cerebras, Hugging Face…) ; pour les autres fournisseurs, pas posée | `https://api.openai.com/v1` pour `openai` ; sinon l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas. Écrite comme `baseUrl` sauf si c'est la valeur par défaut | | 243 | +| `--base-url` | `openai` seulement : Endpoint — l'adresse compatible OpenAI du service (OpenAI, Cerebras, Hugging Face…) ; pour les autres fournisseurs, pas posée | `https://api.openai.com/v1` pour `openai` ; sinon `AGENT_BASE_URL` s'il est défini, sinon la première des adresses locales du fournisseur dont la liste de modèles répond — son adresse par défaut, puis la même avec `127.0.0.1` quand elle nomme `localhost` — et son adresse de secours pour conteneur (`host.docker.internal`) seulement quand aucune ne répond. La question du modèle nomme l'adresse d'où vient sa liste (`Model? (listed from …)`). Écrite comme `baseUrl` sauf si c'est la valeur par défaut | |
| 244 | | `--api-key-env` | `openai` seulement : variable d'environnement qui contient la clé d'API | `OPENAI_API_KEY`. Écrite comme `apiKeyEnv` sauf si c'est la valeur par défaut | | 244 | | `--api-key-env` | `openai` seulement : variable d'environnement qui contient la clé d'API | `OPENAI_API_KEY`. Écrite comme `apiKeyEnv` sauf si c'est la valeur par défaut | |
| 245 | | `--model` | Modèle : la liste des modèles du serveur (`/api/tags` pour llmman et Ollama, `/models` pour les autres ; 5 s pour un serveur local, 20 s pour `openai`), ou un nom à taper quand il n'y a pas de liste — la question dit pourquoi (`<VARIABLE> is not set: no list`, ou l'erreur du serveur). Pour `openai`, la liste est demandée avec la clé lue dans la variable nommée plus haut, et pas demandée du tout quand cette variable est vide | le premier de la liste | | 245 | | `--model` | Modèle : la liste des modèles du serveur (`/api/tags` pour llmman et Ollama, `/models` pour les autres ; 5 s pour un serveur local, 20 s pour `openai`), ou un nom à taper quand il n'y a pas de liste — la question dit pourquoi (`<VARIABLE> is not set: no list`, ou l'erreur du serveur). Pour `openai`, la liste est demandée avec la clé lue dans la variable nommée plus haut, et pas demandée du tout quand cette variable est vide | le premier de la liste | |
| 246 | | `--family` | Famille du modèle | détectée d'après le nom du modèle, puis d'après le nom de famille donné par le serveur | | 246 | | `--family` | Famille du modèle | détectée d'après le nom du modèle, puis d'après le nom de famille donné par le serveur | |
modified
internal/agentinit/flow.go +5 -2 | @@ -222,6 +222,9 @@ func (f *Flow) Step(key string) Step { | ||
| 222 | 222 | st.Kind, st.Prompt = elicit.Text, f.typeModelPrompt() |
| 223 | 223 | break |
| 224 | 224 | } |
| 225 | + // Where the list comes from (ticket 214): a wrong address — the | |
| 226 | + // container one on the host — shows at once. | |
| 227 | + st.Prompt = fmt.Sprintf("Model? (listed from %s)", f.Base()) | |
| 225 | 228 | for _, m := range models { |
| 226 | 229 | st.Options = append(st.Options, Option{Value: m, Label: m, Detail: modelDetail(f.c.Provider, m)}) |
| 227 | 230 | } |
| @@ -578,7 +581,7 @@ func (f *Flow) Result() (Choices, Facts, string) { | ||
| 578 | 581 | // Base is the resolved server address ("" before a provider is chosen). |
| 579 | 582 | func (f *Flow) Base() string { |
| 580 | 583 | if f.base == "" && f.c.Provider != "" { |
| 581 | - f.base = resolveBase(f.c) | |
| 584 | + f.base = resolveBase(f.ctx, f.c) | |
| 582 | 585 | } |
| 583 | 586 | return f.base |
| 584 | 587 | } |
| @@ -628,7 +631,7 @@ func (f *Flow) answers(name string) bool { | ||
| 628 | 631 | if f.reachable == nil { |
| 629 | 632 | f.reachable = map[string]bool{} |
| 630 | 633 | for _, p := range local { |
| 631 | - _, err := Models(f.ctx, p, resolveBase(Choices{Provider: p})) | |
| 634 | + _, err := Models(f.ctx, p, resolveBase(f.ctx, Choices{Provider: p})) | |
| 632 | 635 | f.reachable[p] = err == nil |
| 633 | 636 | } |
| 634 | 637 | } |
| @@ -222,6 +222,9 @@ func (f *Flow) Step(key string) Step { | |||
| 222 | st.Kind, st.Prompt = elicit.Text, f.typeModelPrompt() | 222 | st.Kind, st.Prompt = elicit.Text, f.typeModelPrompt() |
| 223 | break | 223 | break |
| 224 | } | 224 | } |
| 225 | + // Where the list comes from (ticket 214): a wrong address — the | ||
| 226 | + // container one on the host — shows at once. | ||
| 227 | + st.Prompt = fmt.Sprintf("Model? (listed from %s)", f.Base()) | ||
| 225 | for _, m := range models { | 228 | for _, m := range models { |
| 226 | st.Options = append(st.Options, Option{Value: m, Label: m, Detail: modelDetail(f.c.Provider, m)}) | 229 | st.Options = append(st.Options, Option{Value: m, Label: m, Detail: modelDetail(f.c.Provider, m)}) |
| 227 | } | 230 | } |
| @@ -578,7 +581,7 @@ func (f *Flow) Result() (Choices, Facts, string) { | |||
| 578 | // Base is the resolved server address ("" before a provider is chosen). | 581 | // Base is the resolved server address ("" before a provider is chosen). |
| 579 | func (f *Flow) Base() string { | 582 | func (f *Flow) Base() string { |
| 580 | if f.base == "" && f.c.Provider != "" { | 583 | if f.base == "" && f.c.Provider != "" { |
| 581 | - f.base = resolveBase(f.c) | 584 | + f.base = resolveBase(f.ctx, f.c) |
| 582 | } | 585 | } |
| 583 | return f.base | 586 | return f.base |
| 584 | } | 587 | } |
| @@ -628,7 +631,7 @@ func (f *Flow) answers(name string) bool { | |||
| 628 | if f.reachable == nil { | 631 | if f.reachable == nil { |
| 629 | f.reachable = map[string]bool{} | 632 | f.reachable = map[string]bool{} |
| 630 | for _, p := range local { | 633 | for _, p := range local { |
| 631 | - _, err := Models(f.ctx, p, resolveBase(Choices{Provider: p})) | 634 | + _, err := Models(f.ctx, p, resolveBase(f.ctx, Choices{Provider: p})) |
| 632 | f.reachable[p] = err == nil | 635 | f.reachable[p] = err == nil |
| 633 | } | 636 | } |
| 634 | } | 637 | } |
modified
internal/agentinit/wizard.go +61 -3 | @@ -367,12 +367,70 @@ func (w *lineWizard) ask(st Step) string { | ||
| 367 | 367 | } |
| 368 | 368 | } |
| 369 | 369 | |
| 370 | -// resolveBase is the server's address: the flag's, else the provider's | |
| 371 | -// default (or its fallback, when the default does not answer). | |
| 372 | -func resolveBase(c Choices) string { | |
| 370 | +// resolveBase is the server's address the wizard lists models from: the | |
| 371 | +// flag's, else the one the environment imposes (AGENT_BASE_URL…), else the | |
| 372 | +// first that answers of the provider's local addresses, then its fallback | |
| 373 | +// (chooseBase). | |
| 374 | +// | |
| 375 | +// Ticket 214: it used to take the engine's own resolution, whose probe gives | |
| 376 | +// the default address 2 s on /models and switches to the container fallback | |
| 377 | +// (host.docker.internal) without a word. mm on a Mac with Docker Desktop — | |
| 378 | +// where that name resolves on the host too — then listed Ollama's models | |
| 379 | +// from the container address. The written file is not concerned: it carries | |
| 380 | +// no baseUrl for the default address. | |
| 381 | +func resolveBase(ctx context.Context, c Choices) string { | |
| 373 | 382 | if c.BaseURL != "" { |
| 374 | 383 | return c.BaseURL |
| 375 | 384 | } |
| 385 | + if env := engine.EnvBaseURL(c.Provider); env != "" { | |
| 386 | + return env | |
| 387 | + } | |
| 388 | + if base, fallback, ok := engine.DefaultURLs(c.Provider); ok { | |
| 389 | + return chooseBase(base, fallback, func(url string) bool { | |
| 390 | + _, err := Models(ctx, c.Provider, url) | |
| 391 | + return err == nil | |
| 392 | + }) | |
| 393 | + } | |
| 394 | + return engineBase(c) | |
| 395 | +} | |
| 396 | + | |
| 397 | +// chooseBase picks the address to list models from: the first that lists | |
| 398 | +// them among the local ones (localAddresses), else the fallback when it | |
| 399 | +// does, else the default — whose error the model question then shows. A | |
| 400 | +// provider without a fallback (llamacpp, openai) is not probed at all: there | |
| 401 | +// is nothing to choose, and openai's default is a remote service nobody | |
| 402 | +// asked mm to call yet. | |
| 403 | +func chooseBase(base, fallback string, lists func(string) bool) string { | |
| 404 | + if fallback == "" { | |
| 405 | + return base | |
| 406 | + } | |
| 407 | + for _, url := range localAddresses(base) { | |
| 408 | + if lists(url) { | |
| 409 | + return url | |
| 410 | + } | |
| 411 | + } | |
| 412 | + if lists(fallback) { | |
| 413 | + return fallback | |
| 414 | + } | |
| 415 | + return base | |
| 416 | +} | |
| 417 | + | |
| 418 | +// localAddresses is the default address, then — when it names localhost — | |
| 419 | +// the same with 127.0.0.1: a server listening on IPv4 only does not answer | |
| 420 | +// a localhost that resolves to ::1 first. | |
| 421 | +// | |
| 422 | +// localAddresses("http://localhost:11434/v1") // [http://localhost:11434/v1 http://127.0.0.1:11434/v1] | |
| 423 | +func localAddresses(base string) []string { | |
| 424 | + out := []string{base} | |
| 425 | + if strings.Contains(base, "://localhost:") || strings.HasSuffix(base, "://localhost") { | |
| 426 | + out = append(out, strings.Replace(base, "://localhost", "://127.0.0.1", 1)) | |
| 427 | + } | |
| 428 | + return out | |
| 429 | +} | |
| 430 | + | |
| 431 | +// engineBase is the engine's own resolution, for a provider the wizard has | |
| 432 | +// no addresses for. | |
| 433 | +func engineBase(c Choices) string { | |
| 376 | 434 | p, err := engine.Lookup(c.Provider) |
| 377 | 435 | if err != nil { |
| 378 | 436 | return "" |
| @@ -367,12 +367,70 @@ func (w *lineWizard) ask(st Step) string { | |||
| 367 | } | 367 | } |
| 368 | } | 368 | } |
| 369 | 369 | ||
| 370 | -// resolveBase is the server's address: the flag's, else the provider's | 370 | +// resolveBase is the server's address the wizard lists models from: the |
| 371 | -// default (or its fallback, when the default does not answer). | 371 | +// flag's, else the one the environment imposes (AGENT_BASE_URL…), else the |
| 372 | -func resolveBase(c Choices) string { | 372 | +// first that answers of the provider's local addresses, then its fallback |
| 373 | +// (chooseBase). | ||
| 374 | +// | ||
| 375 | +// Ticket 214: it used to take the engine's own resolution, whose probe gives | ||
| 376 | +// the default address 2 s on /models and switches to the container fallback | ||
| 377 | +// (host.docker.internal) without a word. mm on a Mac with Docker Desktop — | ||
| 378 | +// where that name resolves on the host too — then listed Ollama's models | ||
| 379 | +// from the container address. The written file is not concerned: it carries | ||
| 380 | +// no baseUrl for the default address. | ||
| 381 | +func resolveBase(ctx context.Context, c Choices) string { | ||
| 373 | if c.BaseURL != "" { | 382 | if c.BaseURL != "" { |
| 374 | return c.BaseURL | 383 | return c.BaseURL |
| 375 | } | 384 | } |
| 385 | + if env := engine.EnvBaseURL(c.Provider); env != "" { | ||
| 386 | + return env | ||
| 387 | + } | ||
| 388 | + if base, fallback, ok := engine.DefaultURLs(c.Provider); ok { | ||
| 389 | + return chooseBase(base, fallback, func(url string) bool { | ||
| 390 | + _, err := Models(ctx, c.Provider, url) | ||
| 391 | + return err == nil | ||
| 392 | + }) | ||
| 393 | + } | ||
| 394 | + return engineBase(c) | ||
| 395 | +} | ||
| 396 | + | ||
| 397 | +// chooseBase picks the address to list models from: the first that lists | ||
| 398 | +// them among the local ones (localAddresses), else the fallback when it | ||
| 399 | +// does, else the default — whose error the model question then shows. A | ||
| 400 | +// provider without a fallback (llamacpp, openai) is not probed at all: there | ||
| 401 | +// is nothing to choose, and openai's default is a remote service nobody | ||
| 402 | +// asked mm to call yet. | ||
| 403 | +func chooseBase(base, fallback string, lists func(string) bool) string { | ||
| 404 | + if fallback == "" { | ||
| 405 | + return base | ||
| 406 | + } | ||
| 407 | + for _, url := range localAddresses(base) { | ||
| 408 | + if lists(url) { | ||
| 409 | + return url | ||
| 410 | + } | ||
| 411 | + } | ||
| 412 | + if lists(fallback) { | ||
| 413 | + return fallback | ||
| 414 | + } | ||
| 415 | + return base | ||
| 416 | +} | ||
| 417 | + | ||
| 418 | +// localAddresses is the default address, then — when it names localhost — | ||
| 419 | +// the same with 127.0.0.1: a server listening on IPv4 only does not answer | ||
| 420 | +// a localhost that resolves to ::1 first. | ||
| 421 | +// | ||
| 422 | +// localAddresses("http://localhost:11434/v1") // [http://localhost:11434/v1 http://127.0.0.1:11434/v1] | ||
| 423 | +func localAddresses(base string) []string { | ||
| 424 | + out := []string{base} | ||
| 425 | + if strings.Contains(base, "://localhost:") || strings.HasSuffix(base, "://localhost") { | ||
| 426 | + out = append(out, strings.Replace(base, "://localhost", "://127.0.0.1", 1)) | ||
| 427 | + } | ||
| 428 | + return out | ||
| 429 | +} | ||
| 430 | + | ||
| 431 | +// engineBase is the engine's own resolution, for a provider the wizard has | ||
| 432 | +// no addresses for. | ||
| 433 | +func engineBase(c Choices) string { | ||
| 376 | p, err := engine.Lookup(c.Provider) | 434 | p, err := engine.Lookup(c.Provider) |
| 377 | if err != nil { | 435 | if err != nil { |
| 378 | return "" | 436 | return "" |
modified
internal/agentinit/wizard_test.go +102 -0 | @@ -3,13 +3,18 @@ package agentinit | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "context" |
| 6 | + "encoding/json" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 6 | 9 | "os" |
| 7 | 10 | "path/filepath" |
| 8 | 11 | "strings" |
| 9 | 12 | "testing" |
| 13 | + "time" | |
| 10 | 14 | |
| 11 | 15 | "mm/internal/config" |
| 12 | 16 | "mm/internal/elicit" |
| 17 | + "mm/internal/engine" | |
| 13 | 18 | ) |
| 14 | 19 | |
| 15 | 20 | func llmmanStub(t *testing.T) string { |
| @@ -247,3 +252,100 @@ func TestMainToStdoutAndBadAnswers(t *testing.T) { | ||
| 247 | 252 | t.Errorf("bad usage: exit %d:\n%s", code, stdout.String()) |
| 248 | 253 | } |
| 249 | 254 | } |
| 255 | + | |
| 256 | +// Ticket 214: the wizard lists the models from a local address whenever one | |
| 257 | +// answers — the default, then the same with 127.0.0.1 — and takes the | |
| 258 | +// container fallback only when none does. A provider without a fallback is | |
| 259 | +// not probed. | |
| 260 | +func TestChooseBasePrefersALocalAddress(t *testing.T) { | |
| 261 | + const base, ipv4, fallback = "http://localhost:11434/v1", "http://127.0.0.1:11434/v1", "http://host.docker.internal:11434/v1" | |
| 262 | + answering := func(urls ...string) (func(string) bool, *[]string) { | |
| 263 | + var asked []string | |
| 264 | + return func(url string) bool { | |
| 265 | + asked = append(asked, url) | |
| 266 | + for _, u := range urls { | |
| 267 | + if u == url { | |
| 268 | + return true | |
| 269 | + } | |
| 270 | + } | |
| 271 | + return false | |
| 272 | + }, &asked | |
| 273 | + } | |
| 274 | + cases := []struct { | |
| 275 | + name string | |
| 276 | + answer []string | |
| 277 | + want string | |
| 278 | + wantAsked int | |
| 279 | + }{ | |
| 280 | + {"the default answers, the fallback too (Docker Desktop on a Mac)", []string{base, fallback}, base, 1}, | |
| 281 | + {"only 127.0.0.1 answers (a server on IPv4 only)", []string{ipv4, fallback}, ipv4, 2}, | |
| 282 | + {"only the fallback answers (mm in a container)", []string{fallback}, fallback, 3}, | |
| 283 | + {"nothing answers: the default, whose error the question shows", nil, base, 3}, | |
| 284 | + } | |
| 285 | + for _, c := range cases { | |
| 286 | + lists, asked := answering(c.answer...) | |
| 287 | + if got := chooseBase(base, fallback, lists); got != c.want || len(*asked) != c.wantAsked { | |
| 288 | + t.Errorf("%s: got %s after asking %v, want %s after %d", c.name, got, *asked, c.want, c.wantAsked) | |
| 289 | + } | |
| 290 | + } | |
| 291 | + lists, asked := answering() | |
| 292 | + if got := chooseBase("https://api.openai.com/v1", "", lists); got != "https://api.openai.com/v1" || len(*asked) != 0 { | |
| 293 | + t.Errorf("no fallback: got %s, asked %v — want the default, nothing asked", got, *asked) | |
| 294 | + } | |
| 295 | +} | |
| 296 | + | |
| 297 | +func TestLocalAddresses(t *testing.T) { | |
| 298 | + for in, want := range map[string]string{ | |
| 299 | + "http://localhost:11434/v1": "http://localhost:11434/v1 http://127.0.0.1:11434/v1", | |
| 300 | + "http://localhost:12434/engines/v1": "http://localhost:12434/engines/v1 http://127.0.0.1:12434/engines/v1", | |
| 301 | + "http://127.0.0.1:17434/v1": "http://127.0.0.1:17434/v1", | |
| 302 | + } { | |
| 303 | + if got := strings.Join(localAddresses(in), " "); got != want { | |
| 304 | + t.Errorf("localAddresses(%s) = %s, want %s", in, got, want) | |
| 305 | + } | |
| 306 | + } | |
| 307 | +} | |
| 308 | + | |
| 309 | +// The address comes from the model list, not the engine's 2 s probe of | |
| 310 | +// /models: a server whose /v1/models is slow but whose list answers is kept | |
| 311 | +// — before ticket 214, the probe gave up and the fallback was taken. | |
| 312 | +func TestResolveBaseKeepsASlowLocalServer(t *testing.T) { | |
| 313 | + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 314 | + switch r.URL.Path { | |
| 315 | + case "/api/tags": | |
| 316 | + _ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "qwen3.5:4b"}}}) | |
| 317 | + case "/v1/models": | |
| 318 | + time.Sleep(3 * time.Second) // longer than the engine's probe | |
| 319 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) | |
| 320 | + default: | |
| 321 | + http.NotFound(w, r) | |
| 322 | + } | |
| 323 | + })) | |
| 324 | + t.Cleanup(slow.Close) | |
| 325 | + base := slow.URL + "/v1" | |
| 326 | + // The engine's own resolution — what the wizard used before — gives up | |
| 327 | + // on this server and takes the fallback. | |
| 328 | + fallback := "http://host.docker.internal:1/v1" | |
| 329 | + p, err := engine.Lookup("ollama") | |
| 330 | + if err != nil { | |
| 331 | + t.Fatal(err) | |
| 332 | + } | |
| 333 | + if b, err := p.Resolve(config.Config{Provider: "ollama", Model: "probe", BaseURL: base, Fallback: &fallback}); err != nil || b.BaseURL != fallback { | |
| 334 | + t.Fatalf("the engine's probe kept %s (%v): the test server is not slow enough to show ticket 214", b.BaseURL, err) | |
| 335 | + } | |
| 336 | + got := chooseBase(base, fallback, func(url string) bool { | |
| 337 | + _, err := Models(context.Background(), "ollama", url) | |
| 338 | + return err == nil | |
| 339 | + }) | |
| 340 | + if got != base { | |
| 341 | + t.Errorf("got %s, want the local server %s", got, base) | |
| 342 | + } | |
| 343 | +} | |
| 344 | + | |
| 345 | +// An address imposed by the environment is taken as is, like the engine does. | |
| 346 | +func TestResolveBaseHonoursAgentBaseURL(t *testing.T) { | |
| 347 | + t.Setenv("AGENT_BASE_URL", "http://example.invalid:9/v1") | |
| 348 | + if got := resolveBase(context.Background(), Choices{Provider: "ollama"}); got != "http://example.invalid:9/v1" { | |
| 349 | + t.Errorf("got %s", got) | |
| 350 | + } | |
| 351 | +} | |
| @@ -3,13 +3,18 @@ package agentinit | |||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | 4 | "bytes" |
| 5 | "context" | 5 | "context" |
| 6 | + "encoding/json" | ||
| 7 | + "net/http" | ||
| 8 | + "net/http/httptest" | ||
| 6 | "os" | 9 | "os" |
| 7 | "path/filepath" | 10 | "path/filepath" |
| 8 | "strings" | 11 | "strings" |
| 9 | "testing" | 12 | "testing" |
| 13 | + "time" | ||
| 10 | 14 | ||
| 11 | "mm/internal/config" | 15 | "mm/internal/config" |
| 12 | "mm/internal/elicit" | 16 | "mm/internal/elicit" |
| 17 | + "mm/internal/engine" | ||
| 13 | ) | 18 | ) |
| 14 | 19 | ||
| 15 | func llmmanStub(t *testing.T) string { | 20 | func llmmanStub(t *testing.T) string { |
| @@ -247,3 +252,100 @@ func TestMainToStdoutAndBadAnswers(t *testing.T) { | |||
| 247 | t.Errorf("bad usage: exit %d:\n%s", code, stdout.String()) | 252 | t.Errorf("bad usage: exit %d:\n%s", code, stdout.String()) |
| 248 | } | 253 | } |
| 249 | } | 254 | } |
| 255 | + | ||
| 256 | +// Ticket 214: the wizard lists the models from a local address whenever one | ||
| 257 | +// answers — the default, then the same with 127.0.0.1 — and takes the | ||
| 258 | +// container fallback only when none does. A provider without a fallback is | ||
| 259 | +// not probed. | ||
| 260 | +func TestChooseBasePrefersALocalAddress(t *testing.T) { | ||
| 261 | + const base, ipv4, fallback = "http://localhost:11434/v1", "http://127.0.0.1:11434/v1", "http://host.docker.internal:11434/v1" | ||
| 262 | + answering := func(urls ...string) (func(string) bool, *[]string) { | ||
| 263 | + var asked []string | ||
| 264 | + return func(url string) bool { | ||
| 265 | + asked = append(asked, url) | ||
| 266 | + for _, u := range urls { | ||
| 267 | + if u == url { | ||
| 268 | + return true | ||
| 269 | + } | ||
| 270 | + } | ||
| 271 | + return false | ||
| 272 | + }, &asked | ||
| 273 | + } | ||
| 274 | + cases := []struct { | ||
| 275 | + name string | ||
| 276 | + answer []string | ||
| 277 | + want string | ||
| 278 | + wantAsked int | ||
| 279 | + }{ | ||
| 280 | + {"the default answers, the fallback too (Docker Desktop on a Mac)", []string{base, fallback}, base, 1}, | ||
| 281 | + {"only 127.0.0.1 answers (a server on IPv4 only)", []string{ipv4, fallback}, ipv4, 2}, | ||
| 282 | + {"only the fallback answers (mm in a container)", []string{fallback}, fallback, 3}, | ||
| 283 | + {"nothing answers: the default, whose error the question shows", nil, base, 3}, | ||
| 284 | + } | ||
| 285 | + for _, c := range cases { | ||
| 286 | + lists, asked := answering(c.answer...) | ||
| 287 | + if got := chooseBase(base, fallback, lists); got != c.want || len(*asked) != c.wantAsked { | ||
| 288 | + t.Errorf("%s: got %s after asking %v, want %s after %d", c.name, got, *asked, c.want, c.wantAsked) | ||
| 289 | + } | ||
| 290 | + } | ||
| 291 | + lists, asked := answering() | ||
| 292 | + if got := chooseBase("https://api.openai.com/v1", "", lists); got != "https://api.openai.com/v1" || len(*asked) != 0 { | ||
| 293 | + t.Errorf("no fallback: got %s, asked %v — want the default, nothing asked", got, *asked) | ||
| 294 | + } | ||
| 295 | +} | ||
| 296 | + | ||
| 297 | +func TestLocalAddresses(t *testing.T) { | ||
| 298 | + for in, want := range map[string]string{ | ||
| 299 | + "http://localhost:11434/v1": "http://localhost:11434/v1 http://127.0.0.1:11434/v1", | ||
| 300 | + "http://localhost:12434/engines/v1": "http://localhost:12434/engines/v1 http://127.0.0.1:12434/engines/v1", | ||
| 301 | + "http://127.0.0.1:17434/v1": "http://127.0.0.1:17434/v1", | ||
| 302 | + } { | ||
| 303 | + if got := strings.Join(localAddresses(in), " "); got != want { | ||
| 304 | + t.Errorf("localAddresses(%s) = %s, want %s", in, got, want) | ||
| 305 | + } | ||
| 306 | + } | ||
| 307 | +} | ||
| 308 | + | ||
| 309 | +// The address comes from the model list, not the engine's 2 s probe of | ||
| 310 | +// /models: a server whose /v1/models is slow but whose list answers is kept | ||
| 311 | +// — before ticket 214, the probe gave up and the fallback was taken. | ||
| 312 | +func TestResolveBaseKeepsASlowLocalServer(t *testing.T) { | ||
| 313 | + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 314 | + switch r.URL.Path { | ||
| 315 | + case "/api/tags": | ||
| 316 | + _ = json.NewEncoder(w).Encode(map[string]any{"models": []any{map[string]any{"name": "qwen3.5:4b"}}}) | ||
| 317 | + case "/v1/models": | ||
| 318 | + time.Sleep(3 * time.Second) // longer than the engine's probe | ||
| 319 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) | ||
| 320 | + default: | ||
| 321 | + http.NotFound(w, r) | ||
| 322 | + } | ||
| 323 | + })) | ||
| 324 | + t.Cleanup(slow.Close) | ||
| 325 | + base := slow.URL + "/v1" | ||
| 326 | + // The engine's own resolution — what the wizard used before — gives up | ||
| 327 | + // on this server and takes the fallback. | ||
| 328 | + fallback := "http://host.docker.internal:1/v1" | ||
| 329 | + p, err := engine.Lookup("ollama") | ||
| 330 | + if err != nil { | ||
| 331 | + t.Fatal(err) | ||
| 332 | + } | ||
| 333 | + if b, err := p.Resolve(config.Config{Provider: "ollama", Model: "probe", BaseURL: base, Fallback: &fallback}); err != nil || b.BaseURL != fallback { | ||
| 334 | + t.Fatalf("the engine's probe kept %s (%v): the test server is not slow enough to show ticket 214", b.BaseURL, err) | ||
| 335 | + } | ||
| 336 | + got := chooseBase(base, fallback, func(url string) bool { | ||
| 337 | + _, err := Models(context.Background(), "ollama", url) | ||
| 338 | + return err == nil | ||
| 339 | + }) | ||
| 340 | + if got != base { | ||
| 341 | + t.Errorf("got %s, want the local server %s", got, base) | ||
| 342 | + } | ||
| 343 | +} | ||
| 344 | + | ||
| 345 | +// An address imposed by the environment is taken as is, like the engine does. | ||
| 346 | +func TestResolveBaseHonoursAgentBaseURL(t *testing.T) { | ||
| 347 | + t.Setenv("AGENT_BASE_URL", "http://example.invalid:9/v1") | ||
| 348 | + if got := resolveBase(context.Background(), Choices{Provider: "ollama"}); got != "http://example.invalid:9/v1" { | ||
| 349 | + t.Errorf("got %s", got) | ||
| 350 | + } | ||
| 351 | +} | ||
modified
internal/engine/openai_compat.go +14 -0 | @@ -87,6 +87,20 @@ func DefaultURLs(provider string) (baseURL, fallback string, ok bool) { | ||
| 87 | 87 | return p.baseURL, p.fallback, true |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | +// EnvBaseURL is the address the environment imposes on a provider — | |
| 91 | +// AGENT_BASE_URL, else the provider's legacy variable (DMR_BASE_URL) — or | |
| 92 | +// "" when none is set. Resolve takes it as is, without probing; so does | |
| 93 | +// `mm init` (ticket 214). | |
| 94 | +func EnvBaseURL(provider string) string { | |
| 95 | + if env := os.Getenv("AGENT_BASE_URL"); env != "" { | |
| 96 | + return env | |
| 97 | + } | |
| 98 | + if p, found := registry[provider].(*openaiCompat); found && p.legacyBaseURLEnv != "" { | |
| 99 | + return os.Getenv(p.legacyBaseURLEnv) | |
| 100 | + } | |
| 101 | + return "" | |
| 102 | +} | |
| 103 | + | |
| 90 | 104 | func init() { |
| 91 | 105 | register(&openaiCompat{ |
| 92 | 106 | name: "dmr", |
| @@ -87,6 +87,20 @@ func DefaultURLs(provider string) (baseURL, fallback string, ok bool) { | |||
| 87 | return p.baseURL, p.fallback, true | 87 | return p.baseURL, p.fallback, true |
| 88 | } | 88 | } |
| 89 | 89 | ||
| 90 | +// EnvBaseURL is the address the environment imposes on a provider — | ||
| 91 | +// AGENT_BASE_URL, else the provider's legacy variable (DMR_BASE_URL) — or | ||
| 92 | +// "" when none is set. Resolve takes it as is, without probing; so does | ||
| 93 | +// `mm init` (ticket 214). | ||
| 94 | +func EnvBaseURL(provider string) string { | ||
| 95 | + if env := os.Getenv("AGENT_BASE_URL"); env != "" { | ||
| 96 | + return env | ||
| 97 | + } | ||
| 98 | + if p, found := registry[provider].(*openaiCompat); found && p.legacyBaseURLEnv != "" { | ||
| 99 | + return os.Getenv(p.legacyBaseURLEnv) | ||
| 100 | + } | ||
| 101 | + return "" | ||
| 102 | +} | ||
| 103 | + | ||
| 90 | func init() { | 104 | func init() { |
| 91 | register(&openaiCompat{ | 105 | register(&openaiCompat{ |
| 92 | name: "dmr", | 106 | name: "dmr", |