Merge fix/wizard-local-address into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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", |