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

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

Download ZIP

🛟 Updated.

k33g committed 2026-10-26T19:53:51+01:00 Browse files
7c50b59 parent: 2f57ae2
modified .tickets/issues/0165-how-to-unload-models-when-using-decision-models.yaml +2 -2
@@ -6,10 +6,10 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-26T05:00:19.639Z
9-updatedAt: 2026-10-26T14:41:47.750Z
9+updatedAt: 2026-10-26T19:50:00.000Z
1010 labels:
1111 - decision models
12-milestone: v1.0.3
12+milestone: v1.0.4
1313 epic: Decision models
1414 body: ""
1515 tasks: []
@@ -6,10 +6,10 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-26T05:00:19.639Z8 createdAt: 2026-10-26T05:00:19.639Z
9-updatedAt: 2026-10-26T14:41:47.750Z9+updatedAt: 2026-10-26T19:50:00.000Z
10 labels:10 labels:
11 - decision models11 - decision models
12-milestone: v1.0.312+milestone: v1.0.4
13 epic: Decision models13 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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-26T05:01:03.034Z
9-updatedAt: 2026-10-26T14:42:01.348Z
9+updatedAt: 2026-10-26T19:50:00.000Z
1010 labels:
1111 - decision models
12-milestone: v1.0.3
12+milestone: v1.0.4
1313 epic: Decision models
1414 body: ""
1515 tasks: []
@@ -6,10 +6,10 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-26T05:01:03.034Z8 createdAt: 2026-10-26T05:01:03.034Z
9-updatedAt: 2026-10-26T14:42:01.348Z9+updatedAt: 2026-10-26T19:50:00.000Z
10 labels:10 labels:
11 - decision models11 - decision models
12-milestone: v1.0.312+milestone: v1.0.4
13 epic: Decision models13 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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-26T13:50:22.583Z
9-updatedAt: 2026-10-26T14:40:39.691Z
9+updatedAt: 2026-10-26T19:45:00.000Z
1010 labels:
1111 - kind::bug
1212 - To be tested
13-milestone: v1.0.2
13+milestone: v1.0.3
1414 body: |
1515 I generated the `agent.yaml` file with this command: `mm init`
1616 the result is:
@@ -6,11 +6,11 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-26T13:50:22.583Z8 createdAt: 2026-10-26T13:50:22.583Z
9-updatedAt: 2026-10-26T14:40:39.691Z9+updatedAt: 2026-10-26T19:45:00.000Z
10 labels:10 labels:
11 - kind::bug11 - kind::bug
12 - To be tested12 - To be tested
13-milestone: v1.0.213+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:
4747 - title: v1.0.3
4848 state: open
4949 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.347 - title: v1.0.3
48 state: open48 state: open
49 dueOn: 2026-10-3149 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
1919 # The model. Chosen for it (family openai): sampling (temperature,
2020 # max_completion_tokens). Change the model and redo them together — running
2121 # mm init again is the quick way.
22-model: "gpt-5.4"
22+model: "gpt-4.1"
2323 # No baseUrl: openai's defaults apply. For the record:
2424 # baseUrl: "https://api.openai.com/v1"
2525 # (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.
2929
3030 # 0 = unknown: the generic openai provider cannot ask the server for its
3131 # window, so compression counts messages (context.maxMessages) instead — set
@@ -62,6 +62,10 @@ sampling:
6262 # one tool call per reply: small models run a check before the change it
6363 # checks has landed otherwise
6464 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
6569
6670 context:
6771 # 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 — running20 # 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 the26+# 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 its30 # 0 = unknown: the generic openai provider cannot ask the server for its
31 # window, so compression counts messages (context.maxMessages) instead — set31 # 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 it62 # one tool call per reply: small models run a check before the change it
63 # checks has landed otherwise63 # checks has landed otherwise
64 parallel_tool_calls: false64 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
5151 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
5252 ```
5353
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.
5555
5656 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.
5757
@@ -79,6 +79,7 @@ This guide shows how to write a new agent.yaml for a model and a job, with mm's
7979 ```
8080
8181 - **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".
8283 - **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.
8384 - **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.
8485 - **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 quit51 ↑↓ 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
2929 🤖 The command printed: `hello-from-openai-provider`
3030 ```
3131
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)).
3333
3434 ## Variants
3535
3636 - **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.
3738 - **The key is refused.** The start-up warning says `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`.
3839 - **Nothing answers.** The warning says `openai: nothing answers at <baseUrl> — check baseUrl: …`: the address must be the OpenAI-compatible one, usually ending in `/v1`.
3940 - **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 ## Variants34 ## 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
192192 | Flag | Question when absent | Default |
193193 |------|----------------------|---------|
194194 | `--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 |
197198 | `--family` | Model family | detected from the model's name, then from the server's family name |
198199 | `--usage` | What the agent is for: `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
199200 | `--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
206207
207208 | Aspect | Behaviour |
208209 |--------|-----------|
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. |
210211 | 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. |
211212 | 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`. |
212213 | 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
5151 ↑↓ choose · Enter next · Esc back · PgUp/PgDn preview · Ctrl-C quit
5252 ```
5353
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.
5555
5656 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.
5757
@@ -79,6 +79,7 @@ Ce guide montre comment écrire un nouvel agent.yaml pour un modèle et une tâc
7979 ```
8080
8181 - **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 ».
8283 - **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.
8384 - **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.
8485 - **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 quit51 ↑↓ 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
2929 🤖 The command printed: `hello-from-openai-provider`
3030 ```
3131
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)).
3333
3434 ## Variantes
3535
3636 - **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.
3738 - **La clé est refusée.** L'avertissement de démarrage indique `openai: <baseUrl> refused the key (HTTP 401) — export OPENAI_API_KEY`.
3839 - **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`.
3940 - **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 ## Variantes34 ## 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é
192192 | Option | Question quand elle est absente | Défaut |
193193 |--------|---------------------------------|--------|
194194 | `--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 |
197198 | `--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 |
198199 | `--usage` | À quoi sert l'agent : `developer`, `reviewer`, `translator`, `writer`, `assistant`, `researcher`, `docs`, `json`, `coordinator` | `developer` |
199200 | `--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é
206207
207208 | Aspect | Comportement |
208209 |--------|--------------|
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. |
210211 | 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. |
211212 | 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`. |
212213 | 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
109109 | `Échap` | Revient d'une question en arrière, en reprenant sa réponse. |
110110 | `PgUp` `PgDn` | Font défiler l'aperçu. |
111111 | `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. |
113114 | `Ctrl-C` | Quitte sans rien écrire. |
114115
115116 ## 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 à savoir116 ## 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
109109 | `Esc` | Go back one question, taking its answer back. |
110110 | `PgUp` `PgDn` | Scroll the preview. |
111111 | `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. |
113114 | `Ctrl-C` | Quit without writing anything. |
114115
115116 ## 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 know116 ## Good to know
modified internal/agentinit/decide.go +10 -7
@@ -7,14 +7,17 @@ import (
77
88 // Choices are the wizard's answers.
99 type Choices struct {
10- Provider string // dmr, llmman, ollama, llamacpp
10+ Provider string // llmman, ollama, dmr, llamacpp, openai
1111 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
1821 }
1922
2023 // 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, llamacpp10+ Provider string // llmman, ollama, dmr, llamacpp, openai
11 BaseURL string // "" = the provider's default11 BaseURL string // "" = the provider's default
12- Model string12+ // APIKeyEnv names the variable holding the API key of an
13- Usage string13+ // OpenAI-compatible service (provider openai); "" = OPENAI_API_KEY.
14- Machine string14+ APIKeyEnv string
15- Family string // "" = detected from the model's name15+ Model string
16- Name string // acp.name16+ Usage string
17- Yolo *bool // nil = the usage's default17+ 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 that23 // 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
8989 }
9090
9191 // 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.
9394 //
9495 // names, err := agentinit.Models(ctx, "llmman", "http://127.0.0.1:17434/v1")
9596 func Models(ctx context.Context, provider, base string) ([]string, error) {
97+ key := ""
9698 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
98124 }
99125 var names []string
100126 switch provider {
@@ -104,7 +130,7 @@ func Models(ctx context.Context, provider, base string) ([]string, error) {
104130 Name string `json:"name"`
105131 } `json:"models"`
106132 }
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 {
108134 return nil, err
109135 }
110136 for _, m := range tags.Models {
@@ -116,7 +142,7 @@ func Models(ctx context.Context, provider, base string) ([]string, error) {
116142 ID string `json:"id"`
117143 } `json:"data"`
118144 }
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 {
120146 return nil, err
121147 }
122148 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 []string125 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, err134 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, err146 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
33 import (
44 "context"
55 "fmt"
6+ "os"
7+ "regexp"
68 "strings"
79
810 "mm/internal/elicit"
11+ "mm/internal/engine"
912 )
1013
1114 // Option is one choice of a step: the value stored, the label shown, and a
@@ -19,7 +22,7 @@ type Option struct {
1922 // Step is one question of the wizard, ready to show: the line mode prints
2023 // it, the window draws it (internal/tui).
2124 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
2326 Title string // the short name in the window's list of steps
2427 Prompt string // the question
2528 Kind elicit.Kind
@@ -29,14 +32,24 @@ type Step struct {
2932 Given bool // answered by a flag: shown, never asked
3033 }
3134
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"}
3440
3541 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",
3844 }
3945
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+
4053 // Flow is the wizard's state, apart from any way of showing it: the answers
4154 // so far, what the server said, and which answers came from flags. The line
4255 // mode (wizard.go) and the window (internal/tui) both drive one.
@@ -55,7 +68,7 @@ type Flow struct {
5568 base string // the resolved server address, for c.Provider
5669 models []string
5770 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
5972 facts Facts
6073 factsFor string // the model the facts are about
6174 reachable map[string]bool
@@ -64,7 +77,7 @@ type Flow struct {
6477 // NewFlow starts a flow; what the flags gave is marked given and never asked.
6578 func NewFlow(ctx context.Context, p Presets, given Choices, output string) *Flow {
6679 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} {
6881 if value != "" {
6982 f.given[key], f.answer[key] = true, value
7083 }
@@ -90,6 +103,9 @@ func (f *Flow) Steps() []Step {
90103 if key == "yolo" && !f.asksYolo() {
91104 continue
92105 }
106+ if (key == "endpoint" || key == "keyenv") && f.c.Provider != "openai" {
107+ continue
108+ }
93109 out = append(out, f.Step(key))
94110 }
95111 return out
@@ -143,11 +159,15 @@ func (f *Flow) Step(key string) Step {
143159 st.Options = append(st.Options, Option{Value: name, Label: name, Detail: detail})
144160 }
145161 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
146166 case "model":
147167 st.Prompt = "Model?"
148168 models := f.modelList()
149169 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()
151171 break
152172 }
153173 for _, m := range models {
@@ -205,7 +225,32 @@ func (f *Flow) Set(key, value string) error {
205225 f.base = ""
206226 f.forget("model")
207227 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)
208248 }
249+ f.c.APIKeyEnv = ""
250+ if value != defaultKeyEnv {
251+ f.c.APIKeyEnv = value
252+ }
253+ f.forget("model")
209254 case "model":
210255 if value != f.c.Model {
211256 f.c.Model = value
@@ -248,6 +293,10 @@ func (f *Flow) Unset(key string) {
248293 }
249294 delete(f.answer, key)
250295 switch key {
296+ case "endpoint":
297+ f.c.BaseURL, f.base = "", ""
298+ case "keyenv":
299+ f.c.APIKeyEnv = ""
251300 case "family":
252301 f.c.Family = ""
253302 case "usage":
@@ -272,6 +321,45 @@ func (f *Flow) forget(key string) {
272321 }
273322 }
274323
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+
275363 // Preview is the file as it would be written now — with candidate as the
276364 // answer to key, when given, so the window can show the highlighted choice
277365 // before it is chosen. Unanswered questions take their defaults.
@@ -314,24 +402,35 @@ func (f *Flow) Base() string {
314402 return f.base
315403 }
316404
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-
323405 func (f *Flow) modelList() []string {
324406 if f.c.Provider == "" {
325407 return nil
326408 }
327409 key := f.c.Provider + " " + f.Base()
410+ if f.c.Provider == "openai" {
411+ key += " " + f.keyEnv()
412+ }
328413 if f.listed != key {
329- f.models, f.modelsErr = Models(f.ctx, f.c.Provider, f.Base())
414+ f.models, f.modelsErr = f.listModels()
330415 f.listed = key
331416 }
332417 return f.models
333418 }
334419
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+
335434 func (f *Flow) modelFacts() Facts {
336435 if f.c.Model == "" || f.c.Provider == "" {
337436 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 a14 // 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 prints22 // 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, file25+ Key string // provider, endpoint, keyenv, model, family, usage, machine, yolo, name, file
23 Title string // the short name in the window's list of steps26 Title string // the short name in the window's list of steps
24 Prompt string // the question27 Prompt string // the question
25 Kind elicit.Kind28 Kind elicit.Kind
@@ -29,14 +32,24 @@ type Step struct {
29 Given bool // answered by a flag: shown, never asked32 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 answers53 // 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 line54 // 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.Provider68 base string // the resolved server address, for c.Provider
56 models []string69 models []string
57 modelsErr error70 modelsErr error
58- listed string // the provider+base the models were listed for71+ listed string // the provider+base(+key variable) the models were listed for
59 facts Facts72 facts Facts
60 factsFor string // the model the facts are about73 factsFor string // the model the facts are about
61 reachable map[string]bool74 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, value82 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 continue104 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 out111 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 break171 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 = value256 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 the363 // 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 choice364 // 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.base402 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 nil407 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 = key415 f.listed = key
331 }416 }
332 return f.models417 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 {
3333 writeDefaultURLs(&b, c.Provider)
3434 }
3535 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)
4037 }
4138 b.WriteString("\n")
4239
@@ -59,6 +56,9 @@ func Render(s Settings) string {
5956 b.WriteString(" chat_template_kwargs:\n")
6057 writeSettings(&b, " ", s.Kwargs)
6158 }
59+ if c.Provider == "openai" {
60+ b.WriteString(reasoningEffortHint)
61+ }
6262 b.WriteString("\n")
6363
6464 b.WriteString("context:\n")
@@ -210,6 +210,32 @@ func coupledTo(s Settings) string {
210210 return text + ". Change the model and redo them together — running mm init again is the quick way."
211211 }
212212
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+
213239 // writeDefaultURLs records, as comments, the addresses a file without
214240 // baseUrl relies on: the provider's defaults may change, and the file then
215241 // 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 without239 // writeDefaultURLs records, as comments, the addresses a file without
214 // baseUrl relies on: the provider's defaults may change, and the file then240 // 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 the241 // 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
5656 var yolo, output string
5757 var check, yes, line bool
5858 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)")
6061 fs.StringVar(&c.Model, "model", "", "the model, as the server names it")
6162 fs.StringVar(&c.Family, "family", "", "the model family (default: detected from the name)")
6263 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 {
197198 return nil
198199 }
199200 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.
205203 answer := w.ask(st)
206204 if answer == "" {
207205 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 string56 var yolo, output string
57 var check, yes, line bool57 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 nil198 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) {
3434 type initWin struct {
3535 screen tcell.Screen
3636 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?"
4343 problem string
4444 result int // 0 running, 1 write, -1 quit
4545 }
@@ -49,7 +49,7 @@ type initWin struct {
4949 const previewMinWidth = 100
5050
5151 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{}}
5353 w.toFirstPending()
5454 for w.result == 0 {
5555 w.draw()
@@ -104,7 +104,7 @@ func (w *initWin) initSelection(st agentinit.Step) {
104104 }
105105 if st.Kind == elicit.Text {
106106 if _, ok := w.text[st.Key]; !ok {
107- w.text[st.Key] = []rune(want)
107+ w.text[st.Key] = newField(want)
108108 }
109109 }
110110 }
@@ -112,7 +112,7 @@ func (w *initWin) initSelection(st agentinit.Step) {
112112 // candidate is what the step would be answered with now.
113113 func (w *initWin) candidate(st agentinit.Step) string {
114114 if st.Kind == elicit.Text {
115- return strings.TrimSpace(string(w.text[st.Key]))
115+ return strings.TrimSpace(w.text[st.Key].String())
116116 }
117117 if len(st.Options) == 0 {
118118 return ""
@@ -170,19 +170,67 @@ func (w *initWin) handleKey(ev *tcell.EventKey) {
170170 }
171171
172172 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 {
174207 switch ev.Key() {
175208 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++
177211 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--
180215 }
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)
181228 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
185232 }
233+ return true
186234 }
187235
188236 // answer records the highlighted choice and moves to the next question.
@@ -260,7 +308,7 @@ func (w *initWin) drawStatus(p painter) {
260308 case w.confirm:
261309 keys = " Enter write the file · Esc back · PgUp/PgDn preview · Ctrl-C quit "
262310 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 "
264312 }
265313 if p.width() < previewMinWidth {
266314 keys = strings.Replace(keys, " · Ctrl-C", " · Tab preview · Ctrl-C", 1)
@@ -321,8 +369,13 @@ func (w *initWin) drawQuestion(p painter) {
321369 st := w.current()
322370 p.textLimited(1, 0, p.width()-2, st.Prompt, stLabel)
323371 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)
326379 p.showCursor(x, 2)
327380 if st.Default != "" {
328381 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.Screen35 screen tcell.Screen
36 flow *agentinit.Flow36 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 step38+ sel map[string]int // the highlighted option, per step
39- text map[string][]rune39+ text map[string]*field // what is typed for a Text step, with its caret
40- scroll int // preview lines hidden above40+ 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 string43 problem string
44 result int // 0 running, 1 write, -1 quit44 result int // 0 running, 1 write, -1 quit
45 }45 }
@@ -49,7 +49,7 @@ type initWin struct {
49 const previewMinWidth = 10049 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] = nil229+ 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
4343 }
4444 screen.SetSize(w, h)
4545 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{}}
4747 win.toFirstPending()
4848 return win, screen
4949 }
@@ -188,3 +188,60 @@ func TestInitWindowListsModelsByShortName(t *testing.T) {
188188 t.Errorf("the answer is %q, want the full identifier", got)
189189 }
190190 }
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, screen48 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+}