🛟 Updated.
7c50b59 parent: 2f57ae2 modified
.tickets/issues/0165-how-to-unload-models-when-using-decision-models.yaml +2 -2 | @@ -6,10 +6,10 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-26T05:00:19.639Z |
| 9 | -updatedAt: 2026-10-26T14:41:47.750Z | |
| 9 | +updatedAt: 2026-10-26T19:50:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - decision models |
| 12 | -milestone: v1.0.3 | |
| 12 | +milestone: v1.0.4 | |
| 13 | 13 | epic: Decision models |
| 14 | 14 | body: "" |
| 15 | 15 | tasks: [] |
| @@ -6,10 +6,10 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-26T05:00:19.639Z | 8 | createdAt: 2026-10-26T05:00:19.639Z |
| 9 | -updatedAt: 2026-10-26T14:41:47.750Z | 9 | +updatedAt: 2026-10-26T19:50:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - decision models | 11 | - decision models |
| 12 | -milestone: v1.0.3 | 12 | +milestone: v1.0.4 |
| 13 | epic: Decision models | 13 | epic: Decision models |
| 14 | body: "" | 14 | body: "" |
| 15 | tasks: [] | 15 | tasks: [] |
modified
.tickets/issues/0166-think-on-how-to-simplify-usage-of-decision-model.yaml +2 -2 | @@ -6,10 +6,10 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-26T05:01:03.034Z |
| 9 | -updatedAt: 2026-10-26T14:42:01.348Z | |
| 9 | +updatedAt: 2026-10-26T19:50:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - decision models |
| 12 | -milestone: v1.0.3 | |
| 12 | +milestone: v1.0.4 | |
| 13 | 13 | epic: Decision models |
| 14 | 14 | body: "" |
| 15 | 15 | tasks: [] |
| @@ -6,10 +6,10 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-26T05:01:03.034Z | 8 | createdAt: 2026-10-26T05:01:03.034Z |
| 9 | -updatedAt: 2026-10-26T14:42:01.348Z | 9 | +updatedAt: 2026-10-26T19:50:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - decision models | 11 | - decision models |
| 12 | -milestone: v1.0.3 | 12 | +milestone: v1.0.4 |
| 13 | epic: Decision models | 13 | epic: Decision models |
| 14 | body: "" | 14 | body: "" |
| 15 | tasks: [] | 15 | tasks: [] |
modified
.tickets/issues/0176-openai-sampling-settings-bug.yaml +2 -2 | @@ -6,11 +6,11 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-26T13:50:22.583Z |
| 9 | -updatedAt: 2026-10-26T14:40:39.691Z | |
| 9 | +updatedAt: 2026-10-26T19:45:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - kind::bug |
| 12 | 12 | - To be tested |
| 13 | -milestone: v1.0.2 | |
| 13 | +milestone: v1.0.3 | |
| 14 | 14 | body: | |
| 15 | 15 | I generated the `agent.yaml` file with this command: `mm init` |
| 16 | 16 | the result is: |
| @@ -6,11 +6,11 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-26T13:50:22.583Z | 8 | createdAt: 2026-10-26T13:50:22.583Z |
| 9 | -updatedAt: 2026-10-26T14:40:39.691Z | 9 | +updatedAt: 2026-10-26T19:45:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - kind::bug | 11 | - kind::bug |
| 12 | - To be tested | 12 | - To be tested |
| 13 | -milestone: v1.0.2 | 13 | +milestone: v1.0.3 |
| 14 | body: | | 14 | body: | |
| 15 | I generated the `agent.yaml` file with this command: `mm init` | 15 | I generated the `agent.yaml` file with this command: `mm init` |
| 16 | the result is: | 16 | the result is: |
added
.tickets/issues/0177-mm-init-openai-compatible-services.yaml +103 -0 | new file mode 100644 | ||
| @@ -0,0 +1,103 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 177 | |
| 3 | +title: "mm init: OpenAI-compatible services — ask the endpoint and the API key's variable, list or type the model" | |
| 4 | +state: closed | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-26T17:00:00.000Z | |
| 9 | +updatedAt: 2026-10-26T19:45:00.000Z | |
| 10 | +labels: | |
| 11 | + - kind::feature | |
| 12 | +milestone: v1.0.3 | |
| 13 | +related: | |
| 14 | + - 176 | |
| 15 | + - 121 | |
| 16 | +body: | | |
| 17 | + ## Need (2026-10-26) | |
| 18 | + In `mm init`, `provider: openai` is the way to write an agent for ANY OpenAI-compatible service — OpenAI, Cerebras, Hugging Face, Groq… — not only api.openai.com. For that provider, `mm init` should: | |
| 19 | + - ask the **endpoint** (default `https://api.openai.com/v1`) and write it as `baseUrl`; | |
| 20 | + - ask the **name of the environment variable** that holds the API key (default `OPENAI_API_KEY`; e.g. `CEREBRAS_API_KEY`, `HF_TOKEN`) and write it as `apiKeyEnv`; | |
| 21 | + - if that variable is set, **list the models** of that endpoint with the key; otherwise (or when the list fails) **let the user type the model ID**, saying why there is no list. | |
| 22 | + | |
| 23 | + ## Today (checked in internal/agentinit on 2026-10-26) | |
| 24 | + - The endpoint can only be changed with `--base-url`; no question. | |
| 25 | + - The key is always read from `OPENAI_API_KEY` (`facts.go`, `Models`); `apiKeyEnv` is never asked nor written. | |
| 26 | + - The model list is fetched with a **5-second timeout**, the one used to probe local servers. | |
| 27 | + | |
| 28 | + ## Reported by a user | |
| 29 | + - "The first time I choose OpenAI in mm init, no model is offered. The second time, I get the whole list." Likely cause (not reproduced: no network to OpenAI in the sandbox): the 5 s timeout on a first, cold connection to api.openai.com (DNS + TLS); and the **window does not show why** the list is empty — only the line mode prints `ModelsError()`. | |
| 30 | + - `gpt-4o-mini` works; `gpt-6-luna` fails: `400 … Function tools with reasoning_effort are not supported for gpt-6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.` mm speaks `/v1/chat/completions` only and always sends tools, so the fix is configuration: `sampling: reasoning_effort: none` (sent as written since ticket 176). Not verified (no key). | |
| 31 | + | |
| 32 | + ## Plan | |
| 33 | + 1. Two questions, for `provider: openai` only, between the server and the model: the endpoint, then the key's variable name (`--base-url` and a new `--api-key-env` answer them). A new endpoint or variable forgets the model. | |
| 34 | + 2. The model list uses the key read from that variable; no key → no request, the model question becomes a text question that says the variable is not set; a failed list → the same, with the error in the question (window and line mode alike). | |
| 35 | + 3. A longer timeout for the remote list (the `openai` provider) than for the local probes. | |
| 36 | + 4. The written file: `baseUrl` (unless it is the default), `apiKeyEnv` (unless `OPENAI_API_KEY`), and a commented `# reasoning_effort: none` line in `sampling` with why. | |
| 37 | + 5. Tests against a fake server (key sent, no key → text question, the error in the prompt, the file's keys); docs EN + FR (`mm init` how-to and CLI reference). | |
| 38 | +tasks: | |
| 39 | + - id: 1 | |
| 40 | + title: "Endpoint and key-variable questions for provider openai (+ --api-key-env)" | |
| 41 | + state: closed | |
| 42 | + priority: none | |
| 43 | + author: | |
| 44 | + name: k33g | |
| 45 | + email: ph.charriere@gmail.com | |
| 46 | + createdAt: 2026-10-26T17:00:00.000Z | |
| 47 | + - id: 2 | |
| 48 | + title: "Model list with that key; no key or a failed list: type the model ID, the reason shown" | |
| 49 | + state: closed | |
| 50 | + priority: none | |
| 51 | + author: | |
| 52 | + name: k33g | |
| 53 | + email: ph.charriere@gmail.com | |
| 54 | + createdAt: 2026-10-26T17:00:00.000Z | |
| 55 | + - id: 3 | |
| 56 | + title: "Longer timeout for the remote model list" | |
| 57 | + state: closed | |
| 58 | + priority: none | |
| 59 | + author: | |
| 60 | + name: k33g | |
| 61 | + email: ph.charriere@gmail.com | |
| 62 | + createdAt: 2026-10-26T17:00:00.000Z | |
| 63 | + - id: 4 | |
| 64 | + title: "Written file: baseUrl, apiKeyEnv, a commented reasoning_effort: none" | |
| 65 | + state: closed | |
| 66 | + priority: none | |
| 67 | + author: | |
| 68 | + name: k33g | |
| 69 | + email: ph.charriere@gmail.com | |
| 70 | + createdAt: 2026-10-26T17:00:00.000Z | |
| 71 | + - id: 5 | |
| 72 | + title: "Tests and docs EN + FR" | |
| 73 | + state: closed | |
| 74 | + priority: none | |
| 75 | + author: | |
| 76 | + name: k33g | |
| 77 | + email: ph.charriere@gmail.com | |
| 78 | + createdAt: 2026-10-26T17:00:00.000Z | |
| 79 | +comments: | |
| 80 | + - id: 1 | |
| 81 | + author: | |
| 82 | + name: k33g | |
| 83 | + email: ph.charriere@gmail.com | |
| 84 | + createdAt: 2026-10-26T18:00:00.000Z | |
| 85 | + body: | | |
| 86 | + Done (tasks 1–5). | |
| 87 | + | |
| 88 | + - **Questions** (`internal/agentinit/flow.go`): for `provider: openai` only, `endpoint` (Text, default `https://api.openai.com/v1`, must start with http(s)://, trailing / removed) and `keyenv` (Text, default `OPENAI_API_KEY`, a shell variable name), between the server and the model. Flags: `--base-url` (existing) and the new `--api-key-env`. A new endpoint or variable forgets the model; another server forgets both (unless given by a flag). The OpenAI API's own address and `OPENAI_API_KEY` are stored as "default" (no `baseUrl` / `apiKeyEnv` written). | |
| 89 | + - **Model list** (`facts.go`): `ListModels(ctx, provider, base, key)`; for openai the key is read from the named variable, and the service is not called at all when it is empty. Timeout 20 s for openai, 5 s for the local probes (was 5 s for all). | |
| 90 | + - **Why there is no list** is now in the model question itself (`Model ID? (CEREBRAS_API_KEY is not set: no list — type it)` or `Model ID? (no list from <base>: <error>)`), so the window shows it too; the line mode's separate ℹ️ line and the now unused `Flow.ModelsError` were removed. | |
| 91 | + - **Written file** (`render.go`): `baseUrl` and `apiKeyEnv` when not the defaults, the key comment names the variable, and a commented `# reasoning_effort: none` with its reason at the end of `sampling`. | |
| 92 | + - **Tests**: `internal/agentinit/openai_flow_test.go`, 7 tests against a fake service (questions only for openai; list with the named key; no key → text question, service not called; 401 → reason in the question; answers checked; what a change forgets; the file's keys). `go test ./...`, `go vet ./...`, gofmt clean. | |
| 93 | + - **End to end** with the binary and a local fake service: line mode without the key → "FAKE_SVC_KEY is not set: no list — type it", file written and loaded; `--yes` with the key → the service's models listed, `baseUrl`, `apiKeyEnv` and the hint in the file. | |
| 94 | + - **Docs** EN + FR: reference/cli (`--base-url`, `--api-key-env`, `--model` rows), how-to/create-an-agent (variant "An OpenAI-compatible service"), how-to/run-with-an-openai-compatible-server (the mm init line, the reasoning_effort variant). | |
| 95 | + | |
| 96 | + NOT verified: against the real OpenAI, Cerebras or Hugging Face APIs (no keys, no network in the sandbox); that 20 s fixes the reported "no model on the first run" (cause not reproduced); `reasoning_effort: none` with gpt-6-luna (reported by a user, not tried); the window driven live (its tests pass). | |
| 97 | + - id: 2 | |
| 98 | + author: | |
| 99 | + name: k33g | |
| 100 | + email: ph.charriere@gmail.com | |
| 101 | + createdAt: 2026-10-26T19:00:00.000Z | |
| 102 | + body: | | |
| 103 | + Tested by the user with OpenAI and Mistral AI: works. Follow-up fixed: the window's text answers (endpoint, key variable, file) now have a caret — ←→, Home/End (Ctrl-A/Ctrl-E), Backspace before / Delete after the caret, typing inserts at it (`field` in internal/tui/initwin.go, 2 tests; docs EN + FR). Closed. | |
| new file mode 100644 | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 177 | ||
| 3 | +title: "mm init: OpenAI-compatible services — ask the endpoint and the API key's variable, list or type the model" | ||
| 4 | +state: closed | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-26T17:00:00.000Z | ||
| 9 | +updatedAt: 2026-10-26T19:45:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::feature | ||
| 12 | +milestone: v1.0.3 | ||
| 13 | +related: | ||
| 14 | + - 176 | ||
| 15 | + - 121 | ||
| 16 | +body: | | ||
| 17 | + ## Need (2026-10-26) | ||
| 18 | + In `mm init`, `provider: openai` is the way to write an agent for ANY OpenAI-compatible service — OpenAI, Cerebras, Hugging Face, Groq… — not only api.openai.com. For that provider, `mm init` should: | ||
| 19 | + - ask the **endpoint** (default `https://api.openai.com/v1`) and write it as `baseUrl`; | ||
| 20 | + - ask the **name of the environment variable** that holds the API key (default `OPENAI_API_KEY`; e.g. `CEREBRAS_API_KEY`, `HF_TOKEN`) and write it as `apiKeyEnv`; | ||
| 21 | + - if that variable is set, **list the models** of that endpoint with the key; otherwise (or when the list fails) **let the user type the model ID**, saying why there is no list. | ||
| 22 | + | ||
| 23 | + ## Today (checked in internal/agentinit on 2026-10-26) | ||
| 24 | + - The endpoint can only be changed with `--base-url`; no question. | ||
| 25 | + - The key is always read from `OPENAI_API_KEY` (`facts.go`, `Models`); `apiKeyEnv` is never asked nor written. | ||
| 26 | + - The model list is fetched with a **5-second timeout**, the one used to probe local servers. | ||
| 27 | + | ||
| 28 | + ## Reported by a user | ||
| 29 | + - "The first time I choose OpenAI in mm init, no model is offered. The second time, I get the whole list." Likely cause (not reproduced: no network to OpenAI in the sandbox): the 5 s timeout on a first, cold connection to api.openai.com (DNS + TLS); and the **window does not show why** the list is empty — only the line mode prints `ModelsError()`. | ||
| 30 | + - `gpt-4o-mini` works; `gpt-6-luna` fails: `400 … Function tools with reasoning_effort are not supported for gpt-6-luna in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'.` mm speaks `/v1/chat/completions` only and always sends tools, so the fix is configuration: `sampling: reasoning_effort: none` (sent as written since ticket 176). Not verified (no key). | ||
| 31 | + | ||
| 32 | + ## Plan | ||
| 33 | + 1. Two questions, for `provider: openai` only, between the server and the model: the endpoint, then the key's variable name (`--base-url` and a new `--api-key-env` answer them). A new endpoint or variable forgets the model. | ||
| 34 | + 2. The model list uses the key read from that variable; no key → no request, the model question becomes a text question that says the variable is not set; a failed list → the same, with the error in the question (window and line mode alike). | ||
| 35 | + 3. A longer timeout for the remote list (the `openai` provider) than for the local probes. | ||
| 36 | + 4. The written file: `baseUrl` (unless it is the default), `apiKeyEnv` (unless `OPENAI_API_KEY`), and a commented `# reasoning_effort: none` line in `sampling` with why. | ||
| 37 | + 5. Tests against a fake server (key sent, no key → text question, the error in the prompt, the file's keys); docs EN + FR (`mm init` how-to and CLI reference). | ||
| 38 | +tasks: | ||
| 39 | + - id: 1 | ||
| 40 | + title: "Endpoint and key-variable questions for provider openai (+ --api-key-env)" | ||
| 41 | + state: closed | ||
| 42 | + priority: none | ||
| 43 | + author: | ||
| 44 | + name: k33g | ||
| 45 | + email: ph.charriere@gmail.com | ||
| 46 | + createdAt: 2026-10-26T17:00:00.000Z | ||
| 47 | + - id: 2 | ||
| 48 | + title: "Model list with that key; no key or a failed list: type the model ID, the reason shown" | ||
| 49 | + state: closed | ||
| 50 | + priority: none | ||
| 51 | + author: | ||
| 52 | + name: k33g | ||
| 53 | + email: ph.charriere@gmail.com | ||
| 54 | + createdAt: 2026-10-26T17:00:00.000Z | ||
| 55 | + - id: 3 | ||
| 56 | + title: "Longer timeout for the remote model list" | ||
| 57 | + state: closed | ||
| 58 | + priority: none | ||
| 59 | + author: | ||
| 60 | + name: k33g | ||
| 61 | + email: ph.charriere@gmail.com | ||
| 62 | + createdAt: 2026-10-26T17:00:00.000Z | ||
| 63 | + - id: 4 | ||
| 64 | + title: "Written file: baseUrl, apiKeyEnv, a commented reasoning_effort: none" | ||
| 65 | + state: closed | ||
| 66 | + priority: none | ||
| 67 | + author: | ||
| 68 | + name: k33g | ||
| 69 | + email: ph.charriere@gmail.com | ||
| 70 | + createdAt: 2026-10-26T17:00:00.000Z | ||
| 71 | + - id: 5 | ||
| 72 | + title: "Tests and docs EN + FR" | ||
| 73 | + state: closed | ||
| 74 | + priority: none | ||
| 75 | + author: | ||
| 76 | + name: k33g | ||
| 77 | + email: ph.charriere@gmail.com | ||
| 78 | + createdAt: 2026-10-26T17:00:00.000Z | ||
| 79 | +comments: | ||
| 80 | + - id: 1 | ||
| 81 | + author: | ||
| 82 | + name: k33g | ||
| 83 | + email: ph.charriere@gmail.com | ||
| 84 | + createdAt: 2026-10-26T18:00:00.000Z | ||
| 85 | + body: | | ||
| 86 | + Done (tasks 1–5). | ||
| 87 | + | ||
| 88 | + - **Questions** (`internal/agentinit/flow.go`): for `provider: openai` only, `endpoint` (Text, default `https://api.openai.com/v1`, must start with http(s)://, trailing / removed) and `keyenv` (Text, default `OPENAI_API_KEY`, a shell variable name), between the server and the model. Flags: `--base-url` (existing) and the new `--api-key-env`. A new endpoint or variable forgets the model; another server forgets both (unless given by a flag). The OpenAI API's own address and `OPENAI_API_KEY` are stored as "default" (no `baseUrl` / `apiKeyEnv` written). | ||
| 89 | + - **Model list** (`facts.go`): `ListModels(ctx, provider, base, key)`; for openai the key is read from the named variable, and the service is not called at all when it is empty. Timeout 20 s for openai, 5 s for the local probes (was 5 s for all). | ||
| 90 | + - **Why there is no list** is now in the model question itself (`Model ID? (CEREBRAS_API_KEY is not set: no list — type it)` or `Model ID? (no list from <base>: <error>)`), so the window shows it too; the line mode's separate ℹ️ line and the now unused `Flow.ModelsError` were removed. | ||
| 91 | + - **Written file** (`render.go`): `baseUrl` and `apiKeyEnv` when not the defaults, the key comment names the variable, and a commented `# reasoning_effort: none` with its reason at the end of `sampling`. | ||
| 92 | + - **Tests**: `internal/agentinit/openai_flow_test.go`, 7 tests against a fake service (questions only for openai; list with the named key; no key → text question, service not called; 401 → reason in the question; answers checked; what a change forgets; the file's keys). `go test ./...`, `go vet ./...`, gofmt clean. | ||
| 93 | + - **End to end** with the binary and a local fake service: line mode without the key → "FAKE_SVC_KEY is not set: no list — type it", file written and loaded; `--yes` with the key → the service's models listed, `baseUrl`, `apiKeyEnv` and the hint in the file. | ||
| 94 | + - **Docs** EN + FR: reference/cli (`--base-url`, `--api-key-env`, `--model` rows), how-to/create-an-agent (variant "An OpenAI-compatible service"), how-to/run-with-an-openai-compatible-server (the mm init line, the reasoning_effort variant). | ||
| 95 | + | ||
| 96 | + NOT verified: against the real OpenAI, Cerebras or Hugging Face APIs (no keys, no network in the sandbox); that 20 s fixes the reported "no model on the first run" (cause not reproduced); `reasoning_effort: none` with gpt-6-luna (reported by a user, not tried); the window driven live (its tests pass). | ||
| 97 | + - id: 2 | ||
| 98 | + author: | ||
| 99 | + name: k33g | ||
| 100 | + email: ph.charriere@gmail.com | ||
| 101 | + createdAt: 2026-10-26T19:00:00.000Z | ||
| 102 | + body: | | ||
| 103 | + Tested by the user with OpenAI and Mistral AI: works. Follow-up fixed: the window's text answers (endpoint, key variable, file) now have a caret — ←→, Home/End (Ctrl-A/Ctrl-E), Backspace before / Delete after the caret, typing inserts at it (`field` in internal/tui/initwin.go, 2 tests; docs EN + FR). Closed. | ||
added
.tickets/issues/0178-support-openai-responses-api.yaml +102 -0 | new file mode 100644 | ||
| @@ -0,0 +1,102 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 178 | |
| 3 | +title: "Support OpenAI's Responses API, to keep reasoning and tools together (gpt-6-luna)" | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-10-26T19:30:00.000Z | |
| 9 | +updatedAt: 2026-10-26T19:30:00.000Z | |
| 10 | +labels: | |
| 11 | + - kind::feature | |
| 12 | +epic: Providers (LLM Engine) | |
| 13 | +related: | |
| 14 | + - 176 | |
| 15 | + - 177 | |
| 16 | +body: | | |
| 17 | + ## The problem (reported 2026-10-26) | |
| 18 | + With `provider: openai`, `gpt-4o-mini` works but `gpt-6-luna` answers: | |
| 19 | + | |
| 20 | + ``` | |
| 21 | + 400 Bad Request — "Function tools with reasoning_effort are not supported for gpt-6-luna in | |
| 22 | + /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'." | |
| 23 | + ``` | |
| 24 | + | |
| 25 | + - OpenAI has two APIs: **Chat Completions** (`/v1/chat/completions`, the one every "OpenAI-compatible" server speaks) and **Responses** (`/v1/responses`, the newer one OpenAI recommends for its reasoning models). | |
| 26 | + - mm speaks **only Chat Completions**, through Genkit's `compat_oai` plugin. | |
| 27 | + - An mm agent **always sends tools** (bash, the file tools…). For this model, OpenAI refuses tools + active reasoning on Chat Completions; the model's default reasoning applies when the file sets none. | |
| 28 | + | |
| 29 | + ## What already works (configuration only) | |
| 30 | + `sampling: reasoning_effort: none` — sent as written since ticket 176, written commented by `mm init` since ticket 177. The model then works with tools **but does not reason**. Not tried with gpt-6-luna (no key in the sandbox). | |
| 31 | + | |
| 32 | + ## The goal | |
| 33 | + Keep the reasoning AND the tools on such models: let mm speak the **Responses API**. | |
| 34 | + | |
| 35 | + ## What exists (checked 2026-10-26) | |
| 36 | + - Genkit Go v1.13.1, `plugins/compat_oai`: Chat Completions only (no Responses code; one comment links the Responses docs). | |
| 37 | + - `github.com/openai/openai-go` v1.8.2, already in `go.mod`, has a `responses` package (`client.Responses.New` / `NewStreaming`). | |
| 38 | + - So: a Genkit model of our own (`genkit.DefineModel` with mm's generate function) that converts Genkit's request and response to and from the Responses API with openai-go — the same seam `engine.Provider` already gives (one implementation per wire protocol). | |
| 39 | + | |
| 40 | + ## What it must keep working (mm relies on all of it) | |
| 41 | + - Streaming text chunks (watchdog, line mode, window, ACP) — Responses streams typed events (`response.output_text.delta`, `response.function_call_arguments.delta`, `response.completed`…), not Chat Completions chunks. | |
| 42 | + - Tool calls and their results across the turns (`function_call` / `function_call_output` items instead of `tool_calls` / `role: tool` messages); the full conversation returned to mm (`engine.Generate`'s WrapModel capture). | |
| 43 | + - **The reasoning between turns**: with `store: false`, the reasoning items must be sent back (`include: ["reasoning.encrypted_content"]`) or the model loses its thread after each tool call; or `previous_response_id` with `store: true` (the conversation then lives on OpenAI's side — to decide). | |
| 44 | + - Reasoning shown as thoughts (`showThinking`), usage counts (`usage_update`, context %), the struggle signals, `structuredOutput` (`text.format: json_schema`), the context compression's summary request, `max_output_tokens` (Responses' name for the limit). | |
| 45 | + | |
| 46 | + ## Options to decide | |
| 47 | + - **A — a new provider** `openai-responses` (explicit, nothing changes for the existing `openai` files). | |
| 48 | + - **B — a key on the openai provider**, e.g. `api: responses` (one provider, two wire protocols). | |
| 49 | + - **C — automatic**: Responses for OpenAI's reasoning models only — rejected for now: a list of model names would age fast, and other OpenAI-compatible services do not all serve `/v1/responses` (which do — vLLM, Ollama, llama-server… — is NOT verified). | |
| 50 | + | |
| 51 | + ## Not done here | |
| 52 | + Nothing implemented. A real check needs an OpenAI key and a reasoning model. | |
| 53 | +tasks: | |
| 54 | + - id: 1 | |
| 55 | + title: "Decide: a new provider (A) or an api key on openai (B); store false + encrypted reasoning, or previous_response_id" | |
| 56 | + state: open | |
| 57 | + priority: none | |
| 58 | + author: | |
| 59 | + name: k33g | |
| 60 | + email: ph.charriere@gmail.com | |
| 61 | + createdAt: 2026-10-26T19:30:00.000Z | |
| 62 | + - id: 2 | |
| 63 | + title: "Check which OpenAI-compatible servers serve /v1/responses (vLLM, Ollama, llama-server…)" | |
| 64 | + state: open | |
| 65 | + priority: none | |
| 66 | + author: | |
| 67 | + name: k33g | |
| 68 | + email: ph.charriere@gmail.com | |
| 69 | + createdAt: 2026-10-26T19:30:00.000Z | |
| 70 | + - id: 3 | |
| 71 | + title: "A Genkit model on openai-go's responses package: request/response conversion, streaming events, tool calls" | |
| 72 | + state: open | |
| 73 | + priority: none | |
| 74 | + author: | |
| 75 | + name: k33g | |
| 76 | + email: ph.charriere@gmail.com | |
| 77 | + createdAt: 2026-10-26T19:30:00.000Z | |
| 78 | + - id: 4 | |
| 79 | + title: "Reasoning items carried between turns; thoughts, usage, structuredOutput, compaction, max_output_tokens" | |
| 80 | + state: open | |
| 81 | + priority: none | |
| 82 | + author: | |
| 83 | + name: k33g | |
| 84 | + email: ph.charriere@gmail.com | |
| 85 | + createdAt: 2026-10-26T19:30:00.000Z | |
| 86 | + - id: 5 | |
| 87 | + title: "Tests against a fake /v1/responses server; a live check with a reasoning model" | |
| 88 | + state: open | |
| 89 | + priority: none | |
| 90 | + author: | |
| 91 | + name: k33g | |
| 92 | + email: ph.charriere@gmail.com | |
| 93 | + createdAt: 2026-10-26T19:30:00.000Z | |
| 94 | + - id: 6 | |
| 95 | + title: "mm init and docs EN + FR" | |
| 96 | + state: open | |
| 97 | + priority: none | |
| 98 | + author: | |
| 99 | + name: k33g | |
| 100 | + email: ph.charriere@gmail.com | |
| 101 | + createdAt: 2026-10-26T19:30:00.000Z | |
| 102 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,102 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 178 | ||
| 3 | +title: "Support OpenAI's Responses API, to keep reasoning and tools together (gpt-6-luna)" | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-10-26T19:30:00.000Z | ||
| 9 | +updatedAt: 2026-10-26T19:30:00.000Z | ||
| 10 | +labels: | ||
| 11 | + - kind::feature | ||
| 12 | +epic: Providers (LLM Engine) | ||
| 13 | +related: | ||
| 14 | + - 176 | ||
| 15 | + - 177 | ||
| 16 | +body: | | ||
| 17 | + ## The problem (reported 2026-10-26) | ||
| 18 | + With `provider: openai`, `gpt-4o-mini` works but `gpt-6-luna` answers: | ||
| 19 | + | ||
| 20 | + ``` | ||
| 21 | + 400 Bad Request — "Function tools with reasoning_effort are not supported for gpt-6-luna in | ||
| 22 | + /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'." | ||
| 23 | + ``` | ||
| 24 | + | ||
| 25 | + - OpenAI has two APIs: **Chat Completions** (`/v1/chat/completions`, the one every "OpenAI-compatible" server speaks) and **Responses** (`/v1/responses`, the newer one OpenAI recommends for its reasoning models). | ||
| 26 | + - mm speaks **only Chat Completions**, through Genkit's `compat_oai` plugin. | ||
| 27 | + - An mm agent **always sends tools** (bash, the file tools…). For this model, OpenAI refuses tools + active reasoning on Chat Completions; the model's default reasoning applies when the file sets none. | ||
| 28 | + | ||
| 29 | + ## What already works (configuration only) | ||
| 30 | + `sampling: reasoning_effort: none` — sent as written since ticket 176, written commented by `mm init` since ticket 177. The model then works with tools **but does not reason**. Not tried with gpt-6-luna (no key in the sandbox). | ||
| 31 | + | ||
| 32 | + ## The goal | ||
| 33 | + Keep the reasoning AND the tools on such models: let mm speak the **Responses API**. | ||
| 34 | + | ||
| 35 | + ## What exists (checked 2026-10-26) | ||
| 36 | + - Genkit Go v1.13.1, `plugins/compat_oai`: Chat Completions only (no Responses code; one comment links the Responses docs). | ||
| 37 | + - `github.com/openai/openai-go` v1.8.2, already in `go.mod`, has a `responses` package (`client.Responses.New` / `NewStreaming`). | ||
| 38 | + - So: a Genkit model of our own (`genkit.DefineModel` with mm's generate function) that converts Genkit's request and response to and from the Responses API with openai-go — the same seam `engine.Provider` already gives (one implementation per wire protocol). | ||
| 39 | + | ||
| 40 | + ## What it must keep working (mm relies on all of it) | ||
| 41 | + - Streaming text chunks (watchdog, line mode, window, ACP) — Responses streams typed events (`response.output_text.delta`, `response.function_call_arguments.delta`, `response.completed`…), not Chat Completions chunks. | ||
| 42 | + - Tool calls and their results across the turns (`function_call` / `function_call_output` items instead of `tool_calls` / `role: tool` messages); the full conversation returned to mm (`engine.Generate`'s WrapModel capture). | ||
| 43 | + - **The reasoning between turns**: with `store: false`, the reasoning items must be sent back (`include: ["reasoning.encrypted_content"]`) or the model loses its thread after each tool call; or `previous_response_id` with `store: true` (the conversation then lives on OpenAI's side — to decide). | ||
| 44 | + - Reasoning shown as thoughts (`showThinking`), usage counts (`usage_update`, context %), the struggle signals, `structuredOutput` (`text.format: json_schema`), the context compression's summary request, `max_output_tokens` (Responses' name for the limit). | ||
| 45 | + | ||
| 46 | + ## Options to decide | ||
| 47 | + - **A — a new provider** `openai-responses` (explicit, nothing changes for the existing `openai` files). | ||
| 48 | + - **B — a key on the openai provider**, e.g. `api: responses` (one provider, two wire protocols). | ||
| 49 | + - **C — automatic**: Responses for OpenAI's reasoning models only — rejected for now: a list of model names would age fast, and other OpenAI-compatible services do not all serve `/v1/responses` (which do — vLLM, Ollama, llama-server… — is NOT verified). | ||
| 50 | + | ||
| 51 | + ## Not done here | ||
| 52 | + Nothing implemented. A real check needs an OpenAI key and a reasoning model. | ||
| 53 | +tasks: | ||
| 54 | + - id: 1 | ||
| 55 | + title: "Decide: a new provider (A) or an api key on openai (B); store false + encrypted reasoning, or previous_response_id" | ||
| 56 | + state: open | ||
| 57 | + priority: none | ||
| 58 | + author: | ||
| 59 | + name: k33g | ||
| 60 | + email: ph.charriere@gmail.com | ||
| 61 | + createdAt: 2026-10-26T19:30:00.000Z | ||
| 62 | + - id: 2 | ||
| 63 | + title: "Check which OpenAI-compatible servers serve /v1/responses (vLLM, Ollama, llama-server…)" | ||
| 64 | + state: open | ||
| 65 | + priority: none | ||
| 66 | + author: | ||
| 67 | + name: k33g | ||
| 68 | + email: ph.charriere@gmail.com | ||
| 69 | + createdAt: 2026-10-26T19:30:00.000Z | ||
| 70 | + - id: 3 | ||
| 71 | + title: "A Genkit model on openai-go's responses package: request/response conversion, streaming events, tool calls" | ||
| 72 | + state: open | ||
| 73 | + priority: none | ||
| 74 | + author: | ||
| 75 | + name: k33g | ||
| 76 | + email: ph.charriere@gmail.com | ||
| 77 | + createdAt: 2026-10-26T19:30:00.000Z | ||
| 78 | + - id: 4 | ||
| 79 | + title: "Reasoning items carried between turns; thoughts, usage, structuredOutput, compaction, max_output_tokens" | ||
| 80 | + state: open | ||
| 81 | + priority: none | ||
| 82 | + author: | ||
| 83 | + name: k33g | ||
| 84 | + email: ph.charriere@gmail.com | ||
| 85 | + createdAt: 2026-10-26T19:30:00.000Z | ||
| 86 | + - id: 5 | ||
| 87 | + title: "Tests against a fake /v1/responses server; a live check with a reasoning model" | ||
| 88 | + state: open | ||
| 89 | + priority: none | ||
| 90 | + author: | ||
| 91 | + name: k33g | ||
| 92 | + email: ph.charriere@gmail.com | ||
| 93 | + createdAt: 2026-10-26T19:30:00.000Z | ||
| 94 | + - id: 6 | ||
| 95 | + title: "mm init and docs EN + FR" | ||
| 96 | + state: open | ||
| 97 | + priority: none | ||
| 98 | + author: | ||
| 99 | + name: k33g | ||
| 100 | + email: ph.charriere@gmail.com | ||
| 101 | + createdAt: 2026-10-26T19:30:00.000Z | ||
| 102 | +comments: [] | ||
modified
.tickets/milestones.yaml +3 -0 | @@ -47,3 +47,6 @@ milestones: | ||
| 47 | 47 | - title: v1.0.3 |
| 48 | 48 | state: open |
| 49 | 49 | dueOn: 2026-10-31 |
| 50 | + - title: v1.0.4 | |
| 51 | + state: open | |
| 52 | + dueOn: 2026-10-31 | |
| @@ -47,3 +47,6 @@ milestones: | |||
| 47 | - title: v1.0.3 | 47 | - title: v1.0.3 |
| 48 | state: open | 48 | state: open |
| 49 | dueOn: 2026-10-31 | 49 | dueOn: 2026-10-31 |
| 50 | + - title: v1.0.4 | ||
| 51 | + state: open | ||
| 52 | + dueOn: 2026-10-31 | ||
added
demos/open-ai-compliant/README.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +# OpenAI compliant provider | |
| 2 | + | |
| 3 | +## OpenAI | |
| 4 | + | |
| 5 | +```bash | |
| 6 | +export OPENAI_API_KEY=sk-proj.....GO-kA | |
| 7 | +mm init | |
| 8 | +# name of the API key: OPENAI_API_KEY | |
| 9 | +# default endpoint: https://api.openai.com/v1 | |
| 10 | +``` | |
| 11 | + | |
| 12 | +## MistralAI | |
| 13 | + | |
| 14 | +```bash | |
| 15 | +export MISTRAL_API_KEY=mstrl_Ks......_3RFL8e | |
| 16 | +mm init | |
| 17 | +# name of the API key: MISTRAL_API_KEY | |
| 18 | +# default endpoint: https://api.mistral.ai/v1 | |
| 19 | +``` | |
| \ No newline at end of file | ||
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +# OpenAI compliant provider | ||
| 2 | + | ||
| 3 | +## OpenAI | ||
| 4 | + | ||
| 5 | +```bash | ||
| 6 | +export OPENAI_API_KEY=sk-proj.....GO-kA | ||
| 7 | +mm init | ||
| 8 | +# name of the API key: OPENAI_API_KEY | ||
| 9 | +# default endpoint: https://api.openai.com/v1 | ||
| 10 | +``` | ||
| 11 | + | ||
| 12 | +## MistralAI | ||
| 13 | + | ||
| 14 | +```bash | ||
| 15 | +export MISTRAL_API_KEY=mstrl_Ks......_3RFL8e | ||
| 16 | +mm init | ||
| 17 | +# name of the API key: MISTRAL_API_KEY | ||
| 18 | +# default endpoint: https://api.mistral.ai/v1 | ||
| 19 | +``` | ||
| \ No newline at end of file | \ No newline at end of file | ||
added
demos/open-ai-compliant/agent.mistral.yaml +99 -0 | new file mode 100644 | ||
| @@ -0,0 +1,99 @@ | ||
| 1 | +# developer — a coding agent: reads and changes the project, runs commands. | |
| 2 | +# | |
| 3 | +# Written by `mm init`. Every value says why it has that value; change | |
| 4 | +# them as you watch the agent work (guides/agent-settings-for-small-models.md). | |
| 5 | +# | |
| 6 | +# model family: other | |
| 7 | +# usage: developer | |
| 8 | +# machine: small — a laptop, models up to about 8B | |
| 9 | +# model facts: none — openai said nothing about this model beyond its name | |
| 10 | +# | |
| 11 | +# Where the numbers come from (the tag before their comment): | |
| 12 | +# [guide] the settings guide's row for this machine | |
| 13 | +# [rule] a rule of thumb, not specific to this model | |
| 14 | +# | |
| 15 | +# mm -config <this file> or mm -tui <this file> | |
| 16 | + | |
| 17 | +provider: openai | |
| 18 | +# The model. Chosen for it (family other): sampling (temperature, top_p, | |
| 19 | +# max_tokens). Change the model and redo them together — running mm init | |
| 20 | +# again is the quick way. | |
| 21 | +model: "ministral-8b-2512" | |
| 22 | +baseUrl: "https://api.mistral.ai/v1" | |
| 23 | +# The API key is read from MISTRAL_API_KEY: export it — it never goes in this file. | |
| 24 | +apiKeyEnv: MISTRAL_API_KEY | |
| 25 | + | |
| 26 | +# 0 = unknown: the generic openai provider cannot ask the server for its | |
| 27 | +# window, so compression counts messages (context.maxMessages) instead — set | |
| 28 | +# the window your server serves | |
| 29 | +contextWindow: 0 | |
| 30 | +# your answer: every command and file change waits for your answer | |
| 31 | +yolo: false | |
| 32 | +# usage developer | |
| 33 | +bashTool: true | |
| 34 | +editTools: true | |
| 35 | +planTool: true | |
| 36 | +askUserTool: true | |
| 37 | +# no skills: add a skills/ folder and set skillsDir to use some (see | |
| 38 | +# docs/en/how-to/add-a-skill.md) | |
| 39 | +skillsDir: no-skills | |
| 40 | +# usage developer | |
| 41 | +maxTurns: 40 | |
| 42 | +# [guide] settings guide, row "Small model (≤ 8B), short tasks" | |
| 43 | +maxOutput: 8000 | |
| 44 | +firstTokenTimeout: 3m | |
| 45 | +# [guide] aligned on bashTimeout, as the settings guide says when bash is on | |
| 46 | +# (section 3) | |
| 47 | +watchdogTimeout: 30s | |
| 48 | +# [guide] settings guide, row "Small model (≤ 8B), short tasks" | |
| 49 | +bashTimeout: 30s | |
| 50 | + | |
| 51 | +sampling: | |
| 52 | + # [rule] precise temperature for a developer agent without reasoning (family | |
| 53 | + # other) | |
| 54 | + temperature: 0.2 | |
| 55 | + # [rule] family other: skills/create-agent rule when nothing is published: | |
| 56 | + # 0.7 / 0.9, 0.2 for a code agent without reasoning | |
| 57 | + top_p: 0.9 | |
| 58 | + # [rule] room for an answer or a whole file in one tool call | |
| 59 | + max_tokens: 4096 | |
| 60 | + # one tool call per reply: small models run a check before the change it | |
| 61 | + # checks has landed otherwise | |
| 62 | + parallel_tool_calls: false | |
| 63 | + # Uncomment if the API answers "Function tools with reasoning_effort are not | |
| 64 | + # supported … set reasoning_effort to 'none'" (seen with gpt-6-luna): mm uses | |
| 65 | + # /v1/chat/completions and always sends tools. It turns the reasoning off. | |
| 66 | + # reasoning_effort: none | |
| 67 | + | |
| 68 | +context: | |
| 69 | + # compress the history before the window is full (settings guide, every row) | |
| 70 | + enabled: true | |
| 71 | + # mm's default: 75 % of the window the server serves, a quarter left for the | |
| 72 | + # next question and its tool outputs | |
| 73 | + threshold: 75 | |
| 74 | + | |
| 75 | +agentsMd: | |
| 76 | + # An AGENTS.md in the project directory is added to the system prompt. | |
| 77 | + enabled: true | |
| 78 | + | |
| 79 | +acp: | |
| 80 | + name: developer | |
| 81 | + title: "Developer (developer)" | |
| 82 | + | |
| 83 | +system: | | |
| 84 | + You are a coding agent working in a terminal, in the user's project. | |
| 85 | + You have a bash tool, file tools (read_file, write_file, edit_file) and a | |
| 86 | + plan tool. | |
| 87 | + Read before you change: look at the files and run the commands that tell | |
| 88 | + you how the project works. Make the smallest change that does the job, | |
| 89 | + then run the tests or the build to check it. | |
| 90 | + Never state the output of a command you did not run in this answer. | |
| 91 | + When a task has several steps, make a plan and update it as you go. | |
| 92 | + | |
| 93 | +# The family of this model is not in mm's table: the sampling is a generic | |
| 94 | +# starting point — check the model card. | |
| 95 | +# The server said nothing about this model beyond its name: the settings | |
| 96 | +# come from the family table. | |
| 97 | + | |
| 98 | +# Next: skills (docs/en/how-to/add-a-skill.md), MCP servers (docs/en/tutorials/mcp-getting-started.md), | |
| 99 | +# hooks (docs/en/how-to/guard-the-agent-with-hooks.md), sessions (docs/en/how-to/keep-sessions.md). | |
| new file mode 100644 | |||
| @@ -0,0 +1,99 @@ | |||
| 1 | +# developer — a coding agent: reads and changes the project, runs commands. | ||
| 2 | +# | ||
| 3 | +# Written by `mm init`. Every value says why it has that value; change | ||
| 4 | +# them as you watch the agent work (guides/agent-settings-for-small-models.md). | ||
| 5 | +# | ||
| 6 | +# model family: other | ||
| 7 | +# usage: developer | ||
| 8 | +# machine: small — a laptop, models up to about 8B | ||
| 9 | +# model facts: none — openai said nothing about this model beyond its name | ||
| 10 | +# | ||
| 11 | +# Where the numbers come from (the tag before their comment): | ||
| 12 | +# [guide] the settings guide's row for this machine | ||
| 13 | +# [rule] a rule of thumb, not specific to this model | ||
| 14 | +# | ||
| 15 | +# mm -config <this file> or mm -tui <this file> | ||
| 16 | + | ||
| 17 | +provider: openai | ||
| 18 | +# The model. Chosen for it (family other): sampling (temperature, top_p, | ||
| 19 | +# max_tokens). Change the model and redo them together — running mm init | ||
| 20 | +# again is the quick way. | ||
| 21 | +model: "ministral-8b-2512" | ||
| 22 | +baseUrl: "https://api.mistral.ai/v1" | ||
| 23 | +# The API key is read from MISTRAL_API_KEY: export it — it never goes in this file. | ||
| 24 | +apiKeyEnv: MISTRAL_API_KEY | ||
| 25 | + | ||
| 26 | +# 0 = unknown: the generic openai provider cannot ask the server for its | ||
| 27 | +# window, so compression counts messages (context.maxMessages) instead — set | ||
| 28 | +# the window your server serves | ||
| 29 | +contextWindow: 0 | ||
| 30 | +# your answer: every command and file change waits for your answer | ||
| 31 | +yolo: false | ||
| 32 | +# usage developer | ||
| 33 | +bashTool: true | ||
| 34 | +editTools: true | ||
| 35 | +planTool: true | ||
| 36 | +askUserTool: true | ||
| 37 | +# no skills: add a skills/ folder and set skillsDir to use some (see | ||
| 38 | +# docs/en/how-to/add-a-skill.md) | ||
| 39 | +skillsDir: no-skills | ||
| 40 | +# usage developer | ||
| 41 | +maxTurns: 40 | ||
| 42 | +# [guide] settings guide, row "Small model (≤ 8B), short tasks" | ||
| 43 | +maxOutput: 8000 | ||
| 44 | +firstTokenTimeout: 3m | ||
| 45 | +# [guide] aligned on bashTimeout, as the settings guide says when bash is on | ||
| 46 | +# (section 3) | ||
| 47 | +watchdogTimeout: 30s | ||
| 48 | +# [guide] settings guide, row "Small model (≤ 8B), short tasks" | ||
| 49 | +bashTimeout: 30s | ||
| 50 | + | ||
| 51 | +sampling: | ||
| 52 | + # [rule] precise temperature for a developer agent without reasoning (family | ||
| 53 | + # other) | ||
| 54 | + temperature: 0.2 | ||
| 55 | + # [rule] family other: skills/create-agent rule when nothing is published: | ||
| 56 | + # 0.7 / 0.9, 0.2 for a code agent without reasoning | ||
| 57 | + top_p: 0.9 | ||
| 58 | + # [rule] room for an answer or a whole file in one tool call | ||
| 59 | + max_tokens: 4096 | ||
| 60 | + # one tool call per reply: small models run a check before the change it | ||
| 61 | + # checks has landed otherwise | ||
| 62 | + parallel_tool_calls: false | ||
| 63 | + # Uncomment if the API answers "Function tools with reasoning_effort are not | ||
| 64 | + # supported … set reasoning_effort to 'none'" (seen with gpt-6-luna): mm uses | ||
| 65 | + # /v1/chat/completions and always sends tools. It turns the reasoning off. | ||
| 66 | + # reasoning_effort: none | ||
| 67 | + | ||
| 68 | +context: | ||
| 69 | + # compress the history before the window is full (settings guide, every row) | ||
| 70 | + enabled: true | ||
| 71 | + # mm's default: 75 % of the window the server serves, a quarter left for the | ||
| 72 | + # next question and its tool outputs | ||
| 73 | + threshold: 75 | ||
| 74 | + | ||
| 75 | +agentsMd: | ||
| 76 | + # An AGENTS.md in the project directory is added to the system prompt. | ||
| 77 | + enabled: true | ||
| 78 | + | ||
| 79 | +acp: | ||
| 80 | + name: developer | ||
| 81 | + title: "Developer (developer)" | ||
| 82 | + | ||
| 83 | +system: | | ||
| 84 | + You are a coding agent working in a terminal, in the user's project. | ||
| 85 | + You have a bash tool, file tools (read_file, write_file, edit_file) and a | ||
| 86 | + plan tool. | ||
| 87 | + Read before you change: look at the files and run the commands that tell | ||
| 88 | + you how the project works. Make the smallest change that does the job, | ||
| 89 | + then run the tests or the build to check it. | ||
| 90 | + Never state the output of a command you did not run in this answer. | ||
| 91 | + When a task has several steps, make a plan and update it as you go. | ||
| 92 | + | ||
| 93 | +# The family of this model is not in mm's table: the sampling is a generic | ||
| 94 | +# starting point — check the model card. | ||
| 95 | +# The server said nothing about this model beyond its name: the settings | ||
| 96 | +# come from the family table. | ||
| 97 | + | ||
| 98 | +# Next: skills (docs/en/how-to/add-a-skill.md), MCP servers (docs/en/tutorials/mcp-getting-started.md), | ||
| 99 | +# hooks (docs/en/how-to/guard-the-agent-with-hooks.md), sessions (docs/en/how-to/keep-sessions.md). | ||
renamed
demos/open-ai-compliant/agent.yaml +8 -4 | similarity index 90% | ||
| rename from demos/open-ai/agent.yaml | ||
| rename to demos/open-ai-compliant/agent.yaml | ||
| @@ -19,13 +19,13 @@ provider: openai | ||
| 19 | 19 | # The model. Chosen for it (family openai): sampling (temperature, |
| 20 | 20 | # max_completion_tokens). Change the model and redo them together — running |
| 21 | 21 | # mm init again is the quick way. |
| 22 | -model: "gpt-5.4" | |
| 22 | +model: "gpt-4.1" | |
| 23 | 23 | # No baseUrl: openai's defaults apply. For the record: |
| 24 | 24 | # baseUrl: "https://api.openai.com/v1" |
| 25 | 25 | # (openai has no fallback address) |
| 26 | -# The key, when the server needs one, is read from OPENAI_API_KEY (or the | |
| 27 | -# variable apiKeyEnv names): export it — it never goes in this file. | |
| 28 | -# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible server. | |
| 26 | +# The API key is read from OPENAI_API_KEY (or the variable apiKeyEnv names): | |
| 27 | +# export it — it never goes in this file. | |
| 28 | +# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible service. | |
| 29 | 29 | |
| 30 | 30 | # 0 = unknown: the generic openai provider cannot ask the server for its |
| 31 | 31 | # window, so compression counts messages (context.maxMessages) instead — set |
| @@ -62,6 +62,10 @@ sampling: | ||
| 62 | 62 | # one tool call per reply: small models run a check before the change it |
| 63 | 63 | # checks has landed otherwise |
| 64 | 64 | parallel_tool_calls: false |
| 65 | + # Uncomment if the API answers "Function tools with reasoning_effort are not | |
| 66 | + # supported … set reasoning_effort to 'none'" (seen with gpt-6-luna): mm uses | |
| 67 | + # /v1/chat/completions and always sends tools. It turns the reasoning off. | |
| 68 | + # reasoning_effort: none | |
| 65 | 69 | |
| 66 | 70 | context: |
| 67 | 71 | # compress the history before the window is full (settings guide, every row) |
| similarity index 90% | |||
| rename from demos/open-ai/agent.yaml | |||
| rename to demos/open-ai-compliant/agent.yaml | |||
| @@ -19,13 +19,13 @@ provider: openai | |||
| 19 | # The model. Chosen for it (family openai): sampling (temperature, | 19 | # The model. Chosen for it (family openai): sampling (temperature, |
| 20 | # max_completion_tokens). Change the model and redo them together — running | 20 | # max_completion_tokens). Change the model and redo them together — running |
| 21 | # mm init again is the quick way. | 21 | # mm init again is the quick way. |
| 22 | -model: "gpt-5.4" | 22 | +model: "gpt-4.1" |
| 23 | # No baseUrl: openai's defaults apply. For the record: | 23 | # No baseUrl: openai's defaults apply. For the record: |
| 24 | # baseUrl: "https://api.openai.com/v1" | 24 | # baseUrl: "https://api.openai.com/v1" |
| 25 | # (openai has no fallback address) | 25 | # (openai has no fallback address) |
| 26 | -# The key, when the server needs one, is read from OPENAI_API_KEY (or the | 26 | +# The API key is read from OPENAI_API_KEY (or the variable apiKeyEnv names): |
| 27 | -# variable apiKeyEnv names): export it — it never goes in this file. | 27 | +# export it — it never goes in this file. |
| 28 | -# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible server. | 28 | +# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible service. |
| 29 | 29 | ||
| 30 | # 0 = unknown: the generic openai provider cannot ask the server for its | 30 | # 0 = unknown: the generic openai provider cannot ask the server for its |
| 31 | # window, so compression counts messages (context.maxMessages) instead — set | 31 | # window, so compression counts messages (context.maxMessages) instead — set |
| @@ -62,6 +62,10 @@ sampling: | |||
| 62 | # one tool call per reply: small models run a check before the change it | 62 | # one tool call per reply: small models run a check before the change it |
| 63 | # checks has landed otherwise | 63 | # checks has landed otherwise |
| 64 | parallel_tool_calls: false | 64 | parallel_tool_calls: false |
| 65 | + # Uncomment if the API answers "Function tools with reasoning_effort are not | ||
| 66 | + # supported … set reasoning_effort to 'none'" (seen with gpt-6-luna): mm uses | ||
| 67 | + # /v1/chat/completions and always sends tools. It turns the reasoning off. | ||
| 68 | + # reasoning_effort: none | ||
| 65 | 69 | ||
| 66 | context: | 70 | context: |
| 67 | # compress the history before the window is full (settings guide, every row) | 71 | # compress the history before the window is full (settings guide, every row) |
modified
docs/en/how-to/create-an-agent.md +2 -1 | @@ -51,7 +51,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's | ||
| 51 | 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit |
| 52 | 52 | ``` |
| 53 | 53 | |
| 54 | - `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file) is typed; `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file. | |
| 54 | + `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file; the endpoint and the key's variable for `openai`) is typed; `←` `→` move the cursor in it, `Home` `End` go to its ends, `Delete` removes the character after the cursor, `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file. | |
| 55 | 55 | |
| 56 | 56 | The questions: the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, the agent's name and the file. |
| 57 | 57 | |
| @@ -79,6 +79,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's | ||
| 79 | 79 | ``` |
| 80 | 80 | |
| 81 | 81 | - **See the file without writing it.** `-o -` prints it on stdout. |
| 82 | +- **An OpenAI-compatible service (OpenAI, Cerebras, Hugging Face…).** Choose `openai`: two more questions follow, the endpoint (`https://api.openai.com/v1` by default) and the name of the variable that holds the API key (`OPENAI_API_KEY` by default, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Export the key before running `mm init`: with it, the model question lists the service's models; without it, you type the model's ID (the question says the variable is not set). Flags: `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. The file gets `baseUrl` and `apiKeyEnv`, and a commented `reasoning_effort: none` in `sampling`, to uncomment if the API answers "Function tools with reasoning_effort are not supported". | |
| 82 | 83 | - **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`. Without it, the file has no `baseUrl` and records the provider's default address and fallback as comments, ready to uncomment. |
| 83 | 84 | - **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only. |
| 84 | 85 | - **An existing file.** mm never overwrites it, and says so before asking anything: choose another name with `-o`. |
| @@ -51,7 +51,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's | |||
| 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit | 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit |
| 52 | ``` | 52 | ``` |
| 53 | 53 | ||
| 54 | - `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file) is typed; `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file. | 54 | + `↑` `↓` choose, `Enter` answers and goes to the next question, `Esc` goes back one question, `PgUp` `PgDn` scroll the preview, `Ctrl-C` quits without writing. A text question (the name, the file; the endpoint and the key's variable for `openai`) is typed; `←` `→` move the cursor in it, `Home` `End` go to its ends, `Delete` removes the character after the cursor, `Ctrl-U` clears it. On a screen narrower than 100 columns, `Tab` shows the preview in place of the question. When every question is answered, `Enter` writes the file. |
| 55 | 55 | ||
| 56 | The questions: the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, the agent's name and the file. | 56 | The questions: the model server, the model (from the server's list), its family (detected from the name), what the agent is for (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), which machine runs the model (small, medium, large), whether to ask before each command or file change, the agent's name and the file. |
| 57 | 57 | ||
| @@ -79,6 +79,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's | |||
| 79 | ``` | 79 | ``` |
| 80 | 80 | ||
| 81 | - **See the file without writing it.** `-o -` prints it on stdout. | 81 | - **See the file without writing it.** `-o -` prints it on stdout. |
| 82 | +- **An OpenAI-compatible service (OpenAI, Cerebras, Hugging Face…).** Choose `openai`: two more questions follow, the endpoint (`https://api.openai.com/v1` by default) and the name of the variable that holds the API key (`OPENAI_API_KEY` by default, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Export the key before running `mm init`: with it, the model question lists the service's models; without it, you type the model's ID (the question says the variable is not set). Flags: `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. The file gets `baseUrl` and `apiKeyEnv`, and a commented `reasoning_effort: none` in `sampling`, to uncomment if the API answers "Function tools with reasoning_effort are not supported". | ||
| 82 | - **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`. Without it, the file has no `baseUrl` and records the provider's default address and fallback as comments, ready to uncomment. | 83 | - **A server elsewhere.** `--base-url http://192.168.1.20:17434/v1`; it is written into the file as `baseUrl`. Without it, the file has no `baseUrl` and records the provider's default address and fallback as comments, ready to uncomment. |
| 83 | - **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only. | 84 | - **A server that does not answer.** mm says so and asks for the model's name: the settings then come from the family table only. |
| 84 | - **An existing file.** mm never overwrites it, and says so before asking anything: choose another name with `-o`. | 85 | - **An existing file.** mm never overwrites it, and says so before asking anything: choose another name with `-o`. |
modified
docs/en/how-to/run-with-an-openai-compatible-server.md +2 -1 | @@ -29,11 +29,12 @@ This guide shows how to run mini-me on a model server that has no dedicated prov | ||
| 29 | 29 | 🤖 The command printed: `hello-from-openai-provider` |
| 30 | 30 | ``` |
| 31 | 31 | |
| 32 | -`mm init --provider openai --base-url <address>` writes such a file for you (see [Create an agent with `mm init`](create-an-agent.md)). | |
| 32 | +`mm init` writes such a file for you: choose `openai`, then give the endpoint and the name of the variable that holds the key (or `--provider openai --base-url <address> --api-key-env <VARIABLE>`) (see [Create an agent with `mm init`](create-an-agent.md)). | |
| 33 | 33 | |
| 34 | 34 | ## Variants |
| 35 | 35 | |
| 36 | 36 | - **The OpenAI API.** No `baseUrl`, `OPENAI_API_KEY` exported, a model such as `gpt-5-mini`. Its reasoning models accept only the default temperature and take `max_completion_tokens` instead of `max_tokens`: write `temperature: 1.0` and `max_completion_tokens` in `sampling`. With `provider: openai` mm sends that block as written, without its own defaults (`top_p`, `max_tokens`), so remove a key the API still refuses and nothing else gets in the way. `mm init` writes such a block. (The request's content was checked against a local server; no call to the OpenAI API was made in this repository.) |
| 37 | +- **"Function tools with reasoning_effort are not supported".** Some OpenAI models (seen: `gpt-6-luna`) refuse tools on `/v1/chat/completions` while they reason; mm uses that route and always sends tools. Add `reasoning_effort: none` to `sampling` (it turns the reasoning off). Not verified in this repository: reported by a user. | |
| 37 | 38 | - **The key is refused.** The start-up warning says `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`. |
| 38 | 39 | - **Nothing answers.** The warning says `openai: nothing answers at <baseUrl> — check baseUrl: …`: the address must be the OpenAI-compatible one, usually ending in `/v1`. |
| 39 | 40 | - **A server mm has a provider for.** Prefer it (`dmr`, `llamacpp`, `llmman`, `ollama`): those know how the server reads a long prompt, turns reasoning off for a summary, unloads a model, and tell the window size. The generic provider does none of that. |
| @@ -29,11 +29,12 @@ This guide shows how to run mini-me on a model server that has no dedicated prov | |||
| 29 | 🤖 The command printed: `hello-from-openai-provider` | 29 | 🤖 The command printed: `hello-from-openai-provider` |
| 30 | ``` | 30 | ``` |
| 31 | 31 | ||
| 32 | -`mm init --provider openai --base-url <address>` writes such a file for you (see [Create an agent with `mm init`](create-an-agent.md)). | 32 | +`mm init` writes such a file for you: choose `openai`, then give the endpoint and the name of the variable that holds the key (or `--provider openai --base-url <address> --api-key-env <VARIABLE>`) (see [Create an agent with `mm init`](create-an-agent.md)). |
| 33 | 33 | ||
| 34 | ## Variants | 34 | ## Variants |
| 35 | 35 | ||
| 36 | - **The OpenAI API.** No `baseUrl`, `OPENAI_API_KEY` exported, a model such as `gpt-5-mini`. Its reasoning models accept only the default temperature and take `max_completion_tokens` instead of `max_tokens`: write `temperature: 1.0` and `max_completion_tokens` in `sampling`. With `provider: openai` mm sends that block as written, without its own defaults (`top_p`, `max_tokens`), so remove a key the API still refuses and nothing else gets in the way. `mm init` writes such a block. (The request's content was checked against a local server; no call to the OpenAI API was made in this repository.) | 36 | - **The OpenAI API.** No `baseUrl`, `OPENAI_API_KEY` exported, a model such as `gpt-5-mini`. Its reasoning models accept only the default temperature and take `max_completion_tokens` instead of `max_tokens`: write `temperature: 1.0` and `max_completion_tokens` in `sampling`. With `provider: openai` mm sends that block as written, without its own defaults (`top_p`, `max_tokens`), so remove a key the API still refuses and nothing else gets in the way. `mm init` writes such a block. (The request's content was checked against a local server; no call to the OpenAI API was made in this repository.) |
| 37 | +- **"Function tools with reasoning_effort are not supported".** Some OpenAI models (seen: `gpt-6-luna`) refuse tools on `/v1/chat/completions` while they reason; mm uses that route and always sends tools. Add `reasoning_effort: none` to `sampling` (it turns the reasoning off). Not verified in this repository: reported by a user. | ||
| 37 | - **The key is refused.** The start-up warning says `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`. | 38 | - **The key is refused.** The start-up warning says `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`. |
| 38 | - **Nothing answers.** The warning says `openai: nothing answers at <baseUrl> — check baseUrl: …`: the address must be the OpenAI-compatible one, usually ending in `/v1`. | 39 | - **Nothing answers.** The warning says `openai: nothing answers at <baseUrl> — check baseUrl: …`: the address must be the OpenAI-compatible one, usually ending in `/v1`. |
| 39 | - **A server mm has a provider for.** Prefer it (`dmr`, `llamacpp`, `llmman`, `ollama`): those know how the server reads a long prompt, turns reasoning off for a summary, unloads a model, and tell the window size. The generic provider does none of that. | 40 | - **A server mm has a provider for.** Prefer it (`dmr`, `llamacpp`, `llmman`, `ollama`): those know how the server reads a long prompt, turns reasoning off for a summary, unloads a model, and tell the window size. The generic provider does none of that. |
modified
docs/en/reference/cli.md +4 -3 | @@ -192,8 +192,9 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers | ||
| 192 | 192 | | Flag | Question when absent | Default | |
| 193 | 193 | |------|----------------------|---------| |
| 194 | 194 | | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) | |
| 195 | -| `--base-url` | — (not asked) | the provider's address, or its fallback when it does not answer | | |
| 196 | -| `--model` | Model: a list of the server's models (`/api/tags` for llmman and Ollama, `/models` for the others), or a name to type when the server does not answer | the first of the list | | |
| 195 | +| `--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 | | |
| 196 | +| `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default | | |
| 197 | +| `--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 | | |
| 197 | 198 | | `--family` | Model family | detected from the model's name, then from the server's family name | |
| 198 | 199 | | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | |
| 199 | 200 | | `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` | |
| @@ -206,7 +207,7 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers | ||
| 206 | 207 | |
| 207 | 208 | | Aspect | Behaviour | |
| 208 | 209 | |--------|-----------| |
| 209 | -| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): a full-screen window — steps (✓ answered, ▸ current, · to come, each answer beside its step), the question with the highlighted choice's description (models are listed by their short name, as in the window's header — `Qwen3.5-4B:Q4_K_M` — and the highlighted model's full identifier is shown under the list), and the file as it would be written with that choice (preview beside the question from 100 columns, in its place with `Tab` below). Keys: `↑` `↓` `Home` `End` choose, `Enter` answers, `y` / `n` answer the yes-or-no question, `Esc` takes the previous answer back (answers given by flags are skipped), `PgUp` `PgDn` scroll the preview, `Ctrl-U` clears a text answer, `Ctrl-C` quits without writing (`nothing written`, exit 1). A refused answer shows on the bottom rule. Once every question is answered, `Enter` writes the file. | | |
| 210 | +| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): a full-screen window — steps (✓ answered, ▸ current, · to come, each answer beside its step), the question with the highlighted choice's description (models are listed by their short name, as in the window's header — `Qwen3.5-4B:Q4_K_M` — and the highlighted model's full identifier is shown under the list), and the file as it would be written with that choice (preview beside the question from 100 columns, in its place with `Tab` below). Keys: `↑` `↓` `Home` `End` choose, `Enter` answers, `y` / `n` answer the yes-or-no question, `Esc` takes the previous answer back (answers given by flags are skipped), `PgUp` `PgDn` scroll the preview, `←` `→` move the cursor in a text answer (`Home` `End` or `Ctrl-A` `Ctrl-E` to its ends; typing inserts at the cursor, `Backspace` deletes before it, `Delete` after it), `Ctrl-U` clears it, `Ctrl-C` quits without writing (`nothing written`, exit 1). A refused answer shows on the bottom rule. Once every question is answered, `Enter` writes the file. | | |
| 210 | 211 | | Line mode | Otherwise: one question per screen line, numbered choices; a number, an option's text or another text answers; an empty line or `/skip` takes the default. Usages and machines are listed with a one-line description first. | |
| 211 | 212 | | Model facts | llmman and Ollama: `POST /api/show` — the native window, the GGUF's sampling (`general.sampling.*`, Ollama's Modelfile parameters), the chat template's reasoning switch (`enable_thinking`, `reasoning_effort`) and its default. llama-server: `GET /props` — `n_ctx` and the chat template. Docker Model Runner and `openai`: none — the family table decides; `openai` lists its models with `OPENAI_API_KEY`. | |
| 212 | 213 | | Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source and, per sampling block, its `origin` (`measured`, `card` or `rule`). | |
| @@ -192,8 +192,9 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers | |||
| 192 | | Flag | Question when absent | Default | | 192 | | Flag | Question when absent | Default | |
| 193 | |------|----------------------|---------| | 193 | |------|----------------------|---------| |
| 194 | | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) | | 194 | | `--provider` | Model server: `llmman`, `ollama`, `dmr`, `llamacpp`, `openai` | the first local one whose server answers, else `llmman` (`openai` is never probed: its default address is the OpenAI API) | |
| 195 | -| `--base-url` | — (not asked) | the provider's address, or its fallback when it does not answer | | 195 | +| `--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 | |
| 196 | -| `--model` | Model: a list of the server's models (`/api/tags` for llmman and Ollama, `/models` for the others), or a name to type when the server does not answer | the first of the list | | 196 | +| `--api-key-env` | `openai` only: Environment variable that holds the API key | `OPENAI_API_KEY`. Written as `apiKeyEnv` unless it is the default | |
| 197 | +| `--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 | | ||
| 197 | | `--family` | Model family | detected from the model's name, then from the server's family name | | 198 | | `--family` | Model family | detected from the model's name, then from the server's family name | |
| 198 | | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | | 199 | | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | |
| 199 | | `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` | | 200 | | `--machine` | Which machine runs the model: `small`, `medium`, `large` | `small` | |
| @@ -206,7 +207,7 @@ A turn cancelled by the user reports nothing. `demos/struggle-signals/` triggers | |||
| 206 | 207 | ||
| 207 | | Aspect | Behaviour | | 208 | | Aspect | Behaviour | |
| 208 | |--------|-----------| | 209 | |--------|-----------| |
| 209 | -| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): a full-screen window — steps (✓ answered, ▸ current, · to come, each answer beside its step), the question with the highlighted choice's description (models are listed by their short name, as in the window's header — `Qwen3.5-4B:Q4_K_M` — and the highlighted model's full identifier is shown under the list), and the file as it would be written with that choice (preview beside the question from 100 columns, in its place with `Tab` below). Keys: `↑` `↓` `Home` `End` choose, `Enter` answers, `y` / `n` answer the yes-or-no question, `Esc` takes the previous answer back (answers given by flags are skipped), `PgUp` `PgDn` scroll the preview, `Ctrl-U` clears a text answer, `Ctrl-C` quits without writing (`nothing written`, exit 1). A refused answer shows on the bottom rule. Once every question is answered, `Enter` writes the file. | | 210 | +| Window | When stdin and stdout are terminals (and neither `--yes` nor `--line`): a full-screen window — steps (✓ answered, ▸ current, · to come, each answer beside its step), the question with the highlighted choice's description (models are listed by their short name, as in the window's header — `Qwen3.5-4B:Q4_K_M` — and the highlighted model's full identifier is shown under the list), and the file as it would be written with that choice (preview beside the question from 100 columns, in its place with `Tab` below). Keys: `↑` `↓` `Home` `End` choose, `Enter` answers, `y` / `n` answer the yes-or-no question, `Esc` takes the previous answer back (answers given by flags are skipped), `PgUp` `PgDn` scroll the preview, `←` `→` move the cursor in a text answer (`Home` `End` or `Ctrl-A` `Ctrl-E` to its ends; typing inserts at the cursor, `Backspace` deletes before it, `Delete` after it), `Ctrl-U` clears it, `Ctrl-C` quits without writing (`nothing written`, exit 1). A refused answer shows on the bottom rule. Once every question is answered, `Enter` writes the file. | |
| 210 | | Line mode | Otherwise: one question per screen line, numbered choices; a number, an option's text or another text answers; an empty line or `/skip` takes the default. Usages and machines are listed with a one-line description first. | | 211 | | Line mode | Otherwise: one question per screen line, numbered choices; a number, an option's text or another text answers; an empty line or `/skip` takes the default. Usages and machines are listed with a one-line description first. | |
| 211 | | Model facts | llmman and Ollama: `POST /api/show` — the native window, the GGUF's sampling (`general.sampling.*`, Ollama's Modelfile parameters), the chat template's reasoning switch (`enable_thinking`, `reasoning_effort`) and its default. llama-server: `GET /props` — `n_ctx` and the chat template. Docker Model Runner and `openai`: none — the family table decides; `openai` lists its models with `OPENAI_API_KEY`. | | 212 | | Model facts | llmman and Ollama: `POST /api/show` — the native window, the GGUF's sampling (`general.sampling.*`, Ollama's Modelfile parameters), the chat template's reasoning switch (`enable_thinking`, `reasoning_effort`) and its default. llama-server: `GET /props` — `n_ctx` and the chat template. Docker Model Runner and `openai`: none — the family table decides; `openai` lists its models with `OPENAI_API_KEY`. | |
| 212 | | Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source and, per sampling block, its `origin` (`measured`, `card` or `rule`). | | 213 | | Where each value comes from | The server's facts, then the family table, then the usage, then the machine row; the table is `internal/agentinit/presets.yaml`, each entry with its source and, per sampling block, its `origin` (`measured`, `card` or `rule`). | |
modified
docs/fr/how-to/create-an-agent.md +2 -1 | @@ -51,7 +51,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc | ||
| 51 | 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit |
| 52 | 52 | ``` |
| 53 | 53 | |
| 54 | - `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier) se tape ; `Ctrl-U` l'efface. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. | |
| 54 | + `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier ; l'endpoint et la variable de la clé pour `openai`) se tape ; `←` `→` déplacent le curseur, `Début` `Fin` vont à ses bouts, `Suppr` efface le caractère après le curseur, `Ctrl-U` efface tout. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. | |
| 55 | 55 | |
| 56 | 56 | Les questions : le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, le nom de l'agent et le fichier. |
| 57 | 57 | |
| @@ -79,6 +79,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc | ||
| 79 | 79 | ``` |
| 80 | 80 | |
| 81 | 81 | - **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard. |
| 82 | +- **Un service compatible OpenAI (OpenAI, Cerebras, Hugging Face…).** Choisissez `openai` : deux questions de plus suivent, l'endpoint (`https://api.openai.com/v1` par défaut) et le nom de la variable qui contient la clé d'API (`OPENAI_API_KEY` par défaut, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Exportez la clé avant de lancer `mm init` : avec elle, la question du modèle liste les modèles du service ; sans elle, vous tapez l'ID du modèle (la question dit que la variable n'est pas renseignée). Options : `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. Le fichier reçoit `baseUrl` et `apiKeyEnv`, et un `reasoning_effort: none` en commentaire dans `sampling`, à décommenter si l'API répond « Function tools with reasoning_effort are not supported ». | |
| 82 | 83 | - **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`. Sans lui, le fichier n'a pas de `baseUrl` et garde en commentaire l'adresse par défaut du fournisseur et son fallback, prêts à être décommentés. |
| 83 | 84 | - **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles. |
| 84 | 85 | - **Un fichier existant.** mm ne l'écrase jamais, et le dit avant de poser la moindre question : choisissez un autre nom avec `-o`. |
| @@ -51,7 +51,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc | |||
| 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit | 51 | ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit |
| 52 | ``` | 52 | ``` |
| 53 | 53 | ||
| 54 | - `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier) se tape ; `Ctrl-U` l'efface. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. | 54 | + `↑` `↓` choisissent, `Entrée` répond et passe à la question suivante, `Échap` revient d'une question en arrière, `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-C` quitte sans rien écrire. Une question de texte (le nom, le fichier ; l'endpoint et la variable de la clé pour `openai`) se tape ; `←` `→` déplacent le curseur, `Début` `Fin` vont à ses bouts, `Suppr` efface le caractère après le curseur, `Ctrl-U` efface tout. Sur un écran de moins de 100 colonnes, `Tab` affiche l'aperçu à la place de la question. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. |
| 55 | 55 | ||
| 56 | Les questions : le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, le nom de l'agent et le fichier. | 56 | Les questions : le serveur de modèles, le modèle (dans la liste du serveur), sa famille (détectée d'après le nom), à quoi sert l'agent (developer, reviewer, translator, writer, assistant, researcher, docs, json, coordinator), quelle machine fait tourner le modèle (small, medium, large), s'il faut demander avant chaque commande ou modification de fichier, le nom de l'agent et le fichier. |
| 57 | 57 | ||
| @@ -79,6 +79,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc | |||
| 79 | ``` | 79 | ``` |
| 80 | 80 | ||
| 81 | - **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard. | 81 | - **Voir le fichier sans l'écrire.** `-o -` l'affiche sur la sortie standard. |
| 82 | +- **Un service compatible OpenAI (OpenAI, Cerebras, Hugging Face…).** Choisissez `openai` : deux questions de plus suivent, l'endpoint (`https://api.openai.com/v1` par défaut) et le nom de la variable qui contient la clé d'API (`OPENAI_API_KEY` par défaut, `CEREBRAS_API_KEY`, `HF_TOKEN`…). Exportez la clé avant de lancer `mm init` : avec elle, la question du modèle liste les modèles du service ; sans elle, vous tapez l'ID du modèle (la question dit que la variable n'est pas renseignée). Options : `--provider openai --base-url https://api.cerebras.ai/v1 --api-key-env CEREBRAS_API_KEY`. Le fichier reçoit `baseUrl` et `apiKeyEnv`, et un `reasoning_effort: none` en commentaire dans `sampling`, à décommenter si l'API répond « Function tools with reasoning_effort are not supported ». | ||
| 82 | - **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`. Sans lui, le fichier n'a pas de `baseUrl` et garde en commentaire l'adresse par défaut du fournisseur et son fallback, prêts à être décommentés. | 83 | - **Un serveur ailleurs.** `--base-url http://192.168.1.20:17434/v1` ; il est écrit dans le fichier comme `baseUrl`. Sans lui, le fichier n'a pas de `baseUrl` et garde en commentaire l'adresse par défaut du fournisseur et son fallback, prêts à être décommentés. |
| 83 | - **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles. | 84 | - **Un serveur qui ne répond pas.** mm le dit et demande le nom du modèle : les réglages viennent alors de la seule table des familles. |
| 84 | - **Un fichier existant.** mm ne l'écrase jamais, et le dit avant de poser la moindre question : choisissez un autre nom avec `-o`. | 85 | - **Un fichier existant.** mm ne l'écrase jamais, et le dit avant de poser la moindre question : choisissez un autre nom avec `-o`. |
modified
docs/fr/how-to/run-with-an-openai-compatible-server.md +2 -1 | @@ -29,11 +29,12 @@ Ce guide montre comment faire tourner mini-me sur un serveur de modèles qui n'a | ||
| 29 | 29 | 🤖 The command printed: `hello-from-openai-provider` |
| 30 | 30 | ``` |
| 31 | 31 | |
| 32 | -`mm init --provider openai --base-url <adresse>` écrit ce fichier pour vous (voir [Créer un agent avec `mm init`](create-an-agent.md)). | |
| 32 | +`mm init` écrit ce fichier pour vous : choisissez `openai`, puis donnez l'endpoint et le nom de la variable qui contient la clé (ou `--provider openai --base-url <adresse> --api-key-env <VARIABLE>`) (voir [Créer un agent avec `mm init`](create-an-agent.md)). | |
| 33 | 33 | |
| 34 | 34 | ## Variantes |
| 35 | 35 | |
| 36 | 36 | - **L'API d'OpenAI.** Pas de `baseUrl`, `OPENAI_API_KEY` exportée, un modèle comme `gpt-5-mini`. Ses modèles à raisonnement n'acceptent que la température par défaut et prennent `max_completion_tokens` au lieu de `max_tokens` : écrivez `temperature: 1.0` et `max_completion_tokens` dans `sampling`. Avec `provider: openai`, mm envoie ce bloc tel quel, sans ses propres valeurs par défaut (`top_p`, `max_tokens`) : retirez seulement une clé que l'API refuserait encore. `mm init` écrit un tel bloc. (Le contenu de la requête a été vérifié contre un serveur local ; aucun appel à l'API OpenAI n'a été fait dans ce dépôt.) |
| 37 | +- **« Function tools with reasoning_effort are not supported ».** Certains modèles OpenAI (vu : `gpt-6-luna`) refusent les outils sur `/v1/chat/completions` pendant qu'ils raisonnent ; mm utilise cette route et envoie toujours des outils. Ajoutez `reasoning_effort: none` à `sampling` (cela coupe le raisonnement). Non vérifié dans ce dépôt : signalé par un utilisateur. | |
| 37 | 38 | - **La clé est refusée.** L'avertissement de démarrage indique `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`. |
| 38 | 39 | - **Rien ne répond.** L'avertissement indique `openai: nothing answers at <baseUrl> — check baseUrl: …` : l'adresse doit être celle compatible OpenAI, qui finit généralement par `/v1`. |
| 39 | 40 | - **Un serveur pour lequel mm a un fournisseur.** Préférez-le (`dmr`, `llamacpp`, `llmman`, `ollama`) : ceux-là savent comment le serveur lit un long prompt, coupent le raisonnement pour un résumé, déchargent un modèle et donnent la taille de la fenêtre. Le fournisseur générique ne fait rien de tout cela. |
| @@ -29,11 +29,12 @@ Ce guide montre comment faire tourner mini-me sur un serveur de modèles qui n'a | |||
| 29 | 🤖 The command printed: `hello-from-openai-provider` | 29 | 🤖 The command printed: `hello-from-openai-provider` |
| 30 | ``` | 30 | ``` |
| 31 | 31 | ||
| 32 | -`mm init --provider openai --base-url <adresse>` écrit ce fichier pour vous (voir [Créer un agent avec `mm init`](create-an-agent.md)). | 32 | +`mm init` écrit ce fichier pour vous : choisissez `openai`, puis donnez l'endpoint et le nom de la variable qui contient la clé (ou `--provider openai --base-url <adresse> --api-key-env <VARIABLE>`) (voir [Créer un agent avec `mm init`](create-an-agent.md)). |
| 33 | 33 | ||
| 34 | ## Variantes | 34 | ## Variantes |
| 35 | 35 | ||
| 36 | - **L'API d'OpenAI.** Pas de `baseUrl`, `OPENAI_API_KEY` exportée, un modèle comme `gpt-5-mini`. Ses modèles à raisonnement n'acceptent que la température par défaut et prennent `max_completion_tokens` au lieu de `max_tokens` : écrivez `temperature: 1.0` et `max_completion_tokens` dans `sampling`. Avec `provider: openai`, mm envoie ce bloc tel quel, sans ses propres valeurs par défaut (`top_p`, `max_tokens`) : retirez seulement une clé que l'API refuserait encore. `mm init` écrit un tel bloc. (Le contenu de la requête a été vérifié contre un serveur local ; aucun appel à l'API OpenAI n'a été fait dans ce dépôt.) | 36 | - **L'API d'OpenAI.** Pas de `baseUrl`, `OPENAI_API_KEY` exportée, un modèle comme `gpt-5-mini`. Ses modèles à raisonnement n'acceptent que la température par défaut et prennent `max_completion_tokens` au lieu de `max_tokens` : écrivez `temperature: 1.0` et `max_completion_tokens` dans `sampling`. Avec `provider: openai`, mm envoie ce bloc tel quel, sans ses propres valeurs par défaut (`top_p`, `max_tokens`) : retirez seulement une clé que l'API refuserait encore. `mm init` écrit un tel bloc. (Le contenu de la requête a été vérifié contre un serveur local ; aucun appel à l'API OpenAI n'a été fait dans ce dépôt.) |
| 37 | +- **« Function tools with reasoning_effort are not supported ».** Certains modèles OpenAI (vu : `gpt-6-luna`) refusent les outils sur `/v1/chat/completions` pendant qu'ils raisonnent ; mm utilise cette route et envoie toujours des outils. Ajoutez `reasoning_effort: none` à `sampling` (cela coupe le raisonnement). Non vérifié dans ce dépôt : signalé par un utilisateur. | ||
| 37 | - **La clé est refusée.** L'avertissement de démarrage indique `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`. | 38 | - **La clé est refusée.** L'avertissement de démarrage indique `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`. |
| 38 | - **Rien ne répond.** L'avertissement indique `openai: nothing answers at <baseUrl> — check baseUrl: …` : l'adresse doit être celle compatible OpenAI, qui finit généralement par `/v1`. | 39 | - **Rien ne répond.** L'avertissement indique `openai: nothing answers at <baseUrl> — check baseUrl: …` : l'adresse doit être celle compatible OpenAI, qui finit généralement par `/v1`. |
| 39 | - **Un serveur pour lequel mm a un fournisseur.** Préférez-le (`dmr`, `llamacpp`, `llmman`, `ollama`) : ceux-là savent comment le serveur lit un long prompt, coupent le raisonnement pour un résumé, déchargent un modèle et donnent la taille de la fenêtre. Le fournisseur générique ne fait rien de tout cela. | 40 | - **Un serveur pour lequel mm a un fournisseur.** Préférez-le (`dmr`, `llamacpp`, `llmman`, `ollama`) : ceux-là savent comment le serveur lit un long prompt, coupent le raisonnement pour un résumé, déchargent un modèle et donnent la taille de la fenêtre. Le fournisseur générique ne fait rien de tout cela. |
modified
docs/fr/reference/cli.md +4 -3 | @@ -192,8 +192,9 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé | ||
| 192 | 192 | | Option | Question quand elle est absente | Défaut | |
| 193 | 193 | |--------|---------------------------------|--------| |
| 194 | 194 | | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `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) | |
| 195 | -| `--base-url` | — (pas posée) | l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas | | |
| 196 | -| `--model` | Modèle : la liste des modèles du serveur (`/api/tags` pour llmman et Ollama, `/models` pour les autres), ou un nom à taper quand le serveur ne répond pas | le premier de la liste | | |
| 195 | +| `--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 | | |
| 196 | +| `--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 | | |
| 197 | +| `--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 | | |
| 197 | 198 | | `--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 | |
| 198 | 199 | | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | |
| 199 | 200 | | `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` | |
| @@ -206,7 +207,7 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé | ||
| 206 | 207 | |
| 207 | 208 | | Aspect | Comportement | |
| 208 | 209 | |--------|--------------| |
| 209 | -| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : une fenêtre plein écran — les étapes (✓ répondue, ▸ en cours, · à venir, chaque réponse à côté de son étape), la question avec la description du choix surligné (les modèles sont listés par leur nom court, comme dans l'en-tête de la fenêtre — `Qwen3.5-4B:Q4_K_M` — et l'identifiant complet du modèle surligné s'affiche sous la liste), et le fichier tel qu'il serait écrit avec ce choix (aperçu à côté de la question à partir de 100 colonnes, à sa place avec `Tab` en dessous). Touches : `↑` `↓` `Début` `Fin` choisissent, `Entrée` répond, `y` / `n` répondent à la question oui ou non, `Échap` reprend la réponse précédente (les réponses données par option sont sautées), `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-U` efface une réponse de texte, `Ctrl-C` quitte sans rien écrire (`nothing written`, code 1). Une réponse refusée s'affiche sur le trait du bas. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. | | |
| 210 | +| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : une fenêtre plein écran — les étapes (✓ répondue, ▸ en cours, · à venir, chaque réponse à côté de son étape), la question avec la description du choix surligné (les modèles sont listés par leur nom court, comme dans l'en-tête de la fenêtre — `Qwen3.5-4B:Q4_K_M` — et l'identifiant complet du modèle surligné s'affiche sous la liste), et le fichier tel qu'il serait écrit avec ce choix (aperçu à côté de la question à partir de 100 colonnes, à sa place avec `Tab` en dessous). Touches : `↑` `↓` `Début` `Fin` choisissent, `Entrée` répond, `y` / `n` répondent à la question oui ou non, `Échap` reprend la réponse précédente (les réponses données par option sont sautées), `PgUp` `PgDn` font défiler l'aperçu, `←` `→` déplacent le curseur dans une réponse de texte (`Début` `Fin` ou `Ctrl-A` `Ctrl-E` pour aller à ses bouts ; la saisie s'insère au curseur, `Retour arrière` efface avant lui, `Suppr` après), `Ctrl-U` efface une réponse de texte, `Ctrl-C` quitte sans rien écrire (`nothing written`, code 1). Une réponse refusée s'affiche sur le trait du bas. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. | | |
| 210 | 211 | | Mode ligne | Sinon : une question par ligne, choix numérotés ; un numéro, le texte d'une option ou un autre texte y répond ; une ligne vide ou `/skip` prend le défaut. Les usages et les machines sont d'abord listés avec une description d'une ligne. | |
| 211 | 212 | | Faits sur le modèle | llmman et Ollama : `POST /api/show` — la fenêtre native, l'échantillonnage du GGUF (`general.sampling.*`, les paramètres du Modelfile d'Ollama), l'interrupteur de raisonnement du chat template (`enable_thinking`, `reasoning_effort`) et son défaut. llama-server : `GET /props` — `n_ctx` et le chat template. Docker Model Runner et `openai` : rien — la table des familles décide ; `openai` liste ses modèles avec `OPENAI_API_KEY`. | |
| 212 | 213 | | Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source et, par bloc d'échantillonnage, son `origin` (`measured`, `card` ou `rule`). | |
| @@ -192,8 +192,9 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé | |||
| 192 | | Option | Question quand elle est absente | Défaut | | 192 | | Option | Question quand elle est absente | Défaut | |
| 193 | |--------|---------------------------------|--------| | 193 | |--------|---------------------------------|--------| |
| 194 | | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `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) | | 194 | | `--provider` | Serveur de modèles : `llmman`, `ollama`, `dmr`, `llamacpp`, `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) | |
| 195 | -| `--base-url` | — (pas posée) | l'adresse du fournisseur, ou son adresse de secours quand elle ne répond pas | | 195 | +| `--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 | |
| 196 | -| `--model` | Modèle : la liste des modèles du serveur (`/api/tags` pour llmman et Ollama, `/models` pour les autres), ou un nom à taper quand le serveur ne répond pas | le premier de la liste | | 196 | +| `--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 | |
| 197 | +| `--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 | | ||
| 197 | | `--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 | | 198 | | `--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 | |
| 198 | | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | | 199 | | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` | |
| 199 | | `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` | | 200 | | `--machine` | Quelle machine fait tourner le modèle : `small`, `medium`, `large` | `small` | |
| @@ -206,7 +207,7 @@ Un tour annulé par l'utilisateur ne signale rien. `demos/struggle-signals/` dé | |||
| 206 | 207 | ||
| 207 | | Aspect | Comportement | | 208 | | Aspect | Comportement | |
| 208 | |--------|--------------| | 209 | |--------|--------------| |
| 209 | -| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : une fenêtre plein écran — les étapes (✓ répondue, ▸ en cours, · à venir, chaque réponse à côté de son étape), la question avec la description du choix surligné (les modèles sont listés par leur nom court, comme dans l'en-tête de la fenêtre — `Qwen3.5-4B:Q4_K_M` — et l'identifiant complet du modèle surligné s'affiche sous la liste), et le fichier tel qu'il serait écrit avec ce choix (aperçu à côté de la question à partir de 100 colonnes, à sa place avec `Tab` en dessous). Touches : `↑` `↓` `Début` `Fin` choisissent, `Entrée` répond, `y` / `n` répondent à la question oui ou non, `Échap` reprend la réponse précédente (les réponses données par option sont sautées), `PgUp` `PgDn` font défiler l'aperçu, `Ctrl-U` efface une réponse de texte, `Ctrl-C` quitte sans rien écrire (`nothing written`, code 1). Une réponse refusée s'affiche sur le trait du bas. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. | | 210 | +| Fenêtre | Quand l'entrée et la sortie standard sont des terminaux (et ni `--yes` ni `--line`) : une fenêtre plein écran — les étapes (✓ répondue, ▸ en cours, · à venir, chaque réponse à côté de son étape), la question avec la description du choix surligné (les modèles sont listés par leur nom court, comme dans l'en-tête de la fenêtre — `Qwen3.5-4B:Q4_K_M` — et l'identifiant complet du modèle surligné s'affiche sous la liste), et le fichier tel qu'il serait écrit avec ce choix (aperçu à côté de la question à partir de 100 colonnes, à sa place avec `Tab` en dessous). Touches : `↑` `↓` `Début` `Fin` choisissent, `Entrée` répond, `y` / `n` répondent à la question oui ou non, `Échap` reprend la réponse précédente (les réponses données par option sont sautées), `PgUp` `PgDn` font défiler l'aperçu, `←` `→` déplacent le curseur dans une réponse de texte (`Début` `Fin` ou `Ctrl-A` `Ctrl-E` pour aller à ses bouts ; la saisie s'insère au curseur, `Retour arrière` efface avant lui, `Suppr` après), `Ctrl-U` efface une réponse de texte, `Ctrl-C` quitte sans rien écrire (`nothing written`, code 1). Une réponse refusée s'affiche sur le trait du bas. Quand toutes les questions ont une réponse, `Entrée` écrit le fichier. | |
| 210 | | Mode ligne | Sinon : une question par ligne, choix numérotés ; un numéro, le texte d'une option ou un autre texte y répond ; une ligne vide ou `/skip` prend le défaut. Les usages et les machines sont d'abord listés avec une description d'une ligne. | | 211 | | Mode ligne | Sinon : une question par ligne, choix numérotés ; un numéro, le texte d'une option ou un autre texte y répond ; une ligne vide ou `/skip` prend le défaut. Les usages et les machines sont d'abord listés avec une description d'une ligne. | |
| 211 | | Faits sur le modèle | llmman et Ollama : `POST /api/show` — la fenêtre native, l'échantillonnage du GGUF (`general.sampling.*`, les paramètres du Modelfile d'Ollama), l'interrupteur de raisonnement du chat template (`enable_thinking`, `reasoning_effort`) et son défaut. llama-server : `GET /props` — `n_ctx` et le chat template. Docker Model Runner et `openai` : rien — la table des familles décide ; `openai` liste ses modèles avec `OPENAI_API_KEY`. | | 212 | | Faits sur le modèle | llmman et Ollama : `POST /api/show` — la fenêtre native, l'échantillonnage du GGUF (`general.sampling.*`, les paramètres du Modelfile d'Ollama), l'interrupteur de raisonnement du chat template (`enable_thinking`, `reasoning_effort`) et son défaut. llama-server : `GET /props` — `n_ctx` et le chat template. Docker Model Runner et `openai` : rien — la table des familles décide ; `openai` liste ses modèles avec `OPENAI_API_KEY`. | |
| 212 | | Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source et, par bloc d'échantillonnage, son `origin` (`measured`, `card` ou `rule`). | | 213 | | Origine de chaque valeur | Les faits du serveur, puis la table des familles, puis l'usage, puis la ligne de la machine ; la table est `internal/agentinit/presets.yaml`, chaque entrée avec sa source et, par bloc d'échantillonnage, son `origin` (`measured`, `card` ou `rule`). | |
modified
guides/raccourcis-clavier-tui.md +2 -1 | @@ -109,7 +109,8 @@ Quand l'agent vous pose une question (son outil `ask_user`), un petit formulaire | ||
| 109 | 109 | | `Échap` | Revient d'une question en arrière, en reprenant sa réponse. | |
| 110 | 110 | | `PgUp` `PgDn` | Font défiler l'aperçu. | |
| 111 | 111 | | `Tab` | Sur un écran de moins de 100 colonnes : affiche l'aperçu à la place de la question, et inversement. | |
| 112 | -| `Ctrl-U` | Efface une réponse de texte (le nom, le fichier). | | |
| 112 | +| `←` `→` | Dans une réponse de texte (l'endpoint, la variable de la clé, le nom, le fichier) : déplacent le curseur ; la saisie s'insère là. `Début` `Fin` (ou `Ctrl-A` `Ctrl-E`) vont au début et à la fin ; `Retour arrière` efface avant le curseur, `Suppr` après. | | |
| 113 | +| `Ctrl-U` | Efface une réponse de texte. | | |
| 113 | 114 | | `Ctrl-C` | Quitte sans rien écrire. | |
| 114 | 115 | |
| 115 | 116 | ## Bon à savoir |
| @@ -109,7 +109,8 @@ Quand l'agent vous pose une question (son outil `ask_user`), un petit formulaire | |||
| 109 | | `Échap` | Revient d'une question en arrière, en reprenant sa réponse. | | 109 | | `Échap` | Revient d'une question en arrière, en reprenant sa réponse. | |
| 110 | | `PgUp` `PgDn` | Font défiler l'aperçu. | | 110 | | `PgUp` `PgDn` | Font défiler l'aperçu. | |
| 111 | | `Tab` | Sur un écran de moins de 100 colonnes : affiche l'aperçu à la place de la question, et inversement. | | 111 | | `Tab` | Sur un écran de moins de 100 colonnes : affiche l'aperçu à la place de la question, et inversement. | |
| 112 | -| `Ctrl-U` | Efface une réponse de texte (le nom, le fichier). | | 112 | +| `←` `→` | Dans une réponse de texte (l'endpoint, la variable de la clé, le nom, le fichier) : déplacent le curseur ; la saisie s'insère là. `Début` `Fin` (ou `Ctrl-A` `Ctrl-E`) vont au début et à la fin ; `Retour arrière` efface avant le curseur, `Suppr` après. | |
| 113 | +| `Ctrl-U` | Efface une réponse de texte. | | ||
| 113 | | `Ctrl-C` | Quitte sans rien écrire. | | 114 | | `Ctrl-C` | Quitte sans rien écrire. | |
| 114 | 115 | ||
| 115 | ## Bon à savoir | 116 | ## Bon à savoir |
modified
guides/tui-keyboard-shortcuts.md +2 -1 | @@ -109,7 +109,8 @@ When the agent asks you something (its `ask_user` tool), a small form replaces t | ||
| 109 | 109 | | `Esc` | Go back one question, taking its answer back. | |
| 110 | 110 | | `PgUp` `PgDn` | Scroll the preview. | |
| 111 | 111 | | `Tab` | On a screen narrower than 100 columns: show the preview in place of the question, and back. | |
| 112 | -| `Ctrl-U` | Clear a text answer (the name, the file). | | |
| 112 | +| `←` `→` | In a text answer (the endpoint, the key's variable, the name, the file): move the cursor; typing inserts there. `Home` `End` (or `Ctrl-A` `Ctrl-E`) go to the start and the end; `Backspace` deletes before the cursor, `Delete` after it. | | |
| 113 | +| `Ctrl-U` | Clear a text answer. | | |
| 113 | 114 | | `Ctrl-C` | Quit without writing anything. | |
| 114 | 115 | |
| 115 | 116 | ## Good to know |
| @@ -109,7 +109,8 @@ When the agent asks you something (its `ask_user` tool), a small form replaces t | |||
| 109 | | `Esc` | Go back one question, taking its answer back. | | 109 | | `Esc` | Go back one question, taking its answer back. | |
| 110 | | `PgUp` `PgDn` | Scroll the preview. | | 110 | | `PgUp` `PgDn` | Scroll the preview. | |
| 111 | | `Tab` | On a screen narrower than 100 columns: show the preview in place of the question, and back. | | 111 | | `Tab` | On a screen narrower than 100 columns: show the preview in place of the question, and back. | |
| 112 | -| `Ctrl-U` | Clear a text answer (the name, the file). | | 112 | +| `←` `→` | In a text answer (the endpoint, the key's variable, the name, the file): move the cursor; typing inserts there. `Home` `End` (or `Ctrl-A` `Ctrl-E`) go to the start and the end; `Backspace` deletes before the cursor, `Delete` after it. | |
| 113 | +| `Ctrl-U` | Clear a text answer. | | ||
| 113 | | `Ctrl-C` | Quit without writing anything. | | 114 | | `Ctrl-C` | Quit without writing anything. | |
| 114 | 115 | ||
| 115 | ## Good to know | 116 | ## Good to know |
modified
internal/agentinit/decide.go +10 -7 | @@ -7,14 +7,17 @@ import ( | ||
| 7 | 7 | |
| 8 | 8 | // Choices are the wizard's answers. |
| 9 | 9 | type Choices struct { |
| 10 | - Provider string // dmr, llmman, ollama, llamacpp | |
| 10 | + Provider string // llmman, ollama, dmr, llamacpp, openai | |
| 11 | 11 | BaseURL string // "" = the provider's default |
| 12 | - Model string | |
| 13 | - Usage string | |
| 14 | - Machine string | |
| 15 | - Family string // "" = detected from the model's name | |
| 16 | - Name string // acp.name | |
| 17 | - Yolo *bool // nil = the usage's default | |
| 12 | + // APIKeyEnv names the variable holding the API key of an | |
| 13 | + // OpenAI-compatible service (provider openai); "" = OPENAI_API_KEY. | |
| 14 | + APIKeyEnv string | |
| 15 | + Model string | |
| 16 | + Usage string | |
| 17 | + Machine string | |
| 18 | + Family string // "" = detected from the model's name | |
| 19 | + Name string // acp.name | |
| 20 | + Yolo *bool // nil = the usage's default | |
| 18 | 21 | } |
| 19 | 22 | |
| 20 | 23 | // Setting is one value of the generated file with the reason it has that |
| @@ -7,14 +7,17 @@ import ( | |||
| 7 | 7 | ||
| 8 | // Choices are the wizard's answers. | 8 | // Choices are the wizard's answers. |
| 9 | type Choices struct { | 9 | type Choices struct { |
| 10 | - Provider string // dmr, llmman, ollama, llamacpp | 10 | + Provider string // llmman, ollama, dmr, llamacpp, openai |
| 11 | BaseURL string // "" = the provider's default | 11 | BaseURL string // "" = the provider's default |
| 12 | - Model string | 12 | + // APIKeyEnv names the variable holding the API key of an |
| 13 | - Usage string | 13 | + // OpenAI-compatible service (provider openai); "" = OPENAI_API_KEY. |
| 14 | - Machine string | 14 | + APIKeyEnv string |
| 15 | - Family string // "" = detected from the model's name | 15 | + Model string |
| 16 | - Name string // acp.name | 16 | + Usage string |
| 17 | - Yolo *bool // nil = the usage's default | 17 | + Machine string |
| 18 | + Family string // "" = detected from the model's name | ||
| 19 | + Name string // acp.name | ||
| 20 | + Yolo *bool // nil = the usage's default | ||
| 18 | } | 21 | } |
| 19 | 22 | ||
| 20 | // Setting is one value of the generated file with the reason it has that | 23 | // Setting is one value of the generated file with the reason it has that |
modified
internal/agentinit/facts.go +30 -4 | @@ -89,12 +89,38 @@ func call(ctx context.Context, method, url string, body any, timeout time.Durati | ||
| 89 | 89 | } |
| 90 | 90 | |
| 91 | 91 | // Models lists the models a server serves: llmman's and Ollama's /api/tags, |
| 92 | -// the OpenAI /models of the others. Sorted. | |
| 92 | +// the OpenAI /models of the others. Sorted. For `openai` the key is read | |
| 93 | +// from OPENAI_API_KEY; ListModels takes it from elsewhere. | |
| 93 | 94 | // |
| 94 | 95 | // names, err := agentinit.Models(ctx, "llmman", "http://127.0.0.1:17434/v1") |
| 95 | 96 | func Models(ctx context.Context, provider, base string) ([]string, error) { |
| 97 | + key := "" | |
| 96 | 98 | if provider == "openai" { |
| 97 | - ctx = withKey(ctx, os.Getenv("OPENAI_API_KEY")) | |
| 99 | + key = os.Getenv("OPENAI_API_KEY") | |
| 100 | + } | |
| 101 | + return ListModels(ctx, provider, base, key) | |
| 102 | +} | |
| 103 | + | |
| 104 | +// Timeouts of a model list: a local server answers at once or is not | |
| 105 | +// there — the first question probes four of them, so it must stay short; a | |
| 106 | +// remote service (provider openai) may take seconds on a first, cold | |
| 107 | +// connection (DNS, TLS). Ticket 177: with 5 s, the OpenAI list came back | |
| 108 | +// empty on a first `mm init` and full on the second. | |
| 109 | +const ( | |
| 110 | + localListTimeout = 5 * time.Second | |
| 111 | + remoteListTimeout = 20 * time.Second | |
| 112 | +) | |
| 113 | + | |
| 114 | +// ListModels is Models with the API key given (sent as a Bearer token when | |
| 115 | +// not empty) — the key of an OpenAI-compatible service, read from the | |
| 116 | +// variable the user named. | |
| 117 | +// | |
| 118 | +// names, err := agentinit.ListModels(ctx, "openai", "https://api.cerebras.ai/v1", os.Getenv("CEREBRAS_API_KEY")) | |
| 119 | +func ListModels(ctx context.Context, provider, base, key string) ([]string, error) { | |
| 120 | + ctx = withKey(ctx, key) | |
| 121 | + timeout := localListTimeout | |
| 122 | + if provider == "openai" { | |
| 123 | + timeout = remoteListTimeout | |
| 98 | 124 | } |
| 99 | 125 | var names []string |
| 100 | 126 | switch provider { |
| @@ -104,7 +130,7 @@ func Models(ctx context.Context, provider, base string) ([]string, error) { | ||
| 104 | 130 | Name string `json:"name"` |
| 105 | 131 | } `json:"models"` |
| 106 | 132 | } |
| 107 | - if err := call(ctx, http.MethodGet, root(base)+"/api/tags", nil, 5*time.Second, &tags); err != nil { | |
| 133 | + if err := call(ctx, http.MethodGet, root(base)+"/api/tags", nil, timeout, &tags); err != nil { | |
| 108 | 134 | return nil, err |
| 109 | 135 | } |
| 110 | 136 | for _, m := range tags.Models { |
| @@ -116,7 +142,7 @@ func Models(ctx context.Context, provider, base string) ([]string, error) { | ||
| 116 | 142 | ID string `json:"id"` |
| 117 | 143 | } `json:"data"` |
| 118 | 144 | } |
| 119 | - if err := call(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/models", nil, 5*time.Second, &list); err != nil { | |
| 145 | + if err := call(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/models", nil, timeout, &list); err != nil { | |
| 120 | 146 | return nil, err |
| 121 | 147 | } |
| 122 | 148 | for _, m := range list.Data { |
| @@ -89,12 +89,38 @@ func call(ctx context.Context, method, url string, body any, timeout time.Durati | |||
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | // Models lists the models a server serves: llmman's and Ollama's /api/tags, | 91 | // Models lists the models a server serves: llmman's and Ollama's /api/tags, |
| 92 | -// the OpenAI /models of the others. Sorted. | 92 | +// the OpenAI /models of the others. Sorted. For `openai` the key is read |
| 93 | +// from OPENAI_API_KEY; ListModels takes it from elsewhere. | ||
| 93 | // | 94 | // |
| 94 | // names, err := agentinit.Models(ctx, "llmman", "http://127.0.0.1:17434/v1") | 95 | // names, err := agentinit.Models(ctx, "llmman", "http://127.0.0.1:17434/v1") |
| 95 | func Models(ctx context.Context, provider, base string) ([]string, error) { | 96 | func Models(ctx context.Context, provider, base string) ([]string, error) { |
| 97 | + key := "" | ||
| 96 | if provider == "openai" { | 98 | if provider == "openai" { |
| 97 | - ctx = withKey(ctx, os.Getenv("OPENAI_API_KEY")) | 99 | + key = os.Getenv("OPENAI_API_KEY") |
| 100 | + } | ||
| 101 | + return ListModels(ctx, provider, base, key) | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +// Timeouts of a model list: a local server answers at once or is not | ||
| 105 | +// there — the first question probes four of them, so it must stay short; a | ||
| 106 | +// remote service (provider openai) may take seconds on a first, cold | ||
| 107 | +// connection (DNS, TLS). Ticket 177: with 5 s, the OpenAI list came back | ||
| 108 | +// empty on a first `mm init` and full on the second. | ||
| 109 | +const ( | ||
| 110 | + localListTimeout = 5 * time.Second | ||
| 111 | + remoteListTimeout = 20 * time.Second | ||
| 112 | +) | ||
| 113 | + | ||
| 114 | +// ListModels is Models with the API key given (sent as a Bearer token when | ||
| 115 | +// not empty) — the key of an OpenAI-compatible service, read from the | ||
| 116 | +// variable the user named. | ||
| 117 | +// | ||
| 118 | +// names, err := agentinit.ListModels(ctx, "openai", "https://api.cerebras.ai/v1", os.Getenv("CEREBRAS_API_KEY")) | ||
| 119 | +func ListModels(ctx context.Context, provider, base, key string) ([]string, error) { | ||
| 120 | + ctx = withKey(ctx, key) | ||
| 121 | + timeout := localListTimeout | ||
| 122 | + if provider == "openai" { | ||
| 123 | + timeout = remoteListTimeout | ||
| 98 | } | 124 | } |
| 99 | var names []string | 125 | var names []string |
| 100 | switch provider { | 126 | switch provider { |
| @@ -104,7 +130,7 @@ func Models(ctx context.Context, provider, base string) ([]string, error) { | |||
| 104 | Name string `json:"name"` | 130 | Name string `json:"name"` |
| 105 | } `json:"models"` | 131 | } `json:"models"` |
| 106 | } | 132 | } |
| 107 | - if err := call(ctx, http.MethodGet, root(base)+"/api/tags", nil, 5*time.Second, &tags); err != nil { | 133 | + if err := call(ctx, http.MethodGet, root(base)+"/api/tags", nil, timeout, &tags); err != nil { |
| 108 | return nil, err | 134 | return nil, err |
| 109 | } | 135 | } |
| 110 | for _, m := range tags.Models { | 136 | for _, m := range tags.Models { |
| @@ -116,7 +142,7 @@ func Models(ctx context.Context, provider, base string) ([]string, error) { | |||
| 116 | ID string `json:"id"` | 142 | ID string `json:"id"` |
| 117 | } `json:"data"` | 143 | } `json:"data"` |
| 118 | } | 144 | } |
| 119 | - if err := call(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/models", nil, 5*time.Second, &list); err != nil { | 145 | + if err := call(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/models", nil, timeout, &list); err != nil { |
| 120 | return nil, err | 146 | return nil, err |
| 121 | } | 147 | } |
| 122 | for _, m := range list.Data { | 148 | for _, m := range list.Data { |
modified
internal/agentinit/flow.go +114 -15 | @@ -3,9 +3,12 @@ package agentinit | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | "fmt" |
| 6 | + "os" | |
| 7 | + "regexp" | |
| 6 | 8 | "strings" |
| 7 | 9 | |
| 8 | 10 | "mm/internal/elicit" |
| 11 | + "mm/internal/engine" | |
| 9 | 12 | ) |
| 10 | 13 | |
| 11 | 14 | // Option is one choice of a step: the value stored, the label shown, and a |
| @@ -19,7 +22,7 @@ type Option struct { | ||
| 19 | 22 | // Step is one question of the wizard, ready to show: the line mode prints |
| 20 | 23 | // it, the window draws it (internal/tui). |
| 21 | 24 | type Step struct { |
| 22 | - Key string // provider, model, family, usage, machine, yolo, name, file | |
| 25 | + Key string // provider, endpoint, keyenv, model, family, usage, machine, yolo, name, file | |
| 23 | 26 | Title string // the short name in the window's list of steps |
| 24 | 27 | Prompt string // the question |
| 25 | 28 | Kind elicit.Kind |
| @@ -29,14 +32,24 @@ type Step struct { | ||
| 29 | 32 | Given bool // answered by a flag: shown, never asked |
| 30 | 33 | } |
| 31 | 34 | |
| 32 | -// stepKeys is the order of the questions. | |
| 33 | -var stepKeys = []string{"provider", "model", "family", "usage", "machine", "yolo", "name", "file"} | |
| 35 | +// stepKeys is the order of the questions. endpoint and keyenv are asked | |
| 36 | +// for provider openai only (ticket 177): that provider is any | |
| 37 | +// OpenAI-compatible service — OpenAI, Cerebras, Hugging Face… — so its | |
| 38 | +// address and the variable holding its key are the user's to give. | |
| 39 | +var stepKeys = []string{"provider", "endpoint", "keyenv", "model", "family", "usage", "machine", "yolo", "name", "file"} | |
| 34 | 40 | |
| 35 | 41 | var stepTitles = map[string]string{ |
| 36 | - "provider": "Server", "model": "Model", "family": "Family", "usage": "Usage", | |
| 37 | - "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File", | |
| 42 | + "provider": "Server", "endpoint": "Endpoint", "keyenv": "API key", "model": "Model", "family": "Family", | |
| 43 | + "usage": "Usage", "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File", | |
| 38 | 44 | } |
| 39 | 45 | |
| 46 | +// defaultKeyEnv is where the openai provider reads its key when the file | |
| 47 | +// names no apiKeyEnv. | |
| 48 | +const defaultKeyEnv = "OPENAI_API_KEY" | |
| 49 | + | |
| 50 | +// envName is what a shell accepts as a variable name. | |
| 51 | +var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | |
| 52 | + | |
| 40 | 53 | // Flow is the wizard's state, apart from any way of showing it: the answers |
| 41 | 54 | // so far, what the server said, and which answers came from flags. The line |
| 42 | 55 | // mode (wizard.go) and the window (internal/tui) both drive one. |
| @@ -55,7 +68,7 @@ type Flow struct { | ||
| 55 | 68 | base string // the resolved server address, for c.Provider |
| 56 | 69 | models []string |
| 57 | 70 | modelsErr error |
| 58 | - listed string // the provider+base the models were listed for | |
| 71 | + listed string // the provider+base(+key variable) the models were listed for | |
| 59 | 72 | facts Facts |
| 60 | 73 | factsFor string // the model the facts are about |
| 61 | 74 | reachable map[string]bool |
| @@ -64,7 +77,7 @@ type Flow struct { | ||
| 64 | 77 | // NewFlow starts a flow; what the flags gave is marked given and never asked. |
| 65 | 78 | func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow { |
| 66 | 79 | f := &Flow{presets: p, ctx: ctx, c: given, output: output, given: map[string]bool{}, answer: map[string]string{}} |
| 67 | - for key, value := range map[string]string{"provider": given.Provider, "model": given.Model, "family": given.Family, "usage": given.Usage, "machine": given.Machine, "name": given.Name, "file": output} { | |
| 80 | + for key, value := range map[string]string{"provider": given.Provider, "endpoint": given.BaseURL, "keyenv": given.APIKeyEnv, "model": given.Model, "family": given.Family, "usage": given.Usage, "machine": given.Machine, "name": given.Name, "file": output} { | |
| 68 | 81 | if value != "" { |
| 69 | 82 | f.given[key], f.answer[key] = true, value |
| 70 | 83 | } |
| @@ -90,6 +103,9 @@ func (f *Flow) Steps() []Step { | ||
| 90 | 103 | if key == "yolo" && !f.asksYolo() { |
| 91 | 104 | continue |
| 92 | 105 | } |
| 106 | + if (key == "endpoint" || key == "keyenv") && f.c.Provider != "openai" { | |
| 107 | + continue | |
| 108 | + } | |
| 93 | 109 | out = append(out, f.Step(key)) |
| 94 | 110 | } |
| 95 | 111 | return out |
| @@ -143,11 +159,15 @@ func (f *Flow) Step(key string) Step { | ||
| 143 | 159 | st.Options = append(st.Options, Option{Value: name, Label: name, Detail: detail}) |
| 144 | 160 | } |
| 145 | 161 | st.Default = f.firstAnswering() |
| 162 | + case "endpoint": | |
| 163 | + st.Prompt, st.Kind, st.Default = "Endpoint (the service's OpenAI-compatible address)?", elicit.Text, openAIDefaultBase() | |
| 164 | + case "keyenv": | |
| 165 | + st.Prompt, st.Kind, st.Default = "Environment variable that holds the API key?", elicit.Text, defaultKeyEnv | |
| 146 | 166 | case "model": |
| 147 | 167 | st.Prompt = "Model?" |
| 148 | 168 | models := f.modelList() |
| 149 | 169 | if len(models) == 0 { |
| 150 | - st.Kind, st.Prompt = elicit.Text, "Model (as the server names it)?" | |
| 170 | + st.Kind, st.Prompt = elicit.Text, f.typeModelPrompt() | |
| 151 | 171 | break |
| 152 | 172 | } |
| 153 | 173 | for _, m := range models { |
| @@ -205,7 +225,32 @@ func (f *Flow) Set(key, value string) error { | ||
| 205 | 225 | f.base = "" |
| 206 | 226 | f.forget("model") |
| 207 | 227 | f.c.Model = "" |
| 228 | + // The endpoint and the key's variable belong to openai: a | |
| 229 | + // change of server forgets those answered here. | |
| 230 | + f.forgetService() | |
| 231 | + } | |
| 232 | + case "endpoint": | |
| 233 | + if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { | |
| 234 | + return fmt.Errorf("endpoint: %q is not an http:// or https:// address", value) | |
| 235 | + } | |
| 236 | + value = strings.TrimRight(value, "/") | |
| 237 | + // The OpenAI API's own address is openai's default: no baseUrl | |
| 238 | + // line then, the file records it as a comment instead. | |
| 239 | + f.c.BaseURL = value | |
| 240 | + if f.c.Provider == "openai" && value == openAIDefaultBase() { | |
| 241 | + f.c.BaseURL = "" | |
| 242 | + } | |
| 243 | + f.base = "" | |
| 244 | + f.forget("model") | |
| 245 | + case "keyenv": | |
| 246 | + if !envName.MatchString(value) { | |
| 247 | + return fmt.Errorf("API key: %q is not a variable name (letters, digits, _)", value) | |
| 208 | 248 | } |
| 249 | + f.c.APIKeyEnv = "" | |
| 250 | + if value != defaultKeyEnv { | |
| 251 | + f.c.APIKeyEnv = value | |
| 252 | + } | |
| 253 | + f.forget("model") | |
| 209 | 254 | case "model": |
| 210 | 255 | if value != f.c.Model { |
| 211 | 256 | f.c.Model = value |
| @@ -248,6 +293,10 @@ func (f *Flow) Unset(key string) { | ||
| 248 | 293 | } |
| 249 | 294 | delete(f.answer, key) |
| 250 | 295 | switch key { |
| 296 | + case "endpoint": | |
| 297 | + f.c.BaseURL, f.base = "", "" | |
| 298 | + case "keyenv": | |
| 299 | + f.c.APIKeyEnv = "" | |
| 251 | 300 | case "family": |
| 252 | 301 | f.c.Family = "" |
| 253 | 302 | case "usage": |
| @@ -272,6 +321,45 @@ func (f *Flow) forget(key string) { | ||
| 272 | 321 | } |
| 273 | 322 | } |
| 274 | 323 | |
| 324 | +// forgetService drops the endpoint and key variable answered for openai | |
| 325 | +// (not those given by flags), when another server is chosen. | |
| 326 | +func (f *Flow) forgetService() { | |
| 327 | + if !f.given["endpoint"] { | |
| 328 | + delete(f.answer, "endpoint") | |
| 329 | + f.c.BaseURL = "" | |
| 330 | + } | |
| 331 | + if !f.given["keyenv"] { | |
| 332 | + delete(f.answer, "keyenv") | |
| 333 | + f.c.APIKeyEnv = "" | |
| 334 | + } | |
| 335 | +} | |
| 336 | + | |
| 337 | +// keyEnv is the variable the openai provider reads its key from. | |
| 338 | +func (f *Flow) keyEnv() string { | |
| 339 | + if f.c.APIKeyEnv != "" { | |
| 340 | + return f.c.APIKeyEnv | |
| 341 | + } | |
| 342 | + return defaultKeyEnv | |
| 343 | +} | |
| 344 | + | |
| 345 | +// typeModelPrompt asks for the model's ID when there is no list, and says | |
| 346 | +// why — in the window as in the line mode, both show the prompt. | |
| 347 | +func (f *Flow) typeModelPrompt() string { | |
| 348 | + if f.c.Provider == "openai" && os.Getenv(f.keyEnv()) == "" { | |
| 349 | + return fmt.Sprintf("Model ID? (%s is not set: no list — type it)", f.keyEnv()) | |
| 350 | + } | |
| 351 | + if f.modelsErr != nil { | |
| 352 | + return fmt.Sprintf("Model ID? (no list from %s: %v)", f.Base(), f.modelsErr) | |
| 353 | + } | |
| 354 | + return "Model (as the server names it)?" | |
| 355 | +} | |
| 356 | + | |
| 357 | +// openAIDefaultBase is the openai provider's default address (the OpenAI API). | |
| 358 | +func openAIDefaultBase() string { | |
| 359 | + base, _, _ := engine.DefaultURLs("openai") | |
| 360 | + return base | |
| 361 | +} | |
| 362 | + | |
| 275 | 363 | // Preview is the file as it would be written now — with candidate as the |
| 276 | 364 | // answer to key, when given, so the window can show the highlighted choice |
| 277 | 365 | // before it is chosen. Unanswered questions take their defaults. |
| @@ -314,24 +402,35 @@ func (f *Flow) Base() string { | ||
| 314 | 402 | return f.base |
| 315 | 403 | } |
| 316 | 404 | |
| 317 | -// ModelsError is why the server gave no model list, nil when it did. | |
| 318 | -func (f *Flow) ModelsError() error { | |
| 319 | - f.modelList() | |
| 320 | - return f.modelsErr | |
| 321 | -} | |
| 322 | - | |
| 323 | 405 | func (f *Flow) modelList() []string { |
| 324 | 406 | if f.c.Provider == "" { |
| 325 | 407 | return nil |
| 326 | 408 | } |
| 327 | 409 | key := f.c.Provider + " " + f.Base() |
| 410 | + if f.c.Provider == "openai" { | |
| 411 | + key += " " + f.keyEnv() | |
| 412 | + } | |
| 328 | 413 | if f.listed != key { |
| 329 | - f.models, f.modelsErr = Models(f.ctx, f.c.Provider, f.Base()) | |
| 414 | + f.models, f.modelsErr = f.listModels() | |
| 330 | 415 | f.listed = key |
| 331 | 416 | } |
| 332 | 417 | return f.models |
| 333 | 418 | } |
| 334 | 419 | |
| 420 | +// listModels asks the server for its models. An OpenAI-compatible service | |
| 421 | +// is asked with the key read from the variable the user named — and not at | |
| 422 | +// all when that variable is empty: the request would only be refused. | |
| 423 | +func (f *Flow) listModels() ([]string, error) { | |
| 424 | + if f.c.Provider != "openai" { | |
| 425 | + return Models(f.ctx, f.c.Provider, f.Base()) | |
| 426 | + } | |
| 427 | + key := os.Getenv(f.keyEnv()) | |
| 428 | + if key == "" { | |
| 429 | + return nil, fmt.Errorf("%s is not set", f.keyEnv()) | |
| 430 | + } | |
| 431 | + return ListModels(f.ctx, f.c.Provider, f.Base(), key) | |
| 432 | +} | |
| 433 | + | |
| 335 | 434 | func (f *Flow) modelFacts() Facts { |
| 336 | 435 | if f.c.Model == "" || f.c.Provider == "" { |
| 337 | 436 | return Facts{} |
| @@ -3,9 +3,12 @@ package agentinit | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | "fmt" | 5 | "fmt" |
| 6 | + "os" | ||
| 7 | + "regexp" | ||
| 6 | "strings" | 8 | "strings" |
| 7 | 9 | ||
| 8 | "mm/internal/elicit" | 10 | "mm/internal/elicit" |
| 11 | + "mm/internal/engine" | ||
| 9 | ) | 12 | ) |
| 10 | 13 | ||
| 11 | // Option is one choice of a step: the value stored, the label shown, and a | 14 | // Option is one choice of a step: the value stored, the label shown, and a |
| @@ -19,7 +22,7 @@ type Option struct { | |||
| 19 | // Step is one question of the wizard, ready to show: the line mode prints | 22 | // Step is one question of the wizard, ready to show: the line mode prints |
| 20 | // it, the window draws it (internal/tui). | 23 | // it, the window draws it (internal/tui). |
| 21 | type Step struct { | 24 | type Step struct { |
| 22 | - Key string // provider, model, family, usage, machine, yolo, name, file | 25 | + Key string // provider, endpoint, keyenv, model, family, usage, machine, yolo, name, file |
| 23 | Title string // the short name in the window's list of steps | 26 | Title string // the short name in the window's list of steps |
| 24 | Prompt string // the question | 27 | Prompt string // the question |
| 25 | Kind elicit.Kind | 28 | Kind elicit.Kind |
| @@ -29,14 +32,24 @@ type Step struct { | |||
| 29 | Given bool // answered by a flag: shown, never asked | 32 | Given bool // answered by a flag: shown, never asked |
| 30 | } | 33 | } |
| 31 | 34 | ||
| 32 | -// stepKeys is the order of the questions. | 35 | +// stepKeys is the order of the questions. endpoint and keyenv are asked |
| 33 | -var stepKeys = []string{"provider", "model", "family", "usage", "machine", "yolo", "name", "file"} | 36 | +// for provider openai only (ticket 177): that provider is any |
| 37 | +// OpenAI-compatible service — OpenAI, Cerebras, Hugging Face… — so its | ||
| 38 | +// address and the variable holding its key are the user's to give. | ||
| 39 | +var stepKeys = []string{"provider", "endpoint", "keyenv", "model", "family", "usage", "machine", "yolo", "name", "file"} | ||
| 34 | 40 | ||
| 35 | var stepTitles = map[string]string{ | 41 | var stepTitles = map[string]string{ |
| 36 | - "provider": "Server", "model": "Model", "family": "Family", "usage": "Usage", | 42 | + "provider": "Server", "endpoint": "Endpoint", "keyenv": "API key", "model": "Model", "family": "Family", |
| 37 | - "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File", | 43 | + "usage": "Usage", "machine": "Machine", "yolo": "Ask first", "name": "Name", "file": "File", |
| 38 | } | 44 | } |
| 39 | 45 | ||
| 46 | +// defaultKeyEnv is where the openai provider reads its key when the file | ||
| 47 | +// names no apiKeyEnv. | ||
| 48 | +const defaultKeyEnv = "OPENAI_API_KEY" | ||
| 49 | + | ||
| 50 | +// envName is what a shell accepts as a variable name. | ||
| 51 | +var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | ||
| 52 | + | ||
| 40 | // Flow is the wizard's state, apart from any way of showing it: the answers | 53 | // Flow is the wizard's state, apart from any way of showing it: the answers |
| 41 | // so far, what the server said, and which answers came from flags. The line | 54 | // so far, what the server said, and which answers came from flags. The line |
| 42 | // mode (wizard.go) and the window (internal/tui) both drive one. | 55 | // mode (wizard.go) and the window (internal/tui) both drive one. |
| @@ -55,7 +68,7 @@ type Flow struct { | |||
| 55 | base string // the resolved server address, for c.Provider | 68 | base string // the resolved server address, for c.Provider |
| 56 | models []string | 69 | models []string |
| 57 | modelsErr error | 70 | modelsErr error |
| 58 | - listed string // the provider+base the models were listed for | 71 | + listed string // the provider+base(+key variable) the models were listed for |
| 59 | facts Facts | 72 | facts Facts |
| 60 | factsFor string // the model the facts are about | 73 | factsFor string // the model the facts are about |
| 61 | reachable map[string]bool | 74 | reachable map[string]bool |
| @@ -64,7 +77,7 @@ type Flow struct { | |||
| 64 | // NewFlow starts a flow; what the flags gave is marked given and never asked. | 77 | // NewFlow starts a flow; what the flags gave is marked given and never asked. |
| 65 | func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow { | 78 | func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow { |
| 66 | f := &Flow{presets: p, ctx: ctx, c: given, output: output, given: map[string]bool{}, answer: map[string]string{}} | 79 | f := &Flow{presets: p, ctx: ctx, c: given, output: output, given: map[string]bool{}, answer: map[string]string{}} |
| 67 | - for key, value := range map[string]string{"provider": given.Provider, "model": given.Model, "family": given.Family, "usage": given.Usage, "machine": given.Machine, "name": given.Name, "file": output} { | 80 | + for key, value := range map[string]string{"provider": given.Provider, "endpoint": given.BaseURL, "keyenv": given.APIKeyEnv, "model": given.Model, "family": given.Family, "usage": given.Usage, "machine": given.Machine, "name": given.Name, "file": output} { |
| 68 | if value != "" { | 81 | if value != "" { |
| 69 | f.given[key], f.answer[key] = true, value | 82 | f.given[key], f.answer[key] = true, value |
| 70 | } | 83 | } |
| @@ -90,6 +103,9 @@ func (f *Flow) Steps() []Step { | |||
| 90 | if key == "yolo" && !f.asksYolo() { | 103 | if key == "yolo" && !f.asksYolo() { |
| 91 | continue | 104 | continue |
| 92 | } | 105 | } |
| 106 | + if (key == "endpoint" || key == "keyenv") && f.c.Provider != "openai" { | ||
| 107 | + continue | ||
| 108 | + } | ||
| 93 | out = append(out, f.Step(key)) | 109 | out = append(out, f.Step(key)) |
| 94 | } | 110 | } |
| 95 | return out | 111 | return out |
| @@ -143,11 +159,15 @@ func (f *Flow) Step(key string) Step { | |||
| 143 | st.Options = append(st.Options, Option{Value: name, Label: name, Detail: detail}) | 159 | st.Options = append(st.Options, Option{Value: name, Label: name, Detail: detail}) |
| 144 | } | 160 | } |
| 145 | st.Default = f.firstAnswering() | 161 | st.Default = f.firstAnswering() |
| 162 | + case "endpoint": | ||
| 163 | + st.Prompt, st.Kind, st.Default = "Endpoint (the service's OpenAI-compatible address)?", elicit.Text, openAIDefaultBase() | ||
| 164 | + case "keyenv": | ||
| 165 | + st.Prompt, st.Kind, st.Default = "Environment variable that holds the API key?", elicit.Text, defaultKeyEnv | ||
| 146 | case "model": | 166 | case "model": |
| 147 | st.Prompt = "Model?" | 167 | st.Prompt = "Model?" |
| 148 | models := f.modelList() | 168 | models := f.modelList() |
| 149 | if len(models) == 0 { | 169 | if len(models) == 0 { |
| 150 | - st.Kind, st.Prompt = elicit.Text, "Model (as the server names it)?" | 170 | + st.Kind, st.Prompt = elicit.Text, f.typeModelPrompt() |
| 151 | break | 171 | break |
| 152 | } | 172 | } |
| 153 | for _, m := range models { | 173 | for _, m := range models { |
| @@ -205,7 +225,32 @@ func (f *Flow) Set(key, value string) error { | |||
| 205 | f.base = "" | 225 | f.base = "" |
| 206 | f.forget("model") | 226 | f.forget("model") |
| 207 | f.c.Model = "" | 227 | f.c.Model = "" |
| 228 | + // The endpoint and the key's variable belong to openai: a | ||
| 229 | + // change of server forgets those answered here. | ||
| 230 | + f.forgetService() | ||
| 231 | + } | ||
| 232 | + case "endpoint": | ||
| 233 | + if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { | ||
| 234 | + return fmt.Errorf("endpoint: %q is not an http:// or https:// address", value) | ||
| 235 | + } | ||
| 236 | + value = strings.TrimRight(value, "/") | ||
| 237 | + // The OpenAI API's own address is openai's default: no baseUrl | ||
| 238 | + // line then, the file records it as a comment instead. | ||
| 239 | + f.c.BaseURL = value | ||
| 240 | + if f.c.Provider == "openai" && value == openAIDefaultBase() { | ||
| 241 | + f.c.BaseURL = "" | ||
| 242 | + } | ||
| 243 | + f.base = "" | ||
| 244 | + f.forget("model") | ||
| 245 | + case "keyenv": | ||
| 246 | + if !envName.MatchString(value) { | ||
| 247 | + return fmt.Errorf("API key: %q is not a variable name (letters, digits, _)", value) | ||
| 208 | } | 248 | } |
| 249 | + f.c.APIKeyEnv = "" | ||
| 250 | + if value != defaultKeyEnv { | ||
| 251 | + f.c.APIKeyEnv = value | ||
| 252 | + } | ||
| 253 | + f.forget("model") | ||
| 209 | case "model": | 254 | case "model": |
| 210 | if value != f.c.Model { | 255 | if value != f.c.Model { |
| 211 | f.c.Model = value | 256 | f.c.Model = value |
| @@ -248,6 +293,10 @@ func (f *Flow) Unset(key string) { | |||
| 248 | } | 293 | } |
| 249 | delete(f.answer, key) | 294 | delete(f.answer, key) |
| 250 | switch key { | 295 | switch key { |
| 296 | + case "endpoint": | ||
| 297 | + f.c.BaseURL, f.base = "", "" | ||
| 298 | + case "keyenv": | ||
| 299 | + f.c.APIKeyEnv = "" | ||
| 251 | case "family": | 300 | case "family": |
| 252 | f.c.Family = "" | 301 | f.c.Family = "" |
| 253 | case "usage": | 302 | case "usage": |
| @@ -272,6 +321,45 @@ func (f *Flow) forget(key string) { | |||
| 272 | } | 321 | } |
| 273 | } | 322 | } |
| 274 | 323 | ||
| 324 | +// forgetService drops the endpoint and key variable answered for openai | ||
| 325 | +// (not those given by flags), when another server is chosen. | ||
| 326 | +func (f *Flow) forgetService() { | ||
| 327 | + if !f.given["endpoint"] { | ||
| 328 | + delete(f.answer, "endpoint") | ||
| 329 | + f.c.BaseURL = "" | ||
| 330 | + } | ||
| 331 | + if !f.given["keyenv"] { | ||
| 332 | + delete(f.answer, "keyenv") | ||
| 333 | + f.c.APIKeyEnv = "" | ||
| 334 | + } | ||
| 335 | +} | ||
| 336 | + | ||
| 337 | +// keyEnv is the variable the openai provider reads its key from. | ||
| 338 | +func (f *Flow) keyEnv() string { | ||
| 339 | + if f.c.APIKeyEnv != "" { | ||
| 340 | + return f.c.APIKeyEnv | ||
| 341 | + } | ||
| 342 | + return defaultKeyEnv | ||
| 343 | +} | ||
| 344 | + | ||
| 345 | +// typeModelPrompt asks for the model's ID when there is no list, and says | ||
| 346 | +// why — in the window as in the line mode, both show the prompt. | ||
| 347 | +func (f *Flow) typeModelPrompt() string { | ||
| 348 | + if f.c.Provider == "openai" && os.Getenv(f.keyEnv()) == "" { | ||
| 349 | + return fmt.Sprintf("Model ID? (%s is not set: no list — type it)", f.keyEnv()) | ||
| 350 | + } | ||
| 351 | + if f.modelsErr != nil { | ||
| 352 | + return fmt.Sprintf("Model ID? (no list from %s: %v)", f.Base(), f.modelsErr) | ||
| 353 | + } | ||
| 354 | + return "Model (as the server names it)?" | ||
| 355 | +} | ||
| 356 | + | ||
| 357 | +// openAIDefaultBase is the openai provider's default address (the OpenAI API). | ||
| 358 | +func openAIDefaultBase() string { | ||
| 359 | + base, _, _ := engine.DefaultURLs("openai") | ||
| 360 | + return base | ||
| 361 | +} | ||
| 362 | + | ||
| 275 | // Preview is the file as it would be written now — with candidate as the | 363 | // Preview is the file as it would be written now — with candidate as the |
| 276 | // answer to key, when given, so the window can show the highlighted choice | 364 | // answer to key, when given, so the window can show the highlighted choice |
| 277 | // before it is chosen. Unanswered questions take their defaults. | 365 | // before it is chosen. Unanswered questions take their defaults. |
| @@ -314,24 +402,35 @@ func (f *Flow) Base() string { | |||
| 314 | return f.base | 402 | return f.base |
| 315 | } | 403 | } |
| 316 | 404 | ||
| 317 | -// ModelsError is why the server gave no model list, nil when it did. | ||
| 318 | -func (f *Flow) ModelsError() error { | ||
| 319 | - f.modelList() | ||
| 320 | - return f.modelsErr | ||
| 321 | -} | ||
| 322 | - | ||
| 323 | func (f *Flow) modelList() []string { | 405 | func (f *Flow) modelList() []string { |
| 324 | if f.c.Provider == "" { | 406 | if f.c.Provider == "" { |
| 325 | return nil | 407 | return nil |
| 326 | } | 408 | } |
| 327 | key := f.c.Provider + " " + f.Base() | 409 | key := f.c.Provider + " " + f.Base() |
| 410 | + if f.c.Provider == "openai" { | ||
| 411 | + key += " " + f.keyEnv() | ||
| 412 | + } | ||
| 328 | if f.listed != key { | 413 | if f.listed != key { |
| 329 | - f.models, f.modelsErr = Models(f.ctx, f.c.Provider, f.Base()) | 414 | + f.models, f.modelsErr = f.listModels() |
| 330 | f.listed = key | 415 | f.listed = key |
| 331 | } | 416 | } |
| 332 | return f.models | 417 | return f.models |
| 333 | } | 418 | } |
| 334 | 419 | ||
| 420 | +// listModels asks the server for its models. An OpenAI-compatible service | ||
| 421 | +// is asked with the key read from the variable the user named — and not at | ||
| 422 | +// all when that variable is empty: the request would only be refused. | ||
| 423 | +func (f *Flow) listModels() ([]string, error) { | ||
| 424 | + if f.c.Provider != "openai" { | ||
| 425 | + return Models(f.ctx, f.c.Provider, f.Base()) | ||
| 426 | + } | ||
| 427 | + key := os.Getenv(f.keyEnv()) | ||
| 428 | + if key == "" { | ||
| 429 | + return nil, fmt.Errorf("%s is not set", f.keyEnv()) | ||
| 430 | + } | ||
| 431 | + return ListModels(f.ctx, f.c.Provider, f.Base(), key) | ||
| 432 | +} | ||
| 433 | + | ||
| 335 | func (f *Flow) modelFacts() Facts { | 434 | func (f *Flow) modelFacts() Facts { |
| 336 | if f.c.Model == "" || f.c.Provider == "" { | 435 | if f.c.Model == "" || f.c.Provider == "" { |
| 337 | return Facts{} | 436 | return Facts{} |
added
internal/agentinit/openai_flow_test.go +178 -0 | new file mode 100644 | ||
| @@ -0,0 +1,178 @@ | ||
| 1 | +package agentinit | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + | |
| 11 | + "mm/internal/elicit" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// openaiService is a fake OpenAI-compatible service: GET /v1/models answers | |
| 15 | +// two models to the key sk-good, 401 to anything else. calls counts the | |
| 16 | +// requests, auth keeps the last Authorization header. | |
| 17 | +type openaiService struct { | |
| 18 | + url string | |
| 19 | + calls int | |
| 20 | + auth string | |
| 21 | +} | |
| 22 | + | |
| 23 | +func newOpenAIService(t *testing.T) *openaiService { | |
| 24 | + t.Helper() | |
| 25 | + s := &openaiService{} | |
| 26 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 27 | + s.calls++ | |
| 28 | + s.auth = r.Header.Get("Authorization") | |
| 29 | + if s.auth != "Bearer sk-good" { | |
| 30 | + w.WriteHeader(http.StatusUnauthorized) | |
| 31 | + return | |
| 32 | + } | |
| 33 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "llama-4-scout"}, map[string]any{"id": "gpt-oss-120b"}}}) | |
| 34 | + })) | |
| 35 | + t.Cleanup(srv.Close) | |
| 36 | + s.url = srv.URL + "/v1" | |
| 37 | + return s | |
| 38 | +} | |
| 39 | + | |
| 40 | +func openaiFlow(t *testing.T, given Choices) *Flow { | |
| 41 | + t.Helper() | |
| 42 | + keepConfig(t) | |
| 43 | + p, err := LoadPresets() | |
| 44 | + if err != nil { | |
| 45 | + t.Fatal(err) | |
| 46 | + } | |
| 47 | + given.Provider = "openai" | |
| 48 | + return NewFlow(context.Background(), p, given, "") | |
| 49 | +} | |
| 50 | + | |
| 51 | +// Ticket 177: for openai — any OpenAI-compatible service — the endpoint and | |
| 52 | +// the key's variable are asked between the server and the model; for the | |
| 53 | +// other providers they are not. | |
| 54 | +func TestOpenAIAsksTheEndpointAndTheKeyVariable(t *testing.T) { | |
| 55 | + f := openaiFlow(t, Choices{}) | |
| 56 | + if got := keys(f.Pending()); !strings.HasPrefix(got, "endpoint,keyenv,model,") { | |
| 57 | + t.Errorf("pending = %s, want endpoint, keyenv, then the model", got) | |
| 58 | + } | |
| 59 | + if st := f.Step("endpoint"); st.Kind != elicit.Text || st.Default != "https://api.openai.com/v1" { | |
| 60 | + t.Errorf("endpoint step: %+v", st) | |
| 61 | + } | |
| 62 | + if st := f.Step("keyenv"); st.Kind != elicit.Text || st.Default != "OPENAI_API_KEY" { | |
| 63 | + t.Errorf("keyenv step: %+v", st) | |
| 64 | + } | |
| 65 | + | |
| 66 | + other := NewFlow(context.Background(), f.presets, Choices{Provider: "llmman", BaseURL: llmmanStub(t)}, "") | |
| 67 | + if got := keys(other.Pending()); strings.Contains(got, "endpoint") || strings.Contains(got, "keyenv") { | |
| 68 | + t.Errorf("llmman pending = %s, want no endpoint or key question", got) | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +func TestOpenAIListsTheModelsWithTheNamedKey(t *testing.T) { | |
| 73 | + svc := newOpenAIService(t) | |
| 74 | + t.Setenv("CEREBRAS_API_KEY", "sk-good") | |
| 75 | + f := openaiFlow(t, Choices{}) | |
| 76 | + for k, v := range map[string]string{"endpoint": svc.url, "keyenv": "CEREBRAS_API_KEY"} { | |
| 77 | + if err := f.Set(k, v); err != nil { | |
| 78 | + t.Fatal(err) | |
| 79 | + } | |
| 80 | + } | |
| 81 | + st := f.Step("model") | |
| 82 | + if st.Kind != elicit.Choice || len(st.Options) != 2 || st.Default != "gpt-oss-120b" { | |
| 83 | + t.Errorf("model step: %+v, want the two models of the service", st) | |
| 84 | + } | |
| 85 | + if svc.auth != "Bearer sk-good" { | |
| 86 | + t.Errorf("Authorization = %q, want the key read from CEREBRAS_API_KEY", svc.auth) | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +func TestOpenAIWithoutTheKeyAsksToTypeTheModel(t *testing.T) { | |
| 91 | + svc := newOpenAIService(t) | |
| 92 | + t.Setenv("HF_TOKEN", "") | |
| 93 | + f := openaiFlow(t, Choices{BaseURL: svc.url, APIKeyEnv: "HF_TOKEN"}) | |
| 94 | + st := f.Step("model") | |
| 95 | + if st.Kind != elicit.Text || !strings.Contains(st.Prompt, "HF_TOKEN is not set") { | |
| 96 | + t.Errorf("model step: %+v, want a text question saying HF_TOKEN is not set", st) | |
| 97 | + } | |
| 98 | + if svc.calls != 0 { | |
| 99 | + t.Errorf("the service was called %d time(s) without a key", svc.calls) | |
| 100 | + } | |
| 101 | + if err := f.Set("model", "meta-llama/Llama-3.3-70B-Instruct"); err != nil { | |
| 102 | + t.Fatal(err) | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +func TestOpenAIRefusedListSaysWhyInTheQuestion(t *testing.T) { | |
| 107 | + svc := newOpenAIService(t) | |
| 108 | + t.Setenv("OPENAI_API_KEY", "sk-wrong") | |
| 109 | + f := openaiFlow(t, Choices{BaseURL: svc.url}) | |
| 110 | + st := f.Step("model") | |
| 111 | + if st.Kind != elicit.Text || !strings.Contains(st.Prompt, "no list from") || !strings.Contains(st.Prompt, "401") { | |
| 112 | + t.Errorf("model step prompt = %q, want the refusal in it", st.Prompt) | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +func TestOpenAIEndpointAndKeyAnswersAreChecked(t *testing.T) { | |
| 117 | + f := openaiFlow(t, Choices{}) | |
| 118 | + if err := f.Set("endpoint", "api.cerebras.ai/v1"); err == nil { | |
| 119 | + t.Error("an endpoint without http(s):// was accepted") | |
| 120 | + } | |
| 121 | + if err := f.Set("keyenv", "MY-KEY"); err == nil { | |
| 122 | + t.Error("a variable name with a dash was accepted") | |
| 123 | + } | |
| 124 | + if err := f.Set("endpoint", "https://api.cerebras.ai/v1/"); err != nil || f.c.BaseURL != "https://api.cerebras.ai/v1" { | |
| 125 | + t.Errorf("endpoint: err %v, BaseURL %q, want the address without its trailing slash", err, f.c.BaseURL) | |
| 126 | + } | |
| 127 | + if err := f.Set("endpoint", "https://api.openai.com/v1"); err != nil || f.c.BaseURL != "" { | |
| 128 | + t.Errorf("the OpenAI API's own address: err %v, BaseURL %q, want none (the default)", err, f.c.BaseURL) | |
| 129 | + } | |
| 130 | + if err := f.Set("keyenv", "OPENAI_API_KEY"); err != nil || f.c.APIKeyEnv != "" { | |
| 131 | + t.Errorf("OPENAI_API_KEY: err %v, APIKeyEnv %q, want none (the default)", err, f.c.APIKeyEnv) | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +// A new endpoint or key forgets the model; another server forgets both. | |
| 136 | +func TestOpenAIChangesForgetWhatDependsOnThem(t *testing.T) { | |
| 137 | + keepConfig(t) | |
| 138 | + p, _ := LoadPresets() | |
| 139 | + f := NewFlow(context.Background(), p, Choices{}, "") | |
| 140 | + for k, v := range map[string]string{"provider": "openai"} { | |
| 141 | + if err := f.Set(k, v); err != nil { | |
| 142 | + t.Fatal(err) | |
| 143 | + } | |
| 144 | + } | |
| 145 | + for _, kv := range [][2]string{{"endpoint", "https://api.cerebras.ai/v1"}, {"keyenv", "CEREBRAS_API_KEY"}, {"model", "llama-4-scout"}} { | |
| 146 | + if err := f.Set(kv[0], kv[1]); err != nil { | |
| 147 | + t.Fatal(err) | |
| 148 | + } | |
| 149 | + } | |
| 150 | + if err := f.Set("keyenv", "OTHER_KEY"); err != nil || f.Step("model").Value != "" { | |
| 151 | + t.Errorf("a new key variable kept the model %q", f.Step("model").Value) | |
| 152 | + } | |
| 153 | + if err := f.Set("provider", "llmman"); err != nil { | |
| 154 | + t.Fatal(err) | |
| 155 | + } | |
| 156 | + if f.c.BaseURL != "" || f.c.APIKeyEnv != "" { | |
| 157 | + t.Errorf("another server kept BaseURL %q, APIKeyEnv %q", f.c.BaseURL, f.c.APIKeyEnv) | |
| 158 | + } | |
| 159 | +} | |
| 160 | + | |
| 161 | +// The written file: baseUrl and apiKeyEnv when they are not the defaults, | |
| 162 | +// and the reasoning_effort line, commented, in sampling. | |
| 163 | +func TestOpenAIFileHasTheEndpointTheKeyAndTheReasoningHint(t *testing.T) { | |
| 164 | + keepConfig(t) | |
| 165 | + p, _ := LoadPresets() | |
| 166 | + c := Choices{Provider: "openai", BaseURL: "https://api.cerebras.ai/v1", APIKeyEnv: "CEREBRAS_API_KEY", Model: "llama-4-scout", Usage: "developer", Machine: "small"} | |
| 167 | + text := Render(Decide(p, c, Facts{})) | |
| 168 | + for _, want := range []string{`baseUrl: "https://api.cerebras.ai/v1"`, "apiKeyEnv: CEREBRAS_API_KEY", " # reasoning_effort: none"} { | |
| 169 | + if !strings.Contains(text, want) { | |
| 170 | + t.Errorf("the file has no %q:\n%s", want, text) | |
| 171 | + } | |
| 172 | + } | |
| 173 | + c.BaseURL, c.APIKeyEnv = "", "" | |
| 174 | + text = Render(Decide(p, c, Facts{})) | |
| 175 | + if strings.Contains(text, "\napiKeyEnv:") || strings.Contains(text, "\nbaseUrl:") { | |
| 176 | + t.Errorf("defaults written out:\n%s", text) | |
| 177 | + } | |
| 178 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,178 @@ | |||
| 1 | +package agentinit | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "net/http" | ||
| 7 | + "net/http/httptest" | ||
| 8 | + "strings" | ||
| 9 | + "testing" | ||
| 10 | + | ||
| 11 | + "mm/internal/elicit" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// openaiService is a fake OpenAI-compatible service: GET /v1/models answers | ||
| 15 | +// two models to the key sk-good, 401 to anything else. calls counts the | ||
| 16 | +// requests, auth keeps the last Authorization header. | ||
| 17 | +type openaiService struct { | ||
| 18 | + url string | ||
| 19 | + calls int | ||
| 20 | + auth string | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +func newOpenAIService(t *testing.T) *openaiService { | ||
| 24 | + t.Helper() | ||
| 25 | + s := &openaiService{} | ||
| 26 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 27 | + s.calls++ | ||
| 28 | + s.auth = r.Header.Get("Authorization") | ||
| 29 | + if s.auth != "Bearer sk-good" { | ||
| 30 | + w.WriteHeader(http.StatusUnauthorized) | ||
| 31 | + return | ||
| 32 | + } | ||
| 33 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "llama-4-scout"}, map[string]any{"id": "gpt-oss-120b"}}}) | ||
| 34 | + })) | ||
| 35 | + t.Cleanup(srv.Close) | ||
| 36 | + s.url = srv.URL + "/v1" | ||
| 37 | + return s | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +func openaiFlow(t *testing.T, given Choices) *Flow { | ||
| 41 | + t.Helper() | ||
| 42 | + keepConfig(t) | ||
| 43 | + p, err := LoadPresets() | ||
| 44 | + if err != nil { | ||
| 45 | + t.Fatal(err) | ||
| 46 | + } | ||
| 47 | + given.Provider = "openai" | ||
| 48 | + return NewFlow(context.Background(), p, given, "") | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// Ticket 177: for openai — any OpenAI-compatible service — the endpoint and | ||
| 52 | +// the key's variable are asked between the server and the model; for the | ||
| 53 | +// other providers they are not. | ||
| 54 | +func TestOpenAIAsksTheEndpointAndTheKeyVariable(t *testing.T) { | ||
| 55 | + f := openaiFlow(t, Choices{}) | ||
| 56 | + if got := keys(f.Pending()); !strings.HasPrefix(got, "endpoint,keyenv,model,") { | ||
| 57 | + t.Errorf("pending = %s, want endpoint, keyenv, then the model", got) | ||
| 58 | + } | ||
| 59 | + if st := f.Step("endpoint"); st.Kind != elicit.Text || st.Default != "https://api.openai.com/v1" { | ||
| 60 | + t.Errorf("endpoint step: %+v", st) | ||
| 61 | + } | ||
| 62 | + if st := f.Step("keyenv"); st.Kind != elicit.Text || st.Default != "OPENAI_API_KEY" { | ||
| 63 | + t.Errorf("keyenv step: %+v", st) | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + other := NewFlow(context.Background(), f.presets, Choices{Provider: "llmman", BaseURL: llmmanStub(t)}, "") | ||
| 67 | + if got := keys(other.Pending()); strings.Contains(got, "endpoint") || strings.Contains(got, "keyenv") { | ||
| 68 | + t.Errorf("llmman pending = %s, want no endpoint or key question", got) | ||
| 69 | + } | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +func TestOpenAIListsTheModelsWithTheNamedKey(t *testing.T) { | ||
| 73 | + svc := newOpenAIService(t) | ||
| 74 | + t.Setenv("CEREBRAS_API_KEY", "sk-good") | ||
| 75 | + f := openaiFlow(t, Choices{}) | ||
| 76 | + for k, v := range map[string]string{"endpoint": svc.url, "keyenv": "CEREBRAS_API_KEY"} { | ||
| 77 | + if err := f.Set(k, v); err != nil { | ||
| 78 | + t.Fatal(err) | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + st := f.Step("model") | ||
| 82 | + if st.Kind != elicit.Choice || len(st.Options) != 2 || st.Default != "gpt-oss-120b" { | ||
| 83 | + t.Errorf("model step: %+v, want the two models of the service", st) | ||
| 84 | + } | ||
| 85 | + if svc.auth != "Bearer sk-good" { | ||
| 86 | + t.Errorf("Authorization = %q, want the key read from CEREBRAS_API_KEY", svc.auth) | ||
| 87 | + } | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +func TestOpenAIWithoutTheKeyAsksToTypeTheModel(t *testing.T) { | ||
| 91 | + svc := newOpenAIService(t) | ||
| 92 | + t.Setenv("HF_TOKEN", "") | ||
| 93 | + f := openaiFlow(t, Choices{BaseURL: svc.url, APIKeyEnv: "HF_TOKEN"}) | ||
| 94 | + st := f.Step("model") | ||
| 95 | + if st.Kind != elicit.Text || !strings.Contains(st.Prompt, "HF_TOKEN is not set") { | ||
| 96 | + t.Errorf("model step: %+v, want a text question saying HF_TOKEN is not set", st) | ||
| 97 | + } | ||
| 98 | + if svc.calls != 0 { | ||
| 99 | + t.Errorf("the service was called %d time(s) without a key", svc.calls) | ||
| 100 | + } | ||
| 101 | + if err := f.Set("model", "meta-llama/Llama-3.3-70B-Instruct"); err != nil { | ||
| 102 | + t.Fatal(err) | ||
| 103 | + } | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +func TestOpenAIRefusedListSaysWhyInTheQuestion(t *testing.T) { | ||
| 107 | + svc := newOpenAIService(t) | ||
| 108 | + t.Setenv("OPENAI_API_KEY", "sk-wrong") | ||
| 109 | + f := openaiFlow(t, Choices{BaseURL: svc.url}) | ||
| 110 | + st := f.Step("model") | ||
| 111 | + if st.Kind != elicit.Text || !strings.Contains(st.Prompt, "no list from") || !strings.Contains(st.Prompt, "401") { | ||
| 112 | + t.Errorf("model step prompt = %q, want the refusal in it", st.Prompt) | ||
| 113 | + } | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +func TestOpenAIEndpointAndKeyAnswersAreChecked(t *testing.T) { | ||
| 117 | + f := openaiFlow(t, Choices{}) | ||
| 118 | + if err := f.Set("endpoint", "api.cerebras.ai/v1"); err == nil { | ||
| 119 | + t.Error("an endpoint without http(s):// was accepted") | ||
| 120 | + } | ||
| 121 | + if err := f.Set("keyenv", "MY-KEY"); err == nil { | ||
| 122 | + t.Error("a variable name with a dash was accepted") | ||
| 123 | + } | ||
| 124 | + if err := f.Set("endpoint", "https://api.cerebras.ai/v1/"); err != nil || f.c.BaseURL != "https://api.cerebras.ai/v1" { | ||
| 125 | + t.Errorf("endpoint: err %v, BaseURL %q, want the address without its trailing slash", err, f.c.BaseURL) | ||
| 126 | + } | ||
| 127 | + if err := f.Set("endpoint", "https://api.openai.com/v1"); err != nil || f.c.BaseURL != "" { | ||
| 128 | + t.Errorf("the OpenAI API's own address: err %v, BaseURL %q, want none (the default)", err, f.c.BaseURL) | ||
| 129 | + } | ||
| 130 | + if err := f.Set("keyenv", "OPENAI_API_KEY"); err != nil || f.c.APIKeyEnv != "" { | ||
| 131 | + t.Errorf("OPENAI_API_KEY: err %v, APIKeyEnv %q, want none (the default)", err, f.c.APIKeyEnv) | ||
| 132 | + } | ||
| 133 | +} | ||
| 134 | + | ||
| 135 | +// A new endpoint or key forgets the model; another server forgets both. | ||
| 136 | +func TestOpenAIChangesForgetWhatDependsOnThem(t *testing.T) { | ||
| 137 | + keepConfig(t) | ||
| 138 | + p, _ := LoadPresets() | ||
| 139 | + f := NewFlow(context.Background(), p, Choices{}, "") | ||
| 140 | + for k, v := range map[string]string{"provider": "openai"} { | ||
| 141 | + if err := f.Set(k, v); err != nil { | ||
| 142 | + t.Fatal(err) | ||
| 143 | + } | ||
| 144 | + } | ||
| 145 | + for _, kv := range [][2]string{{"endpoint", "https://api.cerebras.ai/v1"}, {"keyenv", "CEREBRAS_API_KEY"}, {"model", "llama-4-scout"}} { | ||
| 146 | + if err := f.Set(kv[0], kv[1]); err != nil { | ||
| 147 | + t.Fatal(err) | ||
| 148 | + } | ||
| 149 | + } | ||
| 150 | + if err := f.Set("keyenv", "OTHER_KEY"); err != nil || f.Step("model").Value != "" { | ||
| 151 | + t.Errorf("a new key variable kept the model %q", f.Step("model").Value) | ||
| 152 | + } | ||
| 153 | + if err := f.Set("provider", "llmman"); err != nil { | ||
| 154 | + t.Fatal(err) | ||
| 155 | + } | ||
| 156 | + if f.c.BaseURL != "" || f.c.APIKeyEnv != "" { | ||
| 157 | + t.Errorf("another server kept BaseURL %q, APIKeyEnv %q", f.c.BaseURL, f.c.APIKeyEnv) | ||
| 158 | + } | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +// The written file: baseUrl and apiKeyEnv when they are not the defaults, | ||
| 162 | +// and the reasoning_effort line, commented, in sampling. | ||
| 163 | +func TestOpenAIFileHasTheEndpointTheKeyAndTheReasoningHint(t *testing.T) { | ||
| 164 | + keepConfig(t) | ||
| 165 | + p, _ := LoadPresets() | ||
| 166 | + c := Choices{Provider: "openai", BaseURL: "https://api.cerebras.ai/v1", APIKeyEnv: "CEREBRAS_API_KEY", Model: "llama-4-scout", Usage: "developer", Machine: "small"} | ||
| 167 | + text := Render(Decide(p, c, Facts{})) | ||
| 168 | + for _, want := range []string{`baseUrl: "https://api.cerebras.ai/v1"`, "apiKeyEnv: CEREBRAS_API_KEY", " # reasoning_effort: none"} { | ||
| 169 | + if !strings.Contains(text, want) { | ||
| 170 | + t.Errorf("the file has no %q:\n%s", want, text) | ||
| 171 | + } | ||
| 172 | + } | ||
| 173 | + c.BaseURL, c.APIKeyEnv = "", "" | ||
| 174 | + text = Render(Decide(p, c, Facts{})) | ||
| 175 | + if strings.Contains(text, "\napiKeyEnv:") || strings.Contains(text, "\nbaseUrl:") { | ||
| 176 | + t.Errorf("defaults written out:\n%s", text) | ||
| 177 | + } | ||
| 178 | +} | ||
modified
internal/agentinit/render.go +30 -4 | @@ -33,10 +33,7 @@ func Render(s Settings) string { | ||
| 33 | 33 | writeDefaultURLs(&b, c.Provider) |
| 34 | 34 | } |
| 35 | 35 | if c.Provider == "openai" { |
| 36 | - b.WriteString("# The key, when the server needs one, is read from OPENAI_API_KEY (or the\n# variable apiKeyEnv names): export it — it never goes in this file.\n") | |
| 37 | - if c.BaseURL == "" { | |
| 38 | - b.WriteString("# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible server.\n") | |
| 39 | - } | |
| 36 | + writeKeyEnv(&b, c) | |
| 40 | 37 | } |
| 41 | 38 | b.WriteString("\n") |
| 42 | 39 | |
| @@ -59,6 +56,9 @@ func Render(s Settings) string { | ||
| 59 | 56 | b.WriteString(" chat_template_kwargs:\n") |
| 60 | 57 | writeSettings(&b, " ", s.Kwargs) |
| 61 | 58 | } |
| 59 | + if c.Provider == "openai" { | |
| 60 | + b.WriteString(reasoningEffortHint) | |
| 61 | + } | |
| 62 | 62 | b.WriteString("\n") |
| 63 | 63 | |
| 64 | 64 | b.WriteString("context:\n") |
| @@ -210,6 +210,32 @@ func coupledTo(s Settings) string { | ||
| 210 | 210 | return text + ". Change the model and redo them together — running mm init again is the quick way." |
| 211 | 211 | } |
| 212 | 212 | |
| 213 | +// writeKeyEnv says where the openai provider reads the API key: the variable | |
| 214 | +// the user named (written as apiKeyEnv) or OPENAI_API_KEY, the default. | |
| 215 | +func writeKeyEnv(b *strings.Builder, c Choices) { | |
| 216 | + if c.APIKeyEnv != "" { | |
| 217 | + fmt.Fprintf(b, "# The API key is read from %s: export it — it never goes in this file.\n", c.APIKeyEnv) | |
| 218 | + fmt.Fprintf(b, "apiKeyEnv: %s\n", c.APIKeyEnv) | |
| 219 | + } else { | |
| 220 | + b.WriteString("# The API key is read from OPENAI_API_KEY (or the variable apiKeyEnv names):\n# export it — it never goes in this file.\n") | |
| 221 | + } | |
| 222 | + if c.BaseURL == "" { | |
| 223 | + b.WriteString("# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible service.\n") | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +// reasoningEffortHint goes at the end of the sampling block of an openai | |
| 228 | +// file, ready to uncomment (ticket 177). mm speaks /v1/chat/completions and | |
| 229 | +// always sends tools; some OpenAI models refuse both together while they | |
| 230 | +// reason — seen: gpt-6-luna, "Function tools with reasoning_effort are not | |
| 231 | +// supported … set reasoning_effort to 'none'". Commented: which models need | |
| 232 | +// it is not known in advance, and it turns the reasoning off. | |
| 233 | +const reasoningEffortHint = ` # Uncomment if the API answers "Function tools with reasoning_effort are not | |
| 234 | + # supported … set reasoning_effort to 'none'" (seen with gpt-6-luna): mm uses | |
| 235 | + # /v1/chat/completions and always sends tools. It turns the reasoning off. | |
| 236 | + # reasoning_effort: none | |
| 237 | +` | |
| 238 | + | |
| 213 | 239 | // writeDefaultURLs records, as comments, the addresses a file without |
| 214 | 240 | // baseUrl relies on: the provider's defaults may change, and the file then |
| 215 | 241 | // still says what they were when it was written. Uncommented, they are the |
| @@ -33,10 +33,7 @@ func Render(s Settings) string { | |||
| 33 | writeDefaultURLs(&b, c.Provider) | 33 | writeDefaultURLs(&b, c.Provider) |
| 34 | } | 34 | } |
| 35 | if c.Provider == "openai" { | 35 | if c.Provider == "openai" { |
| 36 | - b.WriteString("# The key, when the server needs one, is read from OPENAI_API_KEY (or the\n# variable apiKeyEnv names): export it — it never goes in this file.\n") | 36 | + writeKeyEnv(&b, c) |
| 37 | - if c.BaseURL == "" { | ||
| 38 | - b.WriteString("# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible server.\n") | ||
| 39 | - } | ||
| 40 | } | 37 | } |
| 41 | b.WriteString("\n") | 38 | b.WriteString("\n") |
| 42 | 39 | ||
| @@ -59,6 +56,9 @@ func Render(s Settings) string { | |||
| 59 | b.WriteString(" chat_template_kwargs:\n") | 56 | b.WriteString(" chat_template_kwargs:\n") |
| 60 | writeSettings(&b, " ", s.Kwargs) | 57 | writeSettings(&b, " ", s.Kwargs) |
| 61 | } | 58 | } |
| 59 | + if c.Provider == "openai" { | ||
| 60 | + b.WriteString(reasoningEffortHint) | ||
| 61 | + } | ||
| 62 | b.WriteString("\n") | 62 | b.WriteString("\n") |
| 63 | 63 | ||
| 64 | b.WriteString("context:\n") | 64 | b.WriteString("context:\n") |
| @@ -210,6 +210,32 @@ func coupledTo(s Settings) string { | |||
| 210 | return text + ". Change the model and redo them together — running mm init again is the quick way." | 210 | return text + ". Change the model and redo them together — running mm init again is the quick way." |
| 211 | } | 211 | } |
| 212 | 212 | ||
| 213 | +// writeKeyEnv says where the openai provider reads the API key: the variable | ||
| 214 | +// the user named (written as apiKeyEnv) or OPENAI_API_KEY, the default. | ||
| 215 | +func writeKeyEnv(b *strings.Builder, c Choices) { | ||
| 216 | + if c.APIKeyEnv != "" { | ||
| 217 | + fmt.Fprintf(b, "# The API key is read from %s: export it — it never goes in this file.\n", c.APIKeyEnv) | ||
| 218 | + fmt.Fprintf(b, "apiKeyEnv: %s\n", c.APIKeyEnv) | ||
| 219 | + } else { | ||
| 220 | + b.WriteString("# The API key is read from OPENAI_API_KEY (or the variable apiKeyEnv names):\n# export it — it never goes in this file.\n") | ||
| 221 | + } | ||
| 222 | + if c.BaseURL == "" { | ||
| 223 | + b.WriteString("# The default is the OpenAI API: set baseUrl for any other OpenAI-compatible service.\n") | ||
| 224 | + } | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +// reasoningEffortHint goes at the end of the sampling block of an openai | ||
| 228 | +// file, ready to uncomment (ticket 177). mm speaks /v1/chat/completions and | ||
| 229 | +// always sends tools; some OpenAI models refuse both together while they | ||
| 230 | +// reason — seen: gpt-6-luna, "Function tools with reasoning_effort are not | ||
| 231 | +// supported … set reasoning_effort to 'none'". Commented: which models need | ||
| 232 | +// it is not known in advance, and it turns the reasoning off. | ||
| 233 | +const reasoningEffortHint = ` # Uncomment if the API answers "Function tools with reasoning_effort are not | ||
| 234 | + # supported … set reasoning_effort to 'none'" (seen with gpt-6-luna): mm uses | ||
| 235 | + # /v1/chat/completions and always sends tools. It turns the reasoning off. | ||
| 236 | + # reasoning_effort: none | ||
| 237 | +` | ||
| 238 | + | ||
| 213 | // writeDefaultURLs records, as comments, the addresses a file without | 239 | // writeDefaultURLs records, as comments, the addresses a file without |
| 214 | // baseUrl relies on: the provider's defaults may change, and the file then | 240 | // baseUrl relies on: the provider's defaults may change, and the file then |
| 215 | // still says what they were when it was written. Uncommented, they are the | 241 | // still says what they were when it was written. Uncommented, they are the |
modified
internal/agentinit/wizard.go +4 -6 | @@ -56,7 +56,8 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | ||
| 56 | 56 | var yolo, output string |
| 57 | 57 | var check, yes, line bool |
| 58 | 58 | fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", ")) |
| 59 | - fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's)") | |
| 59 | + fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question") | |
| 60 | + fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)") | |
| 60 | 61 | fs.StringVar(&c.Model, "model", "", "the model, as the server names it") |
| 61 | 62 | fs.StringVar(&c.Family, "family", "", "the model family (default: detected from the name)") |
| 62 | 63 | fs.StringVar(&c.Usage, "usage", "", "what the agent is for (developer, translator, docs, json…)") |
| @@ -197,11 +198,8 @@ func (w *lineWizard) run(f *Flow) error { | ||
| 197 | 198 | return nil |
| 198 | 199 | } |
| 199 | 200 | st := pending[0] |
| 200 | - if st.Key == "model" && st.Kind == elicit.Text { | |
| 201 | - if err := f.ModelsError(); err != nil { | |
| 202 | - fmt.Fprintf(w.out, "ℹ️ %s does not answer on %s (%v)\n", f.c.Provider, f.Base(), err) | |
| 203 | - } | |
| 204 | - } | |
| 201 | + // Without a model list, the model question itself says why | |
| 202 | + // (Flow.typeModelPrompt): the window shows the same words. | |
| 205 | 203 | answer := w.ask(st) |
| 206 | 204 | if answer == "" { |
| 207 | 205 | return fmt.Errorf("%s: no answer", strings.ToLower(st.Title)) |
| @@ -56,7 +56,8 @@ func Main(args []string, stdin io.Reader, stdout io.Writer, opts ...Options) int | |||
| 56 | var yolo, output string | 56 | var yolo, output string |
| 57 | var check, yes, line bool | 57 | var check, yes, line bool |
| 58 | fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", ")) | 58 | fs.StringVar(&c.Provider, "provider", "", "model server: "+strings.Join(providers, ", ")) |
| 59 | - fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's)") | 59 | + fs.StringVar(&c.BaseURL, "base-url", "", "the server's OpenAI-compatible address (default: the provider's); for openai, the endpoint question") |
| 60 | + fs.StringVar(&c.APIKeyEnv, "api-key-env", "", "openai: the environment variable that holds the API key (default OPENAI_API_KEY)") | ||
| 60 | fs.StringVar(&c.Model, "model", "", "the model, as the server names it") | 61 | fs.StringVar(&c.Model, "model", "", "the model, as the server names it") |
| 61 | fs.StringVar(&c.Family, "family", "", "the model family (default: detected from the name)") | 62 | fs.StringVar(&c.Family, "family", "", "the model family (default: detected from the name)") |
| 62 | fs.StringVar(&c.Usage, "usage", "", "what the agent is for (developer, translator, docs, json…)") | 63 | fs.StringVar(&c.Usage, "usage", "", "what the agent is for (developer, translator, docs, json…)") |
| @@ -197,11 +198,8 @@ func (w *lineWizard) run(f *Flow) error { | |||
| 197 | return nil | 198 | return nil |
| 198 | } | 199 | } |
| 199 | st := pending[0] | 200 | st := pending[0] |
| 200 | - if st.Key == "model" && st.Kind == elicit.Text { | 201 | + // Without a model list, the model question itself says why |
| 201 | - if err := f.ModelsError(); err != nil { | 202 | + // (Flow.typeModelPrompt): the window shows the same words. |
| 202 | - fmt.Fprintf(w.out, "ℹ️ %s does not answer on %s (%v)\n", f.c.Provider, f.Base(), err) | ||
| 203 | - } | ||
| 204 | - } | ||
| 205 | answer := w.ask(st) | 203 | answer := w.ask(st) |
| 206 | if answer == "" { | 204 | if answer == "" { |
| 207 | return fmt.Errorf("%s: no answer", strings.ToLower(st.Title)) | 205 | return fmt.Errorf("%s: no answer", strings.ToLower(st.Title)) |
modified
internal/tui/initwin.go +72 -19 | @@ -34,12 +34,12 @@ func RunInit(_ context.Context, flow *agentinit.Flow) (bool, error) { | ||
| 34 | 34 | type initWin struct { |
| 35 | 35 | screen tcell.Screen |
| 36 | 36 | flow *agentinit.Flow |
| 37 | - cursor int // the step being answered, in flow.Steps() | |
| 38 | - sel map[string]int // the highlighted option, per step | |
| 39 | - text map[string][]rune | |
| 40 | - scroll int // preview lines hidden above | |
| 41 | - preview bool // narrow screens: the preview replaces the question (Tab) | |
| 42 | - confirm bool // every question answered: "write the file?" | |
| 37 | + cursor int // the step being answered, in flow.Steps() | |
| 38 | + sel map[string]int // the highlighted option, per step | |
| 39 | + text map[string]*field // what is typed for a Text step, with its caret | |
| 40 | + scroll int // preview lines hidden above | |
| 41 | + preview bool // narrow screens: the preview replaces the question (Tab) | |
| 42 | + confirm bool // every question answered: "write the file?" | |
| 43 | 43 | problem string |
| 44 | 44 | result int // 0 running, 1 write, -1 quit |
| 45 | 45 | } |
| @@ -49,7 +49,7 @@ type initWin struct { | ||
| 49 | 49 | const previewMinWidth = 100 |
| 50 | 50 | |
| 51 | 51 | func runInit(screen tcell.Screen, flow *agentinit.Flow) bool { |
| 52 | - w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}} | |
| 52 | + w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}} | |
| 53 | 53 | w.toFirstPending() |
| 54 | 54 | for w.result == 0 { |
| 55 | 55 | w.draw() |
| @@ -104,7 +104,7 @@ func (w *initWin) initSelection(st agentinit.Step) { | ||
| 104 | 104 | } |
| 105 | 105 | if st.Kind == elicit.Text { |
| 106 | 106 | if _, ok := w.text[st.Key]; !ok { |
| 107 | - w.text[st.Key] = []rune(want) | |
| 107 | + w.text[st.Key] = newField(want) | |
| 108 | 108 | } |
| 109 | 109 | } |
| 110 | 110 | } |
| @@ -112,7 +112,7 @@ func (w *initWin) initSelection(st agentinit.Step) { | ||
| 112 | 112 | // candidate is what the step would be answered with now. |
| 113 | 113 | func (w *initWin) candidate(st agentinit.Step) string { |
| 114 | 114 | if st.Kind == elicit.Text { |
| 115 | - return strings.TrimSpace(string(w.text[st.Key])) | |
| 115 | + return strings.TrimSpace(w.text[st.Key].String()) | |
| 116 | 116 | } |
| 117 | 117 | if len(st.Options) == 0 { |
| 118 | 118 | return "" |
| @@ -170,19 +170,67 @@ func (w *initWin) handleKey(ev *tcell.EventKey) { | ||
| 170 | 170 | } |
| 171 | 171 | |
| 172 | 172 | func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) { |
| 173 | - t := w.text[st.Key] | |
| 173 | + if ev.Key() == tcell.KeyEnter { | |
| 174 | + w.answer(st) | |
| 175 | + return | |
| 176 | + } | |
| 177 | + f := w.text[st.Key] | |
| 178 | + if f == nil { | |
| 179 | + f = newField("") | |
| 180 | + w.text[st.Key] = f | |
| 181 | + } | |
| 182 | + f.key(ev) | |
| 183 | +} | |
| 184 | + | |
| 185 | +// field is a one-line text being typed — an endpoint, a variable name, a | |
| 186 | +// file name — with its caret, a rune index: the arrows move it, typing and | |
| 187 | +// deleting happen there (until ticket 177's questions, only appending and | |
| 188 | +// Backspace at the end were possible). | |
| 189 | +// | |
| 190 | +// f := newField("agent.yaml") // caret at the end | |
| 191 | +// f.key(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) | |
| 192 | +// f.key(tcell.NewEventKey(tcell.KeyRune, 'm', tcell.ModNone)) // "magent.yaml" | |
| 193 | +type field struct { | |
| 194 | + runes []rune | |
| 195 | + caret int | |
| 196 | +} | |
| 197 | + | |
| 198 | +func newField(s string) *field { | |
| 199 | + r := []rune(s) | |
| 200 | + return &field{runes: r, caret: len(r)} | |
| 201 | +} | |
| 202 | + | |
| 203 | +func (f *field) String() string { return string(f.runes) } | |
| 204 | + | |
| 205 | +// key edits the text; it reports whether the key was an editing key. | |
| 206 | +func (f *field) key(ev *tcell.EventKey) bool { | |
| 174 | 207 | switch ev.Key() { |
| 175 | 208 | case tcell.KeyRune: |
| 176 | - w.text[st.Key] = append(t, ev.Rune()) | |
| 209 | + f.runes = append(f.runes[:f.caret], append([]rune{ev.Rune()}, f.runes[f.caret:]...)...) | |
| 210 | + f.caret++ | |
| 177 | 211 | case tcell.KeyBackspace, tcell.KeyBackspace2: |
| 178 | - if len(t) > 0 { | |
| 179 | - w.text[st.Key] = t[:len(t)-1] | |
| 212 | + if f.caret > 0 { | |
| 213 | + f.runes = append(f.runes[:f.caret-1], f.runes[f.caret:]...) | |
| 214 | + f.caret-- | |
| 180 | 215 | } |
| 216 | + case tcell.KeyDelete: | |
| 217 | + if f.caret < len(f.runes) { | |
| 218 | + f.runes = append(f.runes[:f.caret], f.runes[f.caret+1:]...) | |
| 219 | + } | |
| 220 | + case tcell.KeyLeft: | |
| 221 | + f.caret = max(f.caret-1, 0) | |
| 222 | + case tcell.KeyRight: | |
| 223 | + f.caret = min(f.caret+1, len(f.runes)) | |
| 224 | + case tcell.KeyHome, tcell.KeyCtrlA: | |
| 225 | + f.caret = 0 | |
| 226 | + case tcell.KeyEnd, tcell.KeyCtrlE: | |
| 227 | + f.caret = len(f.runes) | |
| 181 | 228 | case tcell.KeyCtrlU: |
| 182 | - w.text[st.Key] = nil | |
| 183 | - case tcell.KeyEnter: | |
| 184 | - w.answer(st) | |
| 229 | + f.runes, f.caret = nil, 0 | |
| 230 | + default: | |
| 231 | + return false | |
| 185 | 232 | } |
| 233 | + return true | |
| 186 | 234 | } |
| 187 | 235 | |
| 188 | 236 | // answer records the highlighted choice and moves to the next question. |
| @@ -260,7 +308,7 @@ func (w *initWin) drawStatus(p painter) { | ||
| 260 | 308 | case w.confirm: |
| 261 | 309 | keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit " |
| 262 | 310 | case w.current().Kind == elicit.Text: |
| 263 | - keys = " type · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit " | |
| 311 | + keys = " type · ←→ move · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit " | |
| 264 | 312 | } |
| 265 | 313 | if p.width() < previewMinWidth { |
| 266 | 314 | keys = strings.Replace(keys, " · Ctrl-C", " · Tab preview · Ctrl-C", 1) |
| @@ -321,8 +369,13 @@ func (w *initWin) drawQuestion(p painter) { | ||
| 321 | 369 | st := w.current() |
| 322 | 370 | p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel) |
| 323 | 371 | if st.Kind == elicit.Text { |
| 324 | - t := string(w.text[st.Key]) | |
| 325 | - x := p.text(1, 2, "› "+t, stBody) | |
| 372 | + f := w.text[st.Key] | |
| 373 | + if f == nil { | |
| 374 | + f = newField("") | |
| 375 | + } | |
| 376 | + // The text before the caret, then the rest: the cursor goes between. | |
| 377 | + x := p.text(1, 2, "› "+string(f.runes[:f.caret]), stBody) | |
| 378 | + p.text(x, 2, string(f.runes[f.caret:]), stBody) | |
| 326 | 379 | p.showCursor(x, 2) |
| 327 | 380 | if st.Default != "" { |
| 328 | 381 | p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice) |
| @@ -34,12 +34,12 @@ func RunInit(_ context.Context, flow *agentinit.Flow) (bool, error) { | |||
| 34 | type initWin struct { | 34 | type initWin struct { |
| 35 | screen tcell.Screen | 35 | screen tcell.Screen |
| 36 | flow *agentinit.Flow | 36 | flow *agentinit.Flow |
| 37 | - cursor int // the step being answered, in flow.Steps() | 37 | + cursor int // the step being answered, in flow.Steps() |
| 38 | - sel map[string]int // the highlighted option, per step | 38 | + sel map[string]int // the highlighted option, per step |
| 39 | - text map[string][]rune | 39 | + text map[string]*field // what is typed for a Text step, with its caret |
| 40 | - scroll int // preview lines hidden above | 40 | + scroll int // preview lines hidden above |
| 41 | - preview bool // narrow screens: the preview replaces the question (Tab) | 41 | + preview bool // narrow screens: the preview replaces the question (Tab) |
| 42 | - confirm bool // every question answered: "write the file?" | 42 | + confirm bool // every question answered: "write the file?" |
| 43 | problem string | 43 | problem string |
| 44 | result int // 0 running, 1 write, -1 quit | 44 | result int // 0 running, 1 write, -1 quit |
| 45 | } | 45 | } |
| @@ -49,7 +49,7 @@ type initWin struct { | |||
| 49 | const previewMinWidth = 100 | 49 | const previewMinWidth = 100 |
| 50 | 50 | ||
| 51 | func runInit(screen tcell.Screen, flow *agentinit.Flow) bool { | 51 | func runInit(screen tcell.Screen, flow *agentinit.Flow) bool { |
| 52 | - w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}} | 52 | + w := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}} |
| 53 | w.toFirstPending() | 53 | w.toFirstPending() |
| 54 | for w.result == 0 { | 54 | for w.result == 0 { |
| 55 | w.draw() | 55 | w.draw() |
| @@ -104,7 +104,7 @@ func (w *initWin) initSelection(st agentinit.Step) { | |||
| 104 | } | 104 | } |
| 105 | if st.Kind == elicit.Text { | 105 | if st.Kind == elicit.Text { |
| 106 | if _, ok := w.text[st.Key]; !ok { | 106 | if _, ok := w.text[st.Key]; !ok { |
| 107 | - w.text[st.Key] = []rune(want) | 107 | + w.text[st.Key] = newField(want) |
| 108 | } | 108 | } |
| 109 | } | 109 | } |
| 110 | } | 110 | } |
| @@ -112,7 +112,7 @@ func (w *initWin) initSelection(st agentinit.Step) { | |||
| 112 | // candidate is what the step would be answered with now. | 112 | // candidate is what the step would be answered with now. |
| 113 | func (w *initWin) candidate(st agentinit.Step) string { | 113 | func (w *initWin) candidate(st agentinit.Step) string { |
| 114 | if st.Kind == elicit.Text { | 114 | if st.Kind == elicit.Text { |
| 115 | - return strings.TrimSpace(string(w.text[st.Key])) | 115 | + return strings.TrimSpace(w.text[st.Key].String()) |
| 116 | } | 116 | } |
| 117 | if len(st.Options) == 0 { | 117 | if len(st.Options) == 0 { |
| 118 | return "" | 118 | return "" |
| @@ -170,19 +170,67 @@ func (w *initWin) handleKey(ev *tcell.EventKey) { | |||
| 170 | } | 170 | } |
| 171 | 171 | ||
| 172 | func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) { | 172 | func (w *initWin) textKey(st agentinit.Step, ev *tcell.EventKey) { |
| 173 | - t := w.text[st.Key] | 173 | + if ev.Key() == tcell.KeyEnter { |
| 174 | + w.answer(st) | ||
| 175 | + return | ||
| 176 | + } | ||
| 177 | + f := w.text[st.Key] | ||
| 178 | + if f == nil { | ||
| 179 | + f = newField("") | ||
| 180 | + w.text[st.Key] = f | ||
| 181 | + } | ||
| 182 | + f.key(ev) | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +// field is a one-line text being typed — an endpoint, a variable name, a | ||
| 186 | +// file name — with its caret, a rune index: the arrows move it, typing and | ||
| 187 | +// deleting happen there (until ticket 177's questions, only appending and | ||
| 188 | +// Backspace at the end were possible). | ||
| 189 | +// | ||
| 190 | +// f := newField("agent.yaml") // caret at the end | ||
| 191 | +// f.key(tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone)) | ||
| 192 | +// f.key(tcell.NewEventKey(tcell.KeyRune, 'm', tcell.ModNone)) // "magent.yaml" | ||
| 193 | +type field struct { | ||
| 194 | + runes []rune | ||
| 195 | + caret int | ||
| 196 | +} | ||
| 197 | + | ||
| 198 | +func newField(s string) *field { | ||
| 199 | + r := []rune(s) | ||
| 200 | + return &field{runes: r, caret: len(r)} | ||
| 201 | +} | ||
| 202 | + | ||
| 203 | +func (f *field) String() string { return string(f.runes) } | ||
| 204 | + | ||
| 205 | +// key edits the text; it reports whether the key was an editing key. | ||
| 206 | +func (f *field) key(ev *tcell.EventKey) bool { | ||
| 174 | switch ev.Key() { | 207 | switch ev.Key() { |
| 175 | case tcell.KeyRune: | 208 | case tcell.KeyRune: |
| 176 | - w.text[st.Key] = append(t, ev.Rune()) | 209 | + f.runes = append(f.runes[:f.caret], append([]rune{ev.Rune()}, f.runes[f.caret:]...)...) |
| 210 | + f.caret++ | ||
| 177 | case tcell.KeyBackspace, tcell.KeyBackspace2: | 211 | case tcell.KeyBackspace, tcell.KeyBackspace2: |
| 178 | - if len(t) > 0 { | 212 | + if f.caret > 0 { |
| 179 | - w.text[st.Key] = t[:len(t)-1] | 213 | + f.runes = append(f.runes[:f.caret-1], f.runes[f.caret:]...) |
| 214 | + f.caret-- | ||
| 180 | } | 215 | } |
| 216 | + case tcell.KeyDelete: | ||
| 217 | + if f.caret < len(f.runes) { | ||
| 218 | + f.runes = append(f.runes[:f.caret], f.runes[f.caret+1:]...) | ||
| 219 | + } | ||
| 220 | + case tcell.KeyLeft: | ||
| 221 | + f.caret = max(f.caret-1, 0) | ||
| 222 | + case tcell.KeyRight: | ||
| 223 | + f.caret = min(f.caret+1, len(f.runes)) | ||
| 224 | + case tcell.KeyHome, tcell.KeyCtrlA: | ||
| 225 | + f.caret = 0 | ||
| 226 | + case tcell.KeyEnd, tcell.KeyCtrlE: | ||
| 227 | + f.caret = len(f.runes) | ||
| 181 | case tcell.KeyCtrlU: | 228 | case tcell.KeyCtrlU: |
| 182 | - w.text[st.Key] = nil | 229 | + f.runes, f.caret = nil, 0 |
| 183 | - case tcell.KeyEnter: | 230 | + default: |
| 184 | - w.answer(st) | 231 | + return false |
| 185 | } | 232 | } |
| 233 | + return true | ||
| 186 | } | 234 | } |
| 187 | 235 | ||
| 188 | // answer records the highlighted choice and moves to the next question. | 236 | // answer records the highlighted choice and moves to the next question. |
| @@ -260,7 +308,7 @@ func (w *initWin) drawStatus(p painter) { | |||
| 260 | case w.confirm: | 308 | case w.confirm: |
| 261 | keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit " | 309 | keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit " |
| 262 | case w.current().Kind == elicit.Text: | 310 | case w.current().Kind == elicit.Text: |
| 263 | - keys = " type · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit " | 311 | + keys = " type · ←→ move · Enter next · Ctrl-U clear · Esc back · PgUp/PgDn preview · Ctrl-C quit " |
| 264 | } | 312 | } |
| 265 | if p.width() < previewMinWidth { | 313 | if p.width() < previewMinWidth { |
| 266 | keys = strings.Replace(keys, " · Ctrl-C", " · Tab preview · Ctrl-C", 1) | 314 | keys = strings.Replace(keys, " · Ctrl-C", " · Tab preview · Ctrl-C", 1) |
| @@ -321,8 +369,13 @@ func (w *initWin) drawQuestion(p painter) { | |||
| 321 | st := w.current() | 369 | st := w.current() |
| 322 | p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel) | 370 | p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel) |
| 323 | if st.Kind == elicit.Text { | 371 | if st.Kind == elicit.Text { |
| 324 | - t := string(w.text[st.Key]) | 372 | + f := w.text[st.Key] |
| 325 | - x := p.text(1, 2, "› "+t, stBody) | 373 | + if f == nil { |
| 374 | + f = newField("") | ||
| 375 | + } | ||
| 376 | + // The text before the caret, then the rest: the cursor goes between. | ||
| 377 | + x := p.text(1, 2, "› "+string(f.runes[:f.caret]), stBody) | ||
| 378 | + p.text(x, 2, string(f.runes[f.caret:]), stBody) | ||
| 326 | p.showCursor(x, 2) | 379 | p.showCursor(x, 2) |
| 327 | if st.Default != "" { | 380 | if st.Default != "" { |
| 328 | p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice) | 381 | p.textLimited(1, 4, p.width()-2, "default: "+st.Default, stNotice) |
modified
internal/tui/initwin_test.go +58 -1 | @@ -43,7 +43,7 @@ func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.S | ||
| 43 | 43 | } |
| 44 | 44 | screen.SetSize(w, h) |
| 45 | 45 | t.Cleanup(screen.Fini) |
| 46 | - win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}} | |
| 46 | + win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}} | |
| 47 | 47 | win.toFirstPending() |
| 48 | 48 | return win, screen |
| 49 | 49 | } |
| @@ -188,3 +188,60 @@ func TestInitWindowListsModelsByShortName(t *testing.T) { | ||
| 188 | 188 | t.Errorf("the answer is %q, want the full identifier", got) |
| 189 | 189 | } |
| 190 | 190 | } |
| 191 | + | |
| 192 | +// A text answer is edited where the caret is: the arrows move it, typing | |
| 193 | +// inserts there, Backspace and Delete remove around it (user report: only | |
| 194 | +// appending and Backspace at the end worked). | |
| 195 | +func TestFieldEditsAtTheCaret(t *testing.T) { | |
| 196 | + press := func(f *field, k tcell.Key, r rune) { f.key(tcell.NewEventKey(k, r, tcell.ModNone)) } | |
| 197 | + f := newField("OPENAI_API_KEY") | |
| 198 | + for range len("API_KEY") { | |
| 199 | + press(f, tcell.KeyLeft, 0) | |
| 200 | + } | |
| 201 | + for _, r := range "MISTRAL_" { | |
| 202 | + press(f, tcell.KeyRune, r) | |
| 203 | + } | |
| 204 | + if got := f.String(); got != "OPENAI_MISTRAL_API_KEY" { | |
| 205 | + t.Fatalf("insert in the middle: %q", got) | |
| 206 | + } | |
| 207 | + press(f, tcell.KeyHome, 0) | |
| 208 | + for range len("OPENAI_") { | |
| 209 | + press(f, tcell.KeyDelete, 0) | |
| 210 | + } | |
| 211 | + if got := f.String(); got != "MISTRAL_API_KEY" { | |
| 212 | + t.Fatalf("Delete at the start: %q", got) | |
| 213 | + } | |
| 214 | + press(f, tcell.KeyEnd, 0) | |
| 215 | + press(f, tcell.KeyBackspace2, 0) | |
| 216 | + press(f, tcell.KeyRight, 0) // already at the end: stays | |
| 217 | + press(f, tcell.KeyRune, 'Y') | |
| 218 | + if got := f.String(); got != "MISTRAL_API_KEY" { | |
| 219 | + t.Errorf("Backspace then type at the end: %q", got) | |
| 220 | + } | |
| 221 | + press(f, tcell.KeyCtrlA, 0) | |
| 222 | + press(f, tcell.KeyLeft, 0) // already at the start: stays | |
| 223 | + press(f, tcell.KeyBackspace2, 0) | |
| 224 | + if got := f.String(); got != "MISTRAL_API_KEY" || f.caret != 0 { | |
| 225 | + t.Errorf("Backspace at the start changed %q (caret %d)", got, f.caret) | |
| 226 | + } | |
| 227 | +} | |
| 228 | + | |
| 229 | +// In the window: the name's default, edited in the middle, is what the | |
| 230 | +// flow records. | |
| 231 | +func TestInitWindowEditsATextAnswerInTheMiddle(t *testing.T) { | |
| 232 | + flow := initFlow(t, "out.yaml") | |
| 233 | + win, _ := newInitWin(t, flow, 120, 30) | |
| 234 | + for win.current().Key != "name" { | |
| 235 | + key(win, tcell.KeyEnter) | |
| 236 | + } | |
| 237 | + key(win, tcell.KeyCtrlU) | |
| 238 | + typeText(win, "bobby") | |
| 239 | + key(win, tcell.KeyLeft) | |
| 240 | + key(win, tcell.KeyLeft) | |
| 241 | + key(win, tcell.KeyDelete) | |
| 242 | + typeText(win, "-") | |
| 243 | + key(win, tcell.KeyEnter) | |
| 244 | + if c, _, _ := flow.Result(); c.Name != "bob-y" { | |
| 245 | + t.Errorf("name %q, want bob-y", c.Name) | |
| 246 | + } | |
| 247 | +} | |
| @@ -43,7 +43,7 @@ func newInitWin(t *testing.T, flow *agentinit.Flow, w, h int) (*initWin, tcell.S | |||
| 43 | } | 43 | } |
| 44 | screen.SetSize(w, h) | 44 | screen.SetSize(w, h) |
| 45 | t.Cleanup(screen.Fini) | 45 | t.Cleanup(screen.Fini) |
| 46 | - win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string][]rune{}} | 46 | + win := &initWin{screen: screen, flow: flow, sel: map[string]int{}, text: map[string]*field{}} |
| 47 | win.toFirstPending() | 47 | win.toFirstPending() |
| 48 | return win, screen | 48 | return win, screen |
| 49 | } | 49 | } |
| @@ -188,3 +188,60 @@ func TestInitWindowListsModelsByShortName(t *testing.T) { | |||
| 188 | t.Errorf("the answer is %q, want the full identifier", got) | 188 | t.Errorf("the answer is %q, want the full identifier", got) |
| 189 | } | 189 | } |
| 190 | } | 190 | } |
| 191 | + | ||
| 192 | +// A text answer is edited where the caret is: the arrows move it, typing | ||
| 193 | +// inserts there, Backspace and Delete remove around it (user report: only | ||
| 194 | +// appending and Backspace at the end worked). | ||
| 195 | +func TestFieldEditsAtTheCaret(t *testing.T) { | ||
| 196 | + press := func(f *field, k tcell.Key, r rune) { f.key(tcell.NewEventKey(k, r, tcell.ModNone)) } | ||
| 197 | + f := newField("OPENAI_API_KEY") | ||
| 198 | + for range len("API_KEY") { | ||
| 199 | + press(f, tcell.KeyLeft, 0) | ||
| 200 | + } | ||
| 201 | + for _, r := range "MISTRAL_" { | ||
| 202 | + press(f, tcell.KeyRune, r) | ||
| 203 | + } | ||
| 204 | + if got := f.String(); got != "OPENAI_MISTRAL_API_KEY" { | ||
| 205 | + t.Fatalf("insert in the middle: %q", got) | ||
| 206 | + } | ||
| 207 | + press(f, tcell.KeyHome, 0) | ||
| 208 | + for range len("OPENAI_") { | ||
| 209 | + press(f, tcell.KeyDelete, 0) | ||
| 210 | + } | ||
| 211 | + if got := f.String(); got != "MISTRAL_API_KEY" { | ||
| 212 | + t.Fatalf("Delete at the start: %q", got) | ||
| 213 | + } | ||
| 214 | + press(f, tcell.KeyEnd, 0) | ||
| 215 | + press(f, tcell.KeyBackspace2, 0) | ||
| 216 | + press(f, tcell.KeyRight, 0) // already at the end: stays | ||
| 217 | + press(f, tcell.KeyRune, 'Y') | ||
| 218 | + if got := f.String(); got != "MISTRAL_API_KEY" { | ||
| 219 | + t.Errorf("Backspace then type at the end: %q", got) | ||
| 220 | + } | ||
| 221 | + press(f, tcell.KeyCtrlA, 0) | ||
| 222 | + press(f, tcell.KeyLeft, 0) // already at the start: stays | ||
| 223 | + press(f, tcell.KeyBackspace2, 0) | ||
| 224 | + if got := f.String(); got != "MISTRAL_API_KEY" || f.caret != 0 { | ||
| 225 | + t.Errorf("Backspace at the start changed %q (caret %d)", got, f.caret) | ||
| 226 | + } | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +// In the window: the name's default, edited in the middle, is what the | ||
| 230 | +// flow records. | ||
| 231 | +func TestInitWindowEditsATextAnswerInTheMiddle(t *testing.T) { | ||
| 232 | + flow := initFlow(t, "out.yaml") | ||
| 233 | + win, _ := newInitWin(t, flow, 120, 30) | ||
| 234 | + for win.current().Key != "name" { | ||
| 235 | + key(win, tcell.KeyEnter) | ||
| 236 | + } | ||
| 237 | + key(win, tcell.KeyCtrlU) | ||
| 238 | + typeText(win, "bobby") | ||
| 239 | + key(win, tcell.KeyLeft) | ||
| 240 | + key(win, tcell.KeyLeft) | ||
| 241 | + key(win, tcell.KeyDelete) | ||
| 242 | + typeText(win, "-") | ||
| 243 | + key(win, tcell.KeyEnter) | ||
| 244 | + if c, _, _ := flow.Result(); c.Name != "bob-y" { | ||
| 245 | + t.Errorf("name %q, want bob-y", c.Name) | ||
| 246 | + } | ||
| 247 | +} | ||