Merge pull request #115 from bnema/integrate-upstream-prs
feat: integrate upstream improvements
30 files · +610 -79
M README.md +19 -4
| @@ -145,12 +145,24 @@ $ZUT_HOME/ | |||
| 145 | 145 | └── logs/ # app log files | |
| 146 | 146 | ``` | |
| 147 | 147 | ||
| 148 | - | Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity. | |
| 148 | + | Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity and docs guidance for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity. | |
| 149 | 149 | ||
| 150 | 150 | Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZUT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing. | |
| 151 | 151 | ||
| 152 | 152 | A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules. | |
| 153 | 153 | ||
| 154 | + | ### HTTP proxy | |
| 155 | + | ||
| 156 | + | To route zut-managed HTTP and HTTPS requests through one proxy, add `http_proxy` to `$ZUT_HOME/config.json`: | |
| 157 | + | ||
| 158 | + | ```json | |
| 159 | + | { | |
| 160 | + | "http_proxy": "http://127.0.0.1:7890" | |
| 161 | + | } | |
| 162 | + | ``` | |
| 163 | + | ||
| 164 | + | The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zut after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store. | |
| 165 | + | ||
| 154 | 166 | ## Persistent instructions (AGENTS.md) | |
| 155 | 167 | ||
| 156 | 168 | Use `AGENTS.md` to give zut standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does. | |
| @@ -174,9 +186,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o | |||
| 174 | 186 | |---|---|---| | |
| 175 | 187 | | `$ZUT_HOME/AGENTS.md` | global, every run | appended to the default prompt | | |
| 176 | 188 | | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt | | |
| 177 | - | | `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain | | |
| 189 | + | | `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity and docs guidance; append addenda remain | | |
| 178 | 190 | | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) | | |
| 179 | - | | `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain | | |
| 191 | + | | `--system-prompt <text>` | single run | replaces the built-in identity and docs guidance for one invocation; append addenda remain | | |
| 180 | 192 | ||
| 181 | 193 | > **Note:** zut does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zut will use it. | |
| 182 | 194 | ||
| @@ -190,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n | |||
| 190 | 202 | zut # interactive tui | |
| 191 | 203 | zut "fix the failing test" # tui, pre-filled prompt | |
| 192 | 204 | zut -p "list all go files" # print final text, exit | |
| 205 | + | cat README.md | zut "summarize this text" # pipe input and infer print mode | |
| 193 | 206 | zut -p --stats stats.json "task" # print final text and write generation stats | |
| 194 | 207 | zut --json "refactor main.go" # newline-delimited json events, exit | |
| 195 | 208 | zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize" | |
| @@ -228,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 228 | 241 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | |
| 229 | 242 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | |
| 230 | 243 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | |
| 244 | + | | `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. | | |
| 231 | 245 | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | |
| 232 | 246 | ||
| 233 | 247 | ## Tools | |
| @@ -248,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||
| 248 | 262 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | |
| 249 | 263 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | |
| 250 | 264 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | |
| 265 | + | - **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`. | |
| 251 | 266 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | |
| 252 | 267 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |
| 253 | 268 | ||
| @@ -518,7 +533,7 @@ Place a `models.json` in `$ZUT_HOME` (`$XDG_STATE_HOME/zut/` when set, otherwise | |||
| 518 | 533 | ||
| 519 | 534 | Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`. | |
| 520 | 535 | ||
| 521 | - | `reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests. | |
| 536 | + | `reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, use an identity mapping such as `"max": "max"` to enable a level beyond the protocol default, or map it to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests. | |
| 522 | 537 | ||
| 523 | 538 | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly. | |
| 524 | 539 | ||
| @@ -145,12 +145,24 @@ $ZUT_HOME/ | |||||
| 145 | └── logs/ # app log files | 145 | └── logs/ # app log files | ||
| 146 | ``` | 146 | ``` | ||
| 147 | 147 | ||||
| 148 | - | Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity. | 148 | + | Drop a `SYSTEM.md` in `$ZUT_HOME` to replace the built-in identity and docs guidance for every run. Existing append addenda, including `AGENTS.md`, skills, and enabled Ponytail coding mode, remain layered on top. `--system-prompt` wins per invocation unless a selected replace-mode subagent profile supplies the child's identity. Delete the file to revert to the default identity. |
| 149 | 149 | ||||
| 150 | Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZUT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing. | 150 | Ponytail coding mode is enabled by default, including when an existing `config.json` does not contain a `ponytail_enabled` field. When disabled, its compact guidance is omitted from resolved system prompts; when enabled, the guidance tells the model to apply itself only to engineering work. The setting persists its explicit on/off choice in `$ZUT_HOME/config.json` and can be changed from `/settings`. It is advisory guidance only: it does not change tool permissions, confirmations, jail behavior, or sandboxing. | ||
| 151 | 151 | ||||
| 152 | A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules. | 152 | A selected subagent profile with `systemPromptMode: replace` supplies the child's base identity even when `--system-prompt` was provided for the parent run. Global append addenda, including enabled Ponytail guidance, still follow their normal inclusion rules. | ||
| 153 | 153 | ||||
| 154 | + | ### HTTP proxy | |||
| 155 | + | ||||
| 156 | + | To route zut-managed HTTP and HTTPS requests through one proxy, add `http_proxy` to `$ZUT_HOME/config.json`: | |||
| 157 | + | ||||
| 158 | + | ```json | |||
| 159 | + | { | |||
| 160 | + | "http_proxy": "http://127.0.0.1:7890" | |||
| 161 | + | } | |||
| 162 | + | ``` | |||
| 163 | + | ||||
| 164 | + | The setting is applied at startup to both HTTP and HTTPS traffic. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. `NO_PROXY` and `no_proxy` continue to control bypasses. Restart zut after changing the config file. If the URL contains proxy credentials, prefer protected environment variables because `config.json` is not a credential store. | |||
| 165 | + | ||||
| 154 | ## Persistent instructions (AGENTS.md) | 166 | ## Persistent instructions (AGENTS.md) | ||
| 155 | 167 | ||||
| 156 | Use `AGENTS.md` to give zut standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does. | 168 | Use `AGENTS.md` to give zut standing instructions that layer **on top of** the default system prompt, without replacing it. This is the friendliest way to shape behavior (for example, taming local models that jump straight to code edits) because it adds guidance rather than taking over the base identity the way `SYSTEM.md` does. | ||
| @@ -174,9 +186,9 @@ Treat questions and discussions as requests for explanation. Do not edit files o | |||||
| 174 | |---|---|---| | 186 | |---|---|---| | ||
| 175 | | `$ZUT_HOME/AGENTS.md` | global, every run | appended to the default prompt | | 187 | | `$ZUT_HOME/AGENTS.md` | global, every run | appended to the default prompt | | ||
| 176 | | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt | | 188 | | `./AGENTS.md` (and parent dirs) | project | appended to the default prompt | | ||
| 177 | - | | `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity; append addenda remain | | 189 | + | | `$ZUT_HOME/SYSTEM.md` | global, every run | replaces the built-in identity and docs guidance; append addenda remain | |
| 178 | | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) | | 190 | | `--append-system-prompt <text>` | single run | appends for one invocation (repeatable) | | ||
| 179 | - | | `--system-prompt <text>` | single run | replaces the built-in identity for one invocation; append addenda remain | | 191 | + | | `--system-prompt <text>` | single run | replaces the built-in identity and docs guidance for one invocation; append addenda remain | |
| 180 | 192 | ||||
| 181 | > **Note:** zut does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zut will use it. | 193 | > **Note:** zut does not read a `CLAUDE.md` instruction file. The only Claude-compatible thing it picks up is skills under `.claude/skills/`. If you are migrating from Claude Code, move that content into `AGENTS.md` (global or per-project) and zut will use it. | ||
| 182 | 194 | ||||
| @@ -190,6 +202,7 @@ The first time you launch a newer zut binary, the TUI shows the GitHub release n | |||||
| 190 | zut # interactive tui | 202 | zut # interactive tui | ||
| 191 | zut "fix the failing test" # tui, pre-filled prompt | 203 | zut "fix the failing test" # tui, pre-filled prompt | ||
| 192 | zut -p "list all go files" # print final text, exit | 204 | zut -p "list all go files" # print final text, exit | ||
| 205 | + | cat README.md | zut "summarize this text" # pipe input and infer print mode | |||
| 193 | zut -p --stats stats.json "task" # print final text and write generation stats | 206 | zut -p --stats stats.json "task" # print final text and write generation stats | ||
| 194 | zut --json "refactor main.go" # newline-delimited json events, exit | 207 | zut --json "refactor main.go" # newline-delimited json events, exit | ||
| 195 | zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize" | 208 | zut -p --orchestrate --provider openai --model gpt-5 "implement and synthesize" | ||
| @@ -228,6 +241,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 228 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | 241 | | `-e`, `--ext <path>` | Load an extension from `<path>` for this run (repeatable; wins against installed extensions of the same name). | | ||
| 229 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | 242 | | `--no-ext` | Skip extension discovery for this run. `--ext` still works on top, so `--no-ext --ext ./x` runs only `x`. | | ||
| 230 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | 243 | | `--no-skill` | Disable all skills, including built-ins. No `skill` tool is registered and the system prompt has no skill manifest. | | ||
| 244 | + | | `--no-context-files`, `-nc` | Disable discovery and loading of global and project `AGENTS.md` files. | | |||
| 231 | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | 245 | | `--no-yolo` | Confirm every tool call before it runs (interactive TUI only). A dialog shows the tool name and a one-line preview of its args, while edit calls show the proposed diff in the tool panel, with four choices: yes, yes-always-this-tool-this-session, yes-always-this-session, no. Press `/` while the dialog is focused to run a slash command first; closing its input or child dialog returns to the pending confirmation. Ignored with a stderr warning in print / json / rpc modes, where tools still run freely so scripts and automation keep working. | | ||
| 232 | 246 | ||||
| 233 | ## Tools | 247 | ## Tools | ||
| @@ -248,6 +262,7 @@ When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits r | |||||
| 248 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | 262 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | ||
| 249 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | 263 | - **Print**: `zut -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | ||
| 250 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | 264 | - **Stream**: `zut --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | ||
| 265 | + | - **Piped input**: stdin implies print mode when no explicit mode is selected. Print, stream, and JSON modes prepend piped input to the positional prompt, separated by a newline. For example, `cat README.md | zut "summarize this text"`. | |||
| 251 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | 266 | - **JSON**: `zut --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | ||
| 252 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | 267 | - **RPC**: `zut rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zut in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | ||
| 253 | 268 | ||||
| @@ -518,7 +533,7 @@ Place a `models.json` in `$ZUT_HOME` (`$XDG_STATE_HOME/zut/` when set, otherwise | |||||
| 518 | 533 | ||||
| 519 | Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`. | 534 | Supported fields per model: `id` (required), `name`, `reasoning`, `reasoningLevelMap`, `contextWindow`, `maxTokens`, `baseUrl`, `priceInput`, `priceOutput`, `priceCacheRead`, `priceCacheWrite`. | ||
| 520 | 535 | ||||
| 521 | - | `reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, or to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests. | 536 | + | `reasoningLevelMap` is optional. Protocol defaults apply when it is omitted. Add only model-specific exceptions using `minimum`, `low`, `medium`, `high`, `xhigh`, or `max` as keys. Map a key to another level when both inputs are equivalent, use an identity mapping such as `"max": "max"` to enable a level beyond the protocol default, or map it to an empty string or `off` to remove it. The same effective mapping drives `/reasoning` and provider requests. |
| 522 | 537 | ||||
| 523 | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly. | 538 | Provider keys are normalized: `openai-codex` and `openai-responses` map to `openai`, `anthropic-messages` maps to `anthropic`, `moonshot`, `moonshot-ai`, and `kimi-code` map to `kimi`, and `deepseek-chat` and `deepseek-ai` map to `deepseek`. Built-in provider ids such as `groq`, `openrouter`, `github-copilot`, `amazon-bedrock`, `google-vertex`, `azure-openai-responses`, `fireworks`, `vercel-ai-gateway`, `mistral`, and `xai` can also be used directly. | ||
| 524 | 539 | ||||
M docs/providers.md +14 -0
| @@ -4,6 +4,20 @@ zut ships with built-in providers and a model catalog. You can select models | |||
| 4 | 4 | with `/model`, list them with `zut --list-models`, and add private models in | |
| 5 | 5 | `$ZUT_HOME/models.json`. | |
| 6 | 6 | ||
| 7 | + | ## HTTP proxy | |
| 8 | + | ||
| 9 | + | Set one global proxy for zut-managed HTTP and HTTPS traffic with the `http_proxy` key in `$ZUT_HOME/config.json`: | |
| 10 | + | ||
| 11 | + | ```json | |
| 12 | + | { | |
| 13 | + | "http_proxy": "http://127.0.0.1:7890" | |
| 14 | + | } | |
| 15 | + | ``` | |
| 16 | + | ||
| 17 | + | zut applies this setting at startup. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. Standard `NO_PROXY` and `no_proxy` bypass lists remain effective. Restart zut after changing the setting. | |
| 18 | + | ||
| 19 | + | `config.json` is not a credential store. If the proxy URL contains a username or password, prefer setting the proxy environment variables in a protected shell or service configuration rather than saving those credentials in `config.json`. | |
| 20 | + | ||
| 7 | 21 | ## Login methods | |
| 8 | 22 | ||
| 9 | 23 | Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name. | |
| @@ -4,6 +4,20 @@ zut ships with built-in providers and a model catalog. You can select models | |||||
| 4 | with `/model`, list them with `zut --list-models`, and add private models in | 4 | with `/model`, list them with `zut --list-models`, and add private models in | ||
| 5 | `$ZUT_HOME/models.json`. | 5 | `$ZUT_HOME/models.json`. | ||
| 6 | 6 | ||||
| 7 | + | ## HTTP proxy | |||
| 8 | + | ||||
| 9 | + | Set one global proxy for zut-managed HTTP and HTTPS traffic with the `http_proxy` key in `$ZUT_HOME/config.json`: | |||
| 10 | + | ||||
| 11 | + | ```json | |||
| 12 | + | { | |||
| 13 | + | "http_proxy": "http://127.0.0.1:7890" | |||
| 14 | + | } | |||
| 15 | + | ``` | |||
| 16 | + | ||||
| 17 | + | zut applies this setting at startup. Existing `HTTP_PROXY`, `HTTPS_PROXY`, `http_proxy`, and `https_proxy` environment variables take precedence for their corresponding protocol. Standard `NO_PROXY` and `no_proxy` bypass lists remain effective. Restart zut after changing the setting. | |||
| 18 | + | ||||
| 19 | + | `config.json` is not a credential store. If the proxy URL contains a username or password, prefer setting the proxy environment variables in a protected shell or service configuration rather than saving those credentials in `config.json`. | |||
| 20 | + | ||||
| 7 | ## Login methods | 21 | ## Login methods | ||
| 8 | 22 | ||||
| 9 | Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name. | 23 | Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name. | ||
M docs/rpc.md +2 -2
| @@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom | |||
| 23 | 23 | ||
| 24 | 24 | ## Flags | |
| 25 | 25 | ||
| 26 | - | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | |
| 26 | + | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | |
| 27 | 27 | ||
| 28 | - | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present. | |
| 28 | + | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present. | |
| 29 | 29 | ||
| 30 | 30 | ## Auth | |
| 31 | 31 | ||
| @@ -23,9 +23,9 @@ You'll see one JSON object per line on stdout: a response acknowledging the prom | |||||
| 23 | 23 | ||||
| 24 | ## Flags | 24 | ## Flags | ||
| 25 | 25 | ||||
| 26 | - | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. | 26 | + | `zut rpc` accepts the same flags as the other modes: `--provider`, `--model`, `--cwd`, `--api-key`, `--base-url`, `--system-prompt`, `--append-system-prompt`, `--reasoning`, `--max-steps`, `--no-tools`, `--tools`, `--no-context-files`. Sessions are disabled by default in RPC mode — the embedding application owns persistence. |
| 27 | 27 | ||||
| 28 | - | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity, while enabled append addenda remain present. | 28 | + | RPC agents use the same resolved system-prompt policy as other modes. Ponytail coding mode is enabled when `ponytail_enabled` is missing or `true` in `$ZUT_HOME/config.json`, and disabled when it is `false`. A custom `--system-prompt` replaces the built-in identity and docs guidance, while enabled append addenda remain present. |
| 29 | 29 | ||||
| 30 | ## Auth | 30 | ## Auth | ||
| 31 | 31 | ||||
M go.mod +1 -0
| @@ -9,6 +9,7 @@ require ( | |||
| 9 | 9 | github.com/mattn/go-runewidth v0.0.16 | |
| 10 | 10 | github.com/sahilm/fuzzy v0.1.1 | |
| 11 | 11 | golang.org/x/image v0.43.0 | |
| 12 | + | golang.org/x/mod v0.35.0 | |
| 12 | 13 | golang.org/x/net v0.57.0 | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -9,6 +9,7 @@ require ( | |||||
| 9 | github.com/mattn/go-runewidth v0.0.16 | 9 | github.com/mattn/go-runewidth v0.0.16 | ||
| 10 | github.com/sahilm/fuzzy v0.1.1 | 10 | github.com/sahilm/fuzzy v0.1.1 | ||
| 11 | golang.org/x/image v0.43.0 | 11 | golang.org/x/image v0.43.0 | ||
| 12 | + | golang.org/x/mod v0.35.0 | |||
| 12 | golang.org/x/net v0.57.0 | 13 | golang.org/x/net v0.57.0 | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
M go.sum +2 -0
| @@ -22,6 +22,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= | |||
| 22 | 22 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= | |
| 23 | 23 | golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= | |
| 24 | 24 | golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= | |
| 25 | + | golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= | |
| 26 | + | golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= | |
| 25 | 27 | golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= | |
| 26 | 28 | golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= | |
| 27 | 29 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= | |
| @@ -22,6 +22,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= | |||||
| 22 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= | 22 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= | ||
| 23 | golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= | 23 | golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= | ||
| 24 | golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= | 24 | golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= | ||
| 25 | + | golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= | |||
| 26 | + | golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= | |||
| 25 | golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= | 27 | golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= | ||
| 26 | golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= | 28 | golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= | ||
| 27 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= | 29 | golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= | ||
M packages/agent/args.go +7 -0
| @@ -106,6 +106,10 @@ type Args struct { | |||
| 106 | 106 | // skill discovery, including built-ins. | |
| 107 | 107 | WithSkills bool | |
| 108 | 108 | ||
| 109 | + | // NoContextFiles disables discovery and loading of AGENTS.md files | |
| 110 | + | // from $ZUT_HOME and the cwd's ancestor directories. | |
| 111 | + | NoContextFiles bool | |
| 112 | + | ||
| 109 | 113 | // InsecureTLS skips TLS verification for custom inference endpoints. | |
| 110 | 114 | InsecureTLS bool | |
| 111 | 115 | ||
| @@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 275 | 279 | a.NoExt = true | |
| 276 | 280 | case "--no-skill", "--no-skills": | |
| 277 | 281 | a.NoSkill = true | |
| 282 | + | case "--no-context-files", "-nc": | |
| 283 | + | a.NoContextFiles = true | |
| 278 | 284 | case "--with-skills", "--with-skill": | |
| 279 | 285 | // Deprecated no-op: user skills are loaded by default. | |
| 280 | 286 | a.WithSkills = true | |
| @@ -575,6 +581,7 @@ func PrintHelp(version string) { | |||
| 575 | 581 | row{"-y, --yes", "accept zut run consent without prompting"}, | |
| 576 | 582 | row{"--no-ext", "skip extension discovery for this run"}, | |
| 577 | 583 | row{"--no-skill", "skip all skill discovery for this run"}, | |
| 584 | + | row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"}, | |
| 578 | 585 | ) | |
| 579 | 586 | section("misc", | |
| 580 | 587 | row{"--max-steps N", "agent loop iteration cap (default: unlimited)"}, | |
| @@ -106,6 +106,10 @@ type Args struct { | |||||
| 106 | // skill discovery, including built-ins. | 106 | // skill discovery, including built-ins. | ||
| 107 | WithSkills bool | 107 | WithSkills bool | ||
| 108 | 108 | ||||
| 109 | + | // NoContextFiles disables discovery and loading of AGENTS.md files | |||
| 110 | + | // from $ZUT_HOME and the cwd's ancestor directories. | |||
| 111 | + | NoContextFiles bool | |||
| 112 | + | ||||
| 109 | // InsecureTLS skips TLS verification for custom inference endpoints. | 113 | // InsecureTLS skips TLS verification for custom inference endpoints. | ||
| 110 | InsecureTLS bool | 114 | InsecureTLS bool | ||
| 111 | 115 | ||||
| @@ -275,6 +279,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 275 | a.NoExt = true | 279 | a.NoExt = true | ||
| 276 | case "--no-skill", "--no-skills": | 280 | case "--no-skill", "--no-skills": | ||
| 277 | a.NoSkill = true | 281 | a.NoSkill = true | ||
| 282 | + | case "--no-context-files", "-nc": | |||
| 283 | + | a.NoContextFiles = true | |||
| 278 | case "--with-skills", "--with-skill": | 284 | case "--with-skills", "--with-skill": | ||
| 279 | // Deprecated no-op: user skills are loaded by default. | 285 | // Deprecated no-op: user skills are loaded by default. | ||
| 280 | a.WithSkills = true | 286 | a.WithSkills = true | ||
| @@ -575,6 +581,7 @@ func PrintHelp(version string) { | |||||
| 575 | row{"-y, --yes", "accept zut run consent without prompting"}, | 581 | row{"-y, --yes", "accept zut run consent without prompting"}, | ||
| 576 | row{"--no-ext", "skip extension discovery for this run"}, | 582 | row{"--no-ext", "skip extension discovery for this run"}, | ||
| 577 | row{"--no-skill", "skip all skill discovery for this run"}, | 583 | row{"--no-skill", "skip all skill discovery for this run"}, | ||
| 584 | + | row{"--no-context-files, -nc", "skip AGENTS.md discovery for this run"}, | |||
| 578 | ) | 585 | ) | ||
| 579 | section("misc", | 586 | section("misc", | ||
| 580 | row{"--max-steps N", "agent loop iteration cap (default: unlimited)"}, | 587 | row{"--max-steps N", "agent loop iteration cap (default: unlimited)"}, | ||
M packages/agent/args_test.go +12 -0
| @@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) { | |||
| 188 | 188 | } | |
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | + | func TestParseArgsNoContextFiles(t *testing.T) { | |
| 192 | + | for _, flag := range []string{"--no-context-files", "-nc"} { | |
| 193 | + | args, err := ParseArgs([]string{flag}) | |
| 194 | + | if err != nil { | |
| 195 | + | t.Fatalf("ParseArgs(%q): %v", flag, err) | |
| 196 | + | } | |
| 197 | + | if !args.NoContextFiles { | |
| 198 | + | t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 191 | 203 | func TestParseArgsStream(t *testing.T) { | |
| 192 | 204 | args, err := ParseArgs([]string{"--stream", "hi"}) | |
| 193 | 205 | if err != nil { | |
| @@ -188,6 +188,18 @@ func TestParseArgsStatsRequiresPrintMode(t *testing.T) { | |||||
| 188 | } | 188 | } | ||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | + | func TestParseArgsNoContextFiles(t *testing.T) { | |||
| 192 | + | for _, flag := range []string{"--no-context-files", "-nc"} { | |||
| 193 | + | args, err := ParseArgs([]string{flag}) | |||
| 194 | + | if err != nil { | |||
| 195 | + | t.Fatalf("ParseArgs(%q): %v", flag, err) | |||
| 196 | + | } | |||
| 197 | + | if !args.NoContextFiles { | |||
| 198 | + | t.Fatalf("ParseArgs(%q): NoContextFiles = false", flag) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | ||||
| 191 | func TestParseArgsStream(t *testing.T) { | 203 | func TestParseArgsStream(t *testing.T) { | ||
| 192 | args, err := ParseArgs([]string{"--stream", "hi"}) | 204 | args, err := ParseArgs([]string{"--stream", "hi"}) | ||
| 193 | if err != nil { | 205 | if err != nil { | ||
M packages/agent/build.go +1 -1
| @@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 842 | 842 | summaries := toolSummaries(reg, args) | |
| 843 | 843 | ||
| 844 | 844 | contextFiles := []ContextFile(nil) | |
| 845 | - | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | |
| 845 | + | if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) { | |
| 846 | 846 | contextFiles = loadAgentsContext(args.CWD, ZutHome()) | |
| 847 | 847 | } | |
| 848 | 848 | append_ := []string(nil) | |
| @@ -842,7 +842,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 842 | summaries := toolSummaries(reg, args) | 842 | summaries := toolSummaries(reg, args) | ||
| 843 | 843 | ||||
| 844 | contextFiles := []ContextFile(nil) | 844 | contextFiles := []ContextFile(nil) | ||
| 845 | - | if selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext { | 845 | + | if !args.NoContextFiles && (selectedProfile == nil || selectedProfile.InheritProjectContext == nil || *selectedProfile.InheritProjectContext) { |
| 846 | contextFiles = loadAgentsContext(args.CWD, ZutHome()) | 846 | contextFiles = loadAgentsContext(args.CWD, ZutHome()) | ||
| 847 | } | 847 | } | ||
| 848 | append_ := []string(nil) | 848 | append_ := []string(nil) | ||
M packages/agent/build_test.go +29 -0
| @@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |||
| 708 | 708 | } | |
| 709 | 709 | } | |
| 710 | 710 | ||
| 711 | + | func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) { | |
| 712 | + | zutHome := t.TempDir() | |
| 713 | + | cwd := t.TempDir() | |
| 714 | + | t.Setenv("ZUT_HOME", zutHome) | |
| 715 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |
| 716 | + | if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { | |
| 717 | + | t.Fatal(err) | |
| 718 | + | } | |
| 719 | + | if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil { | |
| 720 | + | t.Fatal(err) | |
| 721 | + | } | |
| 722 | + | ||
| 723 | + | r, err := Resolve(Args{ | |
| 724 | + | Provider: "openai", | |
| 725 | + | Model: "gpt-5", | |
| 726 | + | CWD: cwd, | |
| 727 | + | NoContextFiles: true, | |
| 728 | + | }, false) | |
| 729 | + | if err != nil { | |
| 730 | + | t.Fatal(err) | |
| 731 | + | } | |
| 732 | + | if len(r.ContextFiles) != 0 { | |
| 733 | + | t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles) | |
| 734 | + | } | |
| 735 | + | if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") { | |
| 736 | + | t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt) | |
| 737 | + | } | |
| 738 | + | } | |
| 739 | + | ||
| 711 | 740 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | |
| 712 | 741 | // startup failure caught by the user's screenshot: the persisted | |
| 713 | 742 | // config.json points at a model id that's no longer in the active | |
| @@ -708,6 +708,35 @@ func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |||||
| 708 | } | 708 | } | ||
| 709 | } | 709 | } | ||
| 710 | 710 | ||||
| 711 | + | func TestResolveNoContextFilesSkipsAgentsInstructions(t *testing.T) { | |||
| 712 | + | zutHome := t.TempDir() | |||
| 713 | + | cwd := t.TempDir() | |||
| 714 | + | t.Setenv("ZUT_HOME", zutHome) | |||
| 715 | + | t.Setenv("OPENAI_API_KEY", "test-key") | |||
| 716 | + | if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil { | |||
| 717 | + | t.Fatal(err) | |||
| 718 | + | } | |||
| 719 | + | if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("project rule"), 0o644); err != nil { | |||
| 720 | + | t.Fatal(err) | |||
| 721 | + | } | |||
| 722 | + | ||||
| 723 | + | r, err := Resolve(Args{ | |||
| 724 | + | Provider: "openai", | |||
| 725 | + | Model: "gpt-5", | |||
| 726 | + | CWD: cwd, | |||
| 727 | + | NoContextFiles: true, | |||
| 728 | + | }, false) | |||
| 729 | + | if err != nil { | |||
| 730 | + | t.Fatal(err) | |||
| 731 | + | } | |||
| 732 | + | if len(r.ContextFiles) != 0 { | |||
| 733 | + | t.Fatalf("ContextFiles = %#v, want empty", r.ContextFiles) | |||
| 734 | + | } | |||
| 735 | + | if strings.Contains(r.SystemPrompt, "global rule") || strings.Contains(r.SystemPrompt, "project rule") { | |||
| 736 | + | t.Fatalf("system prompt contains disabled context files:\n%s", r.SystemPrompt) | |||
| 737 | + | } | |||
| 738 | + | } | |||
| 739 | + | ||||
| 711 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | 740 | // TestResolveFallsBackWhenConfiguredModelIsGone reproduces the | ||
| 712 | // startup failure caught by the user's screenshot: the persisted | 741 | // startup failure caught by the user's screenshot: the persisted | ||
| 713 | // config.json points at a model id that's no longer in the active | 742 | // config.json points at a model id that's no longer in the active | ||
M packages/agent/cli.go +31 -15
| @@ -765,6 +765,10 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||
| 765 | 765 | ||
| 766 | 766 | // Run is the top-level entrypoint for the zut binary. | |
| 767 | 767 | func Run(rawArgs []string, version string) error { | |
| 768 | + | // Apply network configuration before any subcommand can make an HTTP | |
| 769 | + | // request. Standard proxy environment variables retain precedence. | |
| 770 | + | applyConfiguredHTTPProxy() | |
| 771 | + | ||
| 768 | 772 | // Extension installs can invoke external build and clone processes. | |
| 769 | 773 | // Give those processes a cancellation context without changing signal | |
| 770 | 774 | // handling for the interactive modes below. | |
| @@ -800,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||
| 800 | 804 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | |
| 801 | 805 | } | |
| 802 | 806 | ||
| 807 | + | if fi, err := os.Stdin.Stat(); err == nil { | |
| 808 | + | rawArgs = argsForStdin(rawArgs, fi.Mode()) | |
| 809 | + | } | |
| 803 | 810 | args, err := ParseArgs(rawArgs) | |
| 804 | 811 | if err != nil { | |
| 805 | 812 | PrintHelp(version) | |
| @@ -1005,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1005 | 1012 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | |
| 1006 | 1013 | } | |
| 1007 | 1014 | ||
| 1008 | - | prompt := args.Prompt | |
| 1009 | - | if prompt == "" { | |
| 1010 | - | piped, _ := readAllStdin() | |
| 1011 | - | prompt = strings.TrimSpace(piped) | |
| 1012 | - | } | |
| 1015 | + | piped, _ := readAllStdin() | |
| 1016 | + | prompt := combinePromptInput(piped, args.Prompt) | |
| 1013 | 1017 | if prompt == "" { | |
| 1014 | 1018 | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | |
| 1015 | 1019 | } | |
| @@ -1090,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||
| 1090 | 1094 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | |
| 1091 | 1095 | } | |
| 1092 | 1096 | ||
| 1093 | - | prompt := args.Prompt | |
| 1094 | - | if prompt == "" { | |
| 1095 | - | piped, _ := readAllStdin() | |
| 1096 | - | prompt = strings.TrimSpace(piped) | |
| 1097 | - | } | |
| 1097 | + | piped, _ := readAllStdin() | |
| 1098 | + | prompt := combinePromptInput(piped, args.Prompt) | |
| 1098 | 1099 | if prompt == "" { | |
| 1099 | 1100 | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | |
| 1100 | 1101 | } | |
| @@ -1214,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||
| 1214 | 1215 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | |
| 1215 | 1216 | } | |
| 1216 | 1217 | ||
| 1217 | - | prompt := args.Prompt | |
| 1218 | - | if prompt == "" { | |
| 1219 | - | piped, _ := readAllStdin() | |
| 1220 | - | prompt = strings.TrimSpace(piped) | |
| 1221 | - | } | |
| 1218 | + | piped, _ := readAllStdin() | |
| 1219 | + | prompt := combinePromptInput(piped, args.Prompt) | |
| 1222 | 1220 | if prompt == "" { | |
| 1223 | 1221 | return fmt.Errorf("json mode requires a prompt (arg or stdin)") | |
| 1224 | 1222 | } | |
| @@ -2887,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex | |||
| 2887 | 2885 | return next, nil | |
| 2888 | 2886 | } | |
| 2889 | 2887 | ||
| 2888 | + | func argsForStdin(rawArgs []string, mode os.FileMode) []string { | |
| 2889 | + | if mode&os.ModeCharDevice != 0 { | |
| 2890 | + | return rawArgs | |
| 2891 | + | } | |
| 2892 | + | return append([]string{"--print"}, rawArgs...) | |
| 2893 | + | } | |
| 2894 | + | ||
| 2895 | + | func combinePromptInput(stdin, positional string) string { | |
| 2896 | + | parts := make([]string, 0, 2) | |
| 2897 | + | if stdin = strings.TrimSpace(stdin); stdin != "" { | |
| 2898 | + | parts = append(parts, stdin) | |
| 2899 | + | } | |
| 2900 | + | if positional = strings.TrimSpace(positional); positional != "" { | |
| 2901 | + | parts = append(parts, positional) | |
| 2902 | + | } | |
| 2903 | + | return strings.Join(parts, "\n") | |
| 2904 | + | } | |
| 2905 | + | ||
| 2890 | 2906 | func readAllStdin() (string, error) { | |
| 2891 | 2907 | fi, err := os.Stdin.Stat() | |
| 2892 | 2908 | if err != nil { | |
| @@ -765,6 +765,10 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |||||
| 765 | 765 | ||||
| 766 | // Run is the top-level entrypoint for the zut binary. | 766 | // Run is the top-level entrypoint for the zut binary. | ||
| 767 | func Run(rawArgs []string, version string) error { | 767 | func Run(rawArgs []string, version string) error { | ||
| 768 | + | // Apply network configuration before any subcommand can make an HTTP | |||
| 769 | + | // request. Standard proxy environment variables retain precedence. | |||
| 770 | + | applyConfiguredHTTPProxy() | |||
| 771 | + | ||||
| 768 | // Extension installs can invoke external build and clone processes. | 772 | // Extension installs can invoke external build and clone processes. | ||
| 769 | // Give those processes a cancellation context without changing signal | 773 | // Give those processes a cancellation context without changing signal | ||
| 770 | // handling for the interactive modes below. | 774 | // handling for the interactive modes below. | ||
| @@ -800,6 +804,9 @@ func runWithArgsRaw(rawArgs []string, version string) error { | |||||
| 800 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | 804 | rawArgs = append([]string{"--rpc"}, rawArgs[1:]...) | ||
| 801 | } | 805 | } | ||
| 802 | 806 | ||||
| 807 | + | if fi, err := os.Stdin.Stat(); err == nil { | |||
| 808 | + | rawArgs = argsForStdin(rawArgs, fi.Mode()) | |||
| 809 | + | } | |||
| 803 | args, err := ParseArgs(rawArgs) | 810 | args, err := ParseArgs(rawArgs) | ||
| 804 | if err != nil { | 811 | if err != nil { | ||
| 805 | PrintHelp(version) | 812 | PrintHelp(version) | ||
| @@ -1005,11 +1012,8 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1005 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | 1012 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | ||
| 1006 | } | 1013 | } | ||
| 1007 | 1014 | ||||
| 1008 | - | prompt := args.Prompt | 1015 | + | piped, _ := readAllStdin() |
| 1009 | - | if prompt == "" { | 1016 | + | prompt := combinePromptInput(piped, args.Prompt) |
| 1010 | - | piped, _ := readAllStdin() | |||
| 1011 | - | prompt = strings.TrimSpace(piped) | |||
| 1012 | - | } | |||
| 1013 | if prompt == "" { | 1017 | if prompt == "" { | ||
| 1014 | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | 1018 | return fmt.Errorf("print mode requires a prompt (arg or stdin)") | ||
| 1015 | } | 1019 | } | ||
| @@ -1090,11 +1094,8 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error | |||||
| 1090 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | 1094 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | ||
| 1091 | } | 1095 | } | ||
| 1092 | 1096 | ||||
| 1093 | - | prompt := args.Prompt | 1097 | + | piped, _ := readAllStdin() |
| 1094 | - | if prompt == "" { | 1098 | + | prompt := combinePromptInput(piped, args.Prompt) |
| 1095 | - | piped, _ := readAllStdin() | |||
| 1096 | - | prompt = strings.TrimSpace(piped) | |||
| 1097 | - | } | |||
| 1098 | if prompt == "" { | 1099 | if prompt == "" { | ||
| 1099 | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | 1100 | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | ||
| 1100 | } | 1101 | } | ||
| @@ -1214,11 +1215,8 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error) | |||||
| 1214 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | 1215 | defer func() { runErr = errors.Join(runErr, sess.FlushRetryLifecycle(ag.LastTurnUsage(), ag.Cost())) }() | ||
| 1215 | } | 1216 | } | ||
| 1216 | 1217 | ||||
| 1217 | - | prompt := args.Prompt | 1218 | + | piped, _ := readAllStdin() |
| 1218 | - | if prompt == "" { | 1219 | + | prompt := combinePromptInput(piped, args.Prompt) |
| 1219 | - | piped, _ := readAllStdin() | |||
| 1220 | - | prompt = strings.TrimSpace(piped) | |||
| 1221 | - | } | |||
| 1222 | if prompt == "" { | 1220 | if prompt == "" { | ||
| 1223 | return fmt.Errorf("json mode requires a prompt (arg or stdin)") | 1221 | return fmt.Errorf("json mode requires a prompt (arg or stdin)") | ||
| 1224 | } | 1222 | } | ||
| @@ -2887,6 +2885,24 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex | |||||
| 2887 | return next, nil | 2885 | return next, nil | ||
| 2888 | } | 2886 | } | ||
| 2889 | 2887 | ||||
| 2888 | + | func argsForStdin(rawArgs []string, mode os.FileMode) []string { | |||
| 2889 | + | if mode&os.ModeCharDevice != 0 { | |||
| 2890 | + | return rawArgs | |||
| 2891 | + | } | |||
| 2892 | + | return append([]string{"--print"}, rawArgs...) | |||
| 2893 | + | } | |||
| 2894 | + | ||||
| 2895 | + | func combinePromptInput(stdin, positional string) string { | |||
| 2896 | + | parts := make([]string, 0, 2) | |||
| 2897 | + | if stdin = strings.TrimSpace(stdin); stdin != "" { | |||
| 2898 | + | parts = append(parts, stdin) | |||
| 2899 | + | } | |||
| 2900 | + | if positional = strings.TrimSpace(positional); positional != "" { | |||
| 2901 | + | parts = append(parts, positional) | |||
| 2902 | + | } | |||
| 2903 | + | return strings.Join(parts, "\n") | |||
| 2904 | + | } | |||
| 2905 | + | ||||
| 2890 | func readAllStdin() (string, error) { | 2906 | func readAllStdin() (string, error) { | ||
| 2891 | fi, err := os.Stdin.Stat() | 2907 | fi, err := os.Stdin.Stat() | ||
| 2892 | if err != nil { | 2908 | if err != nil { | ||
M packages/agent/config.go +24 -0
| @@ -181,6 +181,10 @@ type Config struct { | |||
| 181 | 181 | // Insecure skips TLS verification for custom inference endpoints. | |
| 182 | 182 | Insecure bool `json:"insecure,omitempty"` | |
| 183 | 183 | ||
| 184 | + | // HTTPProxy is a global proxy URL used for HTTP and HTTPS requests when | |
| 185 | + | // the corresponding standard proxy environment variable is not already set. | |
| 186 | + | HTTPProxy string `json:"http_proxy,omitempty"` | |
| 187 | + | ||
| 184 | 188 | // LastChangelogShown is the version whose release-notes | |
| 185 | 189 | // dialog the user has already seen. When the running binary's | |
| 186 | 190 | // version differs, the next interactive run shows the | |
| @@ -332,6 +336,26 @@ func SaveConfig(c Config) error { | |||
| 332 | 336 | return os.WriteFile(ConfigPath(), b, 0o644) | |
| 333 | 337 | } | |
| 334 | 338 | ||
| 339 | + | // applyConfiguredHTTPProxy makes the persisted proxy available to Go's | |
| 340 | + | // standard HTTP transport and to child processes. Explicit environment | |
| 341 | + | // variables take precedence over config.json. | |
| 342 | + | func applyConfiguredHTTPProxy() { | |
| 343 | + | cfg, err := LoadConfig() | |
| 344 | + | if err != nil { | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | proxy := strings.TrimSpace(cfg.HTTPProxy) | |
| 348 | + | if proxy == "" { | |
| 349 | + | return | |
| 350 | + | } | |
| 351 | + | if os.Getenv("HTTP_PROXY") == "" && os.Getenv("http_proxy") == "" { | |
| 352 | + | _ = os.Setenv("HTTP_PROXY", proxy) | |
| 353 | + | } | |
| 354 | + | if os.Getenv("HTTPS_PROXY") == "" && os.Getenv("https_proxy") == "" { | |
| 355 | + | _ = os.Setenv("HTTPS_PROXY", proxy) | |
| 356 | + | } | |
| 357 | + | } | |
| 358 | + | ||
| 335 | 359 | // AuthStoreFor returns the auth.Store backed by AuthPath(). | |
| 336 | 360 | func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | |
| 337 | 361 | ||
| @@ -181,6 +181,10 @@ type Config struct { | |||||
| 181 | // Insecure skips TLS verification for custom inference endpoints. | 181 | // Insecure skips TLS verification for custom inference endpoints. | ||
| 182 | Insecure bool `json:"insecure,omitempty"` | 182 | Insecure bool `json:"insecure,omitempty"` | ||
| 183 | 183 | ||||
| 184 | + | // HTTPProxy is a global proxy URL used for HTTP and HTTPS requests when | |||
| 185 | + | // the corresponding standard proxy environment variable is not already set. | |||
| 186 | + | HTTPProxy string `json:"http_proxy,omitempty"` | |||
| 187 | + | ||||
| 184 | // LastChangelogShown is the version whose release-notes | 188 | // LastChangelogShown is the version whose release-notes | ||
| 185 | // dialog the user has already seen. When the running binary's | 189 | // dialog the user has already seen. When the running binary's | ||
| 186 | // version differs, the next interactive run shows the | 190 | // version differs, the next interactive run shows the | ||
| @@ -332,6 +336,26 @@ func SaveConfig(c Config) error { | |||||
| 332 | return os.WriteFile(ConfigPath(), b, 0o644) | 336 | return os.WriteFile(ConfigPath(), b, 0o644) | ||
| 333 | } | 337 | } | ||
| 334 | 338 | ||||
| 339 | + | // applyConfiguredHTTPProxy makes the persisted proxy available to Go's | |||
| 340 | + | // standard HTTP transport and to child processes. Explicit environment | |||
| 341 | + | // variables take precedence over config.json. | |||
| 342 | + | func applyConfiguredHTTPProxy() { | |||
| 343 | + | cfg, err := LoadConfig() | |||
| 344 | + | if err != nil { | |||
| 345 | + | return | |||
| 346 | + | } | |||
| 347 | + | proxy := strings.TrimSpace(cfg.HTTPProxy) | |||
| 348 | + | if proxy == "" { | |||
| 349 | + | return | |||
| 350 | + | } | |||
| 351 | + | if os.Getenv("HTTP_PROXY") == "" && os.Getenv("http_proxy") == "" { | |||
| 352 | + | _ = os.Setenv("HTTP_PROXY", proxy) | |||
| 353 | + | } | |||
| 354 | + | if os.Getenv("HTTPS_PROXY") == "" && os.Getenv("https_proxy") == "" { | |||
| 355 | + | _ = os.Setenv("HTTPS_PROXY", proxy) | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | ||||
| 335 | // AuthStoreFor returns the auth.Store backed by AuthPath(). | 359 | // AuthStoreFor returns the auth.Store backed by AuthPath(). | ||
| 336 | func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | 360 | func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | ||
| 337 | 361 | ||||
A packages/agent/config_proxy_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestApplyConfiguredHTTPProxy(t *testing.T) { | |
| 9 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 10 | + | for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} { | |
| 11 | + | t.Setenv(key, "") | |
| 12 | + | } | |
| 13 | + | if err := SaveConfig(Config{HTTPProxy: " http://proxy.example:8080 "}); err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | applyConfiguredHTTPProxy() | |
| 18 | + | ||
| 19 | + | if got := getenvForTest("HTTP_PROXY"); got != "http://proxy.example:8080" { | |
| 20 | + | t.Fatalf("HTTP_PROXY = %q", got) | |
| 21 | + | } | |
| 22 | + | if got := getenvForTest("HTTPS_PROXY"); got != "http://proxy.example:8080" { | |
| 23 | + | t.Fatalf("HTTPS_PROXY = %q", got) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestApplyConfiguredHTTPProxyPreservesUppercaseEnvironment(t *testing.T) { | |
| 28 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 29 | + | // Set lowercase first because environment variable names are | |
| 30 | + | // case-insensitive on Windows. | |
| 31 | + | t.Setenv("http_proxy", "") | |
| 32 | + | t.Setenv("https_proxy", "") | |
| 33 | + | t.Setenv("HTTP_PROXY", "http://http-env.example:8080") | |
| 34 | + | t.Setenv("HTTPS_PROXY", "http://https-env.example:8080") | |
| 35 | + | if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | applyConfiguredHTTPProxy() | |
| 40 | + | ||
| 41 | + | if got := getenvForTest("HTTP_PROXY"); got != "http://http-env.example:8080" { | |
| 42 | + | t.Fatalf("HTTP_PROXY = %q", got) | |
| 43 | + | } | |
| 44 | + | if got := getenvForTest("HTTPS_PROXY"); got != "http://https-env.example:8080" { | |
| 45 | + | t.Fatalf("HTTPS_PROXY = %q", got) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestApplyConfiguredHTTPProxyPreservesLowercaseEnvironment(t *testing.T) { | |
| 50 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 51 | + | // Set uppercase first because environment variable names are | |
| 52 | + | // case-insensitive on Windows. | |
| 53 | + | t.Setenv("HTTP_PROXY", "") | |
| 54 | + | t.Setenv("HTTPS_PROXY", "") | |
| 55 | + | t.Setenv("http_proxy", "http://http-env.example:8080") | |
| 56 | + | t.Setenv("https_proxy", "http://https-env.example:8080") | |
| 57 | + | if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | applyConfiguredHTTPProxy() | |
| 62 | + | ||
| 63 | + | if got := getenvForTest("http_proxy"); got != "http://http-env.example:8080" { | |
| 64 | + | t.Fatalf("http_proxy = %q", got) | |
| 65 | + | } | |
| 66 | + | if got := getenvForTest("https_proxy"); got != "http://https-env.example:8080" { | |
| 67 | + | t.Fatalf("https_proxy = %q", got) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestApplyConfiguredHTTPProxyIgnoresEmptySetting(t *testing.T) { | |
| 72 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 73 | + | for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} { | |
| 74 | + | t.Setenv(key, "") | |
| 75 | + | } | |
| 76 | + | if err := SaveConfig(Config{HTTPProxy: " "}); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | applyConfiguredHTTPProxy() | |
| 81 | + | ||
| 82 | + | if got := getenvForTest("HTTP_PROXY"); got != "" { | |
| 83 | + | t.Fatalf("HTTP_PROXY = %q", got) | |
| 84 | + | } | |
| 85 | + | if got := getenvForTest("HTTPS_PROXY"); got != "" { | |
| 86 | + | t.Fatalf("HTTPS_PROXY = %q", got) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func getenvForTest(key string) string { | |
| 91 | + | return os.Getenv(key) | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestApplyConfiguredHTTPProxy(t *testing.T) { | |||
| 9 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 10 | + | for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} { | |||
| 11 | + | t.Setenv(key, "") | |||
| 12 | + | } | |||
| 13 | + | if err := SaveConfig(Config{HTTPProxy: " http://proxy.example:8080 "}); err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | applyConfiguredHTTPProxy() | |||
| 18 | + | ||||
| 19 | + | if got := getenvForTest("HTTP_PROXY"); got != "http://proxy.example:8080" { | |||
| 20 | + | t.Fatalf("HTTP_PROXY = %q", got) | |||
| 21 | + | } | |||
| 22 | + | if got := getenvForTest("HTTPS_PROXY"); got != "http://proxy.example:8080" { | |||
| 23 | + | t.Fatalf("HTTPS_PROXY = %q", got) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestApplyConfiguredHTTPProxyPreservesUppercaseEnvironment(t *testing.T) { | |||
| 28 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 29 | + | // Set lowercase first because environment variable names are | |||
| 30 | + | // case-insensitive on Windows. | |||
| 31 | + | t.Setenv("http_proxy", "") | |||
| 32 | + | t.Setenv("https_proxy", "") | |||
| 33 | + | t.Setenv("HTTP_PROXY", "http://http-env.example:8080") | |||
| 34 | + | t.Setenv("HTTPS_PROXY", "http://https-env.example:8080") | |||
| 35 | + | if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | applyConfiguredHTTPProxy() | |||
| 40 | + | ||||
| 41 | + | if got := getenvForTest("HTTP_PROXY"); got != "http://http-env.example:8080" { | |||
| 42 | + | t.Fatalf("HTTP_PROXY = %q", got) | |||
| 43 | + | } | |||
| 44 | + | if got := getenvForTest("HTTPS_PROXY"); got != "http://https-env.example:8080" { | |||
| 45 | + | t.Fatalf("HTTPS_PROXY = %q", got) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestApplyConfiguredHTTPProxyPreservesLowercaseEnvironment(t *testing.T) { | |||
| 50 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 51 | + | // Set uppercase first because environment variable names are | |||
| 52 | + | // case-insensitive on Windows. | |||
| 53 | + | t.Setenv("HTTP_PROXY", "") | |||
| 54 | + | t.Setenv("HTTPS_PROXY", "") | |||
| 55 | + | t.Setenv("http_proxy", "http://http-env.example:8080") | |||
| 56 | + | t.Setenv("https_proxy", "http://https-env.example:8080") | |||
| 57 | + | if err := SaveConfig(Config{HTTPProxy: "http://config.example:8080"}); err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | applyConfiguredHTTPProxy() | |||
| 62 | + | ||||
| 63 | + | if got := getenvForTest("http_proxy"); got != "http://http-env.example:8080" { | |||
| 64 | + | t.Fatalf("http_proxy = %q", got) | |||
| 65 | + | } | |||
| 66 | + | if got := getenvForTest("https_proxy"); got != "http://https-env.example:8080" { | |||
| 67 | + | t.Fatalf("https_proxy = %q", got) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestApplyConfiguredHTTPProxyIgnoresEmptySetting(t *testing.T) { | |||
| 72 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 73 | + | for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} { | |||
| 74 | + | t.Setenv(key, "") | |||
| 75 | + | } | |||
| 76 | + | if err := SaveConfig(Config{HTTPProxy: " "}); err != nil { | |||
| 77 | + | t.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | applyConfiguredHTTPProxy() | |||
| 81 | + | ||||
| 82 | + | if got := getenvForTest("HTTP_PROXY"); got != "" { | |||
| 83 | + | t.Fatalf("HTTP_PROXY = %q", got) | |||
| 84 | + | } | |||
| 85 | + | if got := getenvForTest("HTTPS_PROXY"); got != "" { | |||
| 86 | + | t.Fatalf("HTTPS_PROXY = %q", got) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func getenvForTest(key string) string { | |||
| 91 | + | return os.Getenv(key) | |||
| 92 | + | } | |||
A packages/agent/prompt_input_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) { | |
| 9 | + | args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe)) | |
| 10 | + | if err != nil { | |
| 11 | + | t.Fatal(err) | |
| 12 | + | } | |
| 13 | + | if args.Mode != ModePrint || args.Prompt != "summarize" { | |
| 14 | + | t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) { | |
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | in []string | |
| 22 | + | mode os.FileMode | |
| 23 | + | want Mode | |
| 24 | + | }{ | |
| 25 | + | {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive}, | |
| 26 | + | {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream}, | |
| 27 | + | {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON}, | |
| 28 | + | {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC}, | |
| 29 | + | } | |
| 30 | + | for _, tc := range tests { | |
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |
| 32 | + | args, err := ParseArgs(argsForStdin(tc.in, tc.mode)) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if args.Mode != tc.want { | |
| 37 | + | t.Fatalf("Mode=%q, want %q", args.Mode, tc.want) | |
| 38 | + | } | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestArgsForStdinAllowsPrintStats(t *testing.T) { | |
| 44 | + | args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe)) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | if args.Mode != ModePrint || args.StatsPath != "stats.json" { | |
| 49 | + | t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestCombinePromptInput(t *testing.T) { | |
| 54 | + | tests := []struct { | |
| 55 | + | name string | |
| 56 | + | stdin string | |
| 57 | + | positional string | |
| 58 | + | want string | |
| 59 | + | }{ | |
| 60 | + | {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"}, | |
| 61 | + | {name: "stdin only", stdin: " input text\n", want: "input text"}, | |
| 62 | + | {name: "positional only", positional: " summarize it ", want: "summarize it"}, | |
| 63 | + | {name: "empty", stdin: " \n", positional: " ", want: ""}, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for _, tc := range tests { | |
| 67 | + | t.Run(tc.name, func(t *testing.T) { | |
| 68 | + | if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want { | |
| 69 | + | t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want) | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestArgsForStdinSelectsPrintModeForPipe(t *testing.T) { | |||
| 9 | + | args, err := ParseArgs(argsForStdin([]string{"summarize"}, os.ModeNamedPipe)) | |||
| 10 | + | if err != nil { | |||
| 11 | + | t.Fatal(err) | |||
| 12 | + | } | |||
| 13 | + | if args.Mode != ModePrint || args.Prompt != "summarize" { | |||
| 14 | + | t.Fatalf("Mode=%q Prompt=%q, want print mode with prompt", args.Mode, args.Prompt) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestArgsForStdinPreservesTerminalAndExplicitModes(t *testing.T) { | |||
| 19 | + | tests := []struct { | |||
| 20 | + | name string | |||
| 21 | + | in []string | |||
| 22 | + | mode os.FileMode | |||
| 23 | + | want Mode | |||
| 24 | + | }{ | |||
| 25 | + | {name: "terminal stays interactive", mode: os.ModeCharDevice, want: ModeInteractive}, | |||
| 26 | + | {name: "explicit stream overrides pipe default", in: []string{"--stream", "prompt"}, mode: os.ModeNamedPipe, want: ModeStream}, | |||
| 27 | + | {name: "explicit json overrides redirected file default", in: []string{"--json", "prompt"}, want: ModeJSON}, | |||
| 28 | + | {name: "explicit rpc overrides pipe default", in: []string{"--rpc"}, mode: os.ModeNamedPipe, want: ModeRPC}, | |||
| 29 | + | } | |||
| 30 | + | for _, tc := range tests { | |||
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 32 | + | args, err := ParseArgs(argsForStdin(tc.in, tc.mode)) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if args.Mode != tc.want { | |||
| 37 | + | t.Fatalf("Mode=%q, want %q", args.Mode, tc.want) | |||
| 38 | + | } | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestArgsForStdinAllowsPrintStats(t *testing.T) { | |||
| 44 | + | args, err := ParseArgs(argsForStdin([]string{"--stats", "stats.json"}, os.ModeNamedPipe)) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | if args.Mode != ModePrint || args.StatsPath != "stats.json" { | |||
| 49 | + | t.Fatalf("Mode=%q StatsPath=%q, want print mode with stats path", args.Mode, args.StatsPath) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestCombinePromptInput(t *testing.T) { | |||
| 54 | + | tests := []struct { | |||
| 55 | + | name string | |||
| 56 | + | stdin string | |||
| 57 | + | positional string | |||
| 58 | + | want string | |||
| 59 | + | }{ | |||
| 60 | + | {name: "stdin and instruction", stdin: "input text\n", positional: "summarize it", want: "input text\nsummarize it"}, | |||
| 61 | + | {name: "stdin only", stdin: " input text\n", want: "input text"}, | |||
| 62 | + | {name: "positional only", positional: " summarize it ", want: "summarize it"}, | |||
| 63 | + | {name: "empty", stdin: " \n", positional: " ", want: ""}, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | for _, tc := range tests { | |||
| 67 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 68 | + | if got := combinePromptInput(tc.stdin, tc.positional); got != tc.want { | |||
| 69 | + | t.Fatalf("combinePromptInput(%q, %q) = %q, want %q", tc.stdin, tc.positional, got, tc.want) | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
M packages/agent/systemprompt.go +2 -2
| @@ -21,7 +21,7 @@ type ToolSummary struct { | |||
| 21 | 21 | type SystemPromptOpts struct { | |
| 22 | 22 | CWD string | |
| 23 | 23 | Tools []ToolSummary | |
| 24 | - | Custom string // if set, replaces the default identity entirely | |
| 24 | + | Custom string // if set, replaces the built-in identity and docs guidance | |
| 25 | 25 | Append []string // extra text appended at the end | |
| 26 | 26 | Now time.Time | |
| 27 | 27 | ZutDocsDir string | |
| @@ -67,7 +67,7 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||
| 67 | 67 | sb.WriteString("\n\n") | |
| 68 | 68 | sb.WriteString(compactedSummaryHandoffInstruction) | |
| 69 | 69 | ||
| 70 | - | if strings.TrimSpace(o.ZutDocsDir) != "" { | |
| 70 | + | if o.Custom == "" && strings.TrimSpace(o.ZutDocsDir) != "" { | |
| 71 | 71 | sb.WriteString("\n\nZut's own docs are installed under ") | |
| 72 | 72 | sb.WriteString(o.ZutDocsDir) | |
| 73 | 73 | sb.WriteString("; use the read tool there when you need details about zut RPC, extensions, skills, or built-in behaviour.") | |
| @@ -21,7 +21,7 @@ type ToolSummary struct { | |||||
| 21 | type SystemPromptOpts struct { | 21 | type SystemPromptOpts struct { | ||
| 22 | CWD string | 22 | CWD string | ||
| 23 | Tools []ToolSummary | 23 | Tools []ToolSummary | ||
| 24 | - | Custom string // if set, replaces the default identity entirely | 24 | + | Custom string // if set, replaces the built-in identity and docs guidance |
| 25 | Append []string // extra text appended at the end | 25 | Append []string // extra text appended at the end | ||
| 26 | Now time.Time | 26 | Now time.Time | ||
| 27 | ZutDocsDir string | 27 | ZutDocsDir string | ||
| @@ -67,7 +67,7 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||||
| 67 | sb.WriteString("\n\n") | 67 | sb.WriteString("\n\n") | ||
| 68 | sb.WriteString(compactedSummaryHandoffInstruction) | 68 | sb.WriteString(compactedSummaryHandoffInstruction) | ||
| 69 | 69 | ||||
| 70 | - | if strings.TrimSpace(o.ZutDocsDir) != "" { | 70 | + | if o.Custom == "" && strings.TrimSpace(o.ZutDocsDir) != "" { |
| 71 | sb.WriteString("\n\nZut's own docs are installed under ") | 71 | sb.WriteString("\n\nZut's own docs are installed under ") | ||
| 72 | sb.WriteString(o.ZutDocsDir) | 72 | sb.WriteString(o.ZutDocsDir) | ||
| 73 | sb.WriteString("; use the read tool there when you need details about zut RPC, extensions, skills, or built-in behaviour.") | 73 | sb.WriteString("; use the read tool there when you need details about zut RPC, extensions, skills, or built-in behaviour.") | ||
M packages/agent/systemprompt_test.go +37 -0
| @@ -3,6 +3,7 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | + | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) { | |
| @@ -22,3 +23,39 @@ func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) { | |||
| 22 | 23 | } | |
| 23 | 24 | } | |
| 24 | 25 | } | |
| 26 | + | ||
| 27 | + | func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) { | |
| 28 | + | got := BuildSystemPrompt(SystemPromptOpts{ | |
| 29 | + | CWD: "/workspace", | |
| 30 | + | Custom: "Custom instructions", | |
| 31 | + | Append: []string{"Additional context"}, | |
| 32 | + | Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC), | |
| 33 | + | ZutDocsDir: "/zut/docs", | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | if strings.Contains(got, "Zut's own docs") || strings.Contains(got, "/zut/docs") { | |
| 37 | + | t.Fatalf("custom prompt includes built-in docs guidance:\n%s", got) | |
| 38 | + | } | |
| 39 | + | for _, want := range []string{ | |
| 40 | + | "Custom instructions", | |
| 41 | + | "Additional context", | |
| 42 | + | "Current date: 2026-08-06", | |
| 43 | + | "Current working directory: /workspace", | |
| 44 | + | } { | |
| 45 | + | if !strings.Contains(got, want) { | |
| 46 | + | t.Errorf("custom prompt missing %q:\n%s", want, got) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | |
| 52 | + | got := BuildSystemPrompt(SystemPromptOpts{ | |
| 53 | + | CWD: "/workspace", | |
| 54 | + | Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC), | |
| 55 | + | ZutDocsDir: "/zut/docs", | |
| 56 | + | }) | |
| 57 | + | ||
| 58 | + | if !strings.Contains(got, "Zut's own docs are installed under /zut/docs") { | |
| 59 | + | t.Fatalf("default prompt missing built-in docs guidance:\n%s", got) | |
| 60 | + | } | |
| 61 | + | } | |
| @@ -3,6 +3,7 @@ package agent | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strings" | 4 | "strings" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | + | "time" | |||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) { | 9 | func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) { | ||
| @@ -22,3 +23,39 @@ func TestBuildSystemPromptAddsCompactionHandoffToCustomPrompt(t *testing.T) { | |||||
| 22 | } | 23 | } | ||
| 23 | } | 24 | } | ||
| 24 | } | 25 | } | ||
| 26 | + | ||||
| 27 | + | func TestBuildSystemPromptCustomOmitsBuiltInDocs(t *testing.T) { | |||
| 28 | + | got := BuildSystemPrompt(SystemPromptOpts{ | |||
| 29 | + | CWD: "/workspace", | |||
| 30 | + | Custom: "Custom instructions", | |||
| 31 | + | Append: []string{"Additional context"}, | |||
| 32 | + | Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC), | |||
| 33 | + | ZutDocsDir: "/zut/docs", | |||
| 34 | + | }) | |||
| 35 | + | ||||
| 36 | + | if strings.Contains(got, "Zut's own docs") || strings.Contains(got, "/zut/docs") { | |||
| 37 | + | t.Fatalf("custom prompt includes built-in docs guidance:\n%s", got) | |||
| 38 | + | } | |||
| 39 | + | for _, want := range []string{ | |||
| 40 | + | "Custom instructions", | |||
| 41 | + | "Additional context", | |||
| 42 | + | "Current date: 2026-08-06", | |||
| 43 | + | "Current working directory: /workspace", | |||
| 44 | + | } { | |||
| 45 | + | if !strings.Contains(got, want) { | |||
| 46 | + | t.Errorf("custom prompt missing %q:\n%s", want, got) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestBuildSystemPromptDefaultIncludesBuiltInDocs(t *testing.T) { | |||
| 52 | + | got := BuildSystemPrompt(SystemPromptOpts{ | |||
| 53 | + | CWD: "/workspace", | |||
| 54 | + | Now: time.Date(2026, time.August, 6, 0, 0, 0, 0, time.UTC), | |||
| 55 | + | ZutDocsDir: "/zut/docs", | |||
| 56 | + | }) | |||
| 57 | + | ||||
| 58 | + | if !strings.Contains(got, "Zut's own docs are installed under /zut/docs") { | |||
| 59 | + | t.Fatalf("default prompt missing built-in docs guidance:\n%s", got) | |||
| 60 | + | } | |||
| 61 | + | } | |||
M packages/agent/update.go +22 -30
| @@ -7,9 +7,10 @@ import ( | |||
| 7 | 7 | "net/http" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | - | "strconv" | |
| 11 | 10 | "strings" | |
| 12 | 11 | "time" | |
| 12 | + | ||
| 13 | + | "golang.org/x/mod/semver" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // updateCheckTTL is how often we hit the GitHub API to look for a new | |
| @@ -132,40 +133,31 @@ func buildInfo(current, latest, url string) UpdateInfo { | |||
| 132 | 133 | return info | |
| 133 | 134 | } | |
| 134 | 135 | ||
| 135 | - | // versionLess returns a < b for dotted semver-ish tags like "0.0.4". | |
| 136 | - | // Non-numeric components compare as zero, which is fine for zut's | |
| 137 | - | // x.y.z-only scheme. | |
| 136 | + | // versionLess reports whether a is an older semantic version than b. | |
| 137 | + | // Invalid versions return false so update checks never offer a downgrade. | |
| 138 | 138 | func versionLess(a, b string) bool { | |
| 139 | - | as := splitVersion(a) | |
| 140 | - | bs := splitVersion(b) | |
| 141 | - | for i := 0; i < 3; i++ { | |
| 142 | - | av, bv := 0, 0 | |
| 143 | - | if i < len(as) { | |
| 144 | - | av = as[i] | |
| 145 | - | } | |
| 146 | - | if i < len(bs) { | |
| 147 | - | bv = bs[i] | |
| 148 | - | } | |
| 149 | - | if av != bv { | |
| 150 | - | return av < bv | |
| 151 | - | } | |
| 152 | - | } | |
| 153 | - | return false | |
| 139 | + | comparison, err := compareVersions(a, b) | |
| 140 | + | return err == nil && comparison < 0 | |
| 154 | 141 | } | |
| 155 | 142 | ||
| 156 | - | func splitVersion(s string) []int { | |
| 157 | - | s = strings.TrimPrefix(s, "v") | |
| 158 | - | // Strip build-metadata suffix like "(abc1234, 2026-04-18)". | |
| 159 | - | if i := strings.IndexAny(s, " ("); i > 0 { | |
| 160 | - | s = s[:i] | |
| 143 | + | func compareVersions(a, b string) (int, error) { | |
| 144 | + | a = normalizedVersion(a) | |
| 145 | + | b = normalizedVersion(b) | |
| 146 | + | if !semver.IsValid(a) { | |
| 147 | + | return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(a, "v")) | |
| 148 | + | } | |
| 149 | + | if !semver.IsValid(b) { | |
| 150 | + | return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(b, "v")) | |
| 161 | 151 | } | |
| 162 | - | parts := strings.Split(s, ".") | |
| 163 | - | out := make([]int, 0, len(parts)) | |
| 164 | - | for _, p := range parts { | |
| 165 | - | n, _ := strconv.Atoi(p) | |
| 166 | - | out = append(out, n) | |
| 152 | + | return semver.Compare(a, b), nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func normalizedVersion(v string) string { | |
| 156 | + | v = versionOnly(v) | |
| 157 | + | if !strings.HasPrefix(v, "v") { | |
| 158 | + | v = "v" + v | |
| 167 | 159 | } | |
| 168 | - | return out | |
| 160 | + | return v | |
| 169 | 161 | } | |
| 170 | 162 | ||
| 171 | 163 | // fetchLatestRelease queries the GitHub API for the latest published | |
| @@ -7,9 +7,10 @@ import ( | |||||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | - | "strconv" | |||
| 11 | "strings" | 10 | "strings" | ||
| 12 | "time" | 11 | "time" | ||
| 12 | + | ||||
| 13 | + | "golang.org/x/mod/semver" | |||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| 15 | // updateCheckTTL is how often we hit the GitHub API to look for a new | 16 | // updateCheckTTL is how often we hit the GitHub API to look for a new | ||
| @@ -132,40 +133,31 @@ func buildInfo(current, latest, url string) UpdateInfo { | |||||
| 132 | return info | 133 | return info | ||
| 133 | } | 134 | } | ||
| 134 | 135 | ||||
| 135 | - | // versionLess returns a < b for dotted semver-ish tags like "0.0.4". | 136 | + | // versionLess reports whether a is an older semantic version than b. |
| 136 | - | // Non-numeric components compare as zero, which is fine for zut's | 137 | + | // Invalid versions return false so update checks never offer a downgrade. |
| 137 | - | // x.y.z-only scheme. | |||
| 138 | func versionLess(a, b string) bool { | 138 | func versionLess(a, b string) bool { | ||
| 139 | - | as := splitVersion(a) | 139 | + | comparison, err := compareVersions(a, b) |
| 140 | - | bs := splitVersion(b) | 140 | + | return err == nil && comparison < 0 |
| 141 | - | for i := 0; i < 3; i++ { | |||
| 142 | - | av, bv := 0, 0 | |||
| 143 | - | if i < len(as) { | |||
| 144 | - | av = as[i] | |||
| 145 | - | } | |||
| 146 | - | if i < len(bs) { | |||
| 147 | - | bv = bs[i] | |||
| 148 | - | } | |||
| 149 | - | if av != bv { | |||
| 150 | - | return av < bv | |||
| 151 | - | } | |||
| 152 | - | } | |||
| 153 | - | return false | |||
| 154 | } | 141 | } | ||
| 155 | 142 | ||||
| 156 | - | func splitVersion(s string) []int { | 143 | + | func compareVersions(a, b string) (int, error) { |
| 157 | - | s = strings.TrimPrefix(s, "v") | 144 | + | a = normalizedVersion(a) |
| 158 | - | // Strip build-metadata suffix like "(abc1234, 2026-04-18)". | 145 | + | b = normalizedVersion(b) |
| 159 | - | if i := strings.IndexAny(s, " ("); i > 0 { | 146 | + | if !semver.IsValid(a) { |
| 160 | - | s = s[:i] | 147 | + | return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(a, "v")) |
| 148 | + | } | |||
| 149 | + | if !semver.IsValid(b) { | |||
| 150 | + | return 0, fmt.Errorf("invalid semantic version %q", strings.TrimPrefix(b, "v")) | |||
| 161 | } | 151 | } | ||
| 162 | - | parts := strings.Split(s, ".") | 152 | + | return semver.Compare(a, b), nil |
| 163 | - | out := make([]int, 0, len(parts)) | 153 | + | } |
| 164 | - | for _, p := range parts { | 154 | + | |
| 165 | - | n, _ := strconv.Atoi(p) | 155 | + | func normalizedVersion(v string) string { |
| 166 | - | out = append(out, n) | 156 | + | v = versionOnly(v) |
| 157 | + | if !strings.HasPrefix(v, "v") { | |||
| 158 | + | v = "v" + v | |||
| 167 | } | 159 | } | ||
| 168 | - | return out | 160 | + | return v |
| 169 | } | 161 | } | ||
| 170 | 162 | ||||
| 171 | // fetchLatestRelease queries the GitHub API for the latest published | 163 | // fetchLatestRelease queries the GitHub API for the latest published | ||
M packages/agent/update_test.go +32 -0
| @@ -52,3 +52,35 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |||
| 52 | 52 | t.Fatalf("development check made %d network requests, want 0", transport.calls) | |
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | + | ||
| 56 | + | func TestVersionLess(t *testing.T) { | |
| 57 | + | pseudo := "0.3.35-0.20260809165004-9e3c28d4b65" | |
| 58 | + | cases := []struct { | |
| 59 | + | name string | |
| 60 | + | a, b string | |
| 61 | + | want bool | |
| 62 | + | }{ | |
| 63 | + | {name: "older release", a: "0.3.33", b: "0.3.34", want: true}, | |
| 64 | + | {name: "newer release", a: "0.3.35", b: "0.3.34", want: false}, | |
| 65 | + | {name: "pseudo after prior release", a: pseudo, b: "0.3.34", want: false}, | |
| 66 | + | {name: "pseudo before final release", a: pseudo, b: "0.3.35", want: true}, | |
| 67 | + | {name: "prior release before pseudo", a: "0.3.34", b: pseudo, want: true}, | |
| 68 | + | {name: "decorated build", a: "0.3.33 (abc1234, 2026-08-09)", b: "0.3.34", want: true}, | |
| 69 | + | {name: "invalid current", a: "not-a-version", b: "0.3.34", want: false}, | |
| 70 | + | } | |
| 71 | + | for _, tc := range cases { | |
| 72 | + | t.Run(tc.name, func(t *testing.T) { | |
| 73 | + | if got := versionLess(tc.a, tc.b); got != tc.want { | |
| 74 | + | t.Fatalf("versionLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestBuildInfoDoesNotOfferPseudoVersionDowngrade(t *testing.T) { | |
| 81 | + | current := "0.3.35-0.20260809165004-9e3c28d4b65" | |
| 82 | + | info := buildInfo(current, "v0.3.34", "https://example.com/release") | |
| 83 | + | if info.Available { | |
| 84 | + | t.Fatalf("buildInfo(%q, %q) offered a downgrade", current, info.Latest) | |
| 85 | + | } | |
| 86 | + | } | |
| @@ -52,3 +52,35 @@ func TestCheckForUpdateSkipsDevVersionWithoutCacheOrNetwork(t *testing.T) { | |||||
| 52 | t.Fatalf("development check made %d network requests, want 0", transport.calls) | 52 | t.Fatalf("development check made %d network requests, want 0", transport.calls) | ||
| 53 | } | 53 | } | ||
| 54 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | func TestVersionLess(t *testing.T) { | |||
| 57 | + | pseudo := "0.3.35-0.20260809165004-9e3c28d4b65" | |||
| 58 | + | cases := []struct { | |||
| 59 | + | name string | |||
| 60 | + | a, b string | |||
| 61 | + | want bool | |||
| 62 | + | }{ | |||
| 63 | + | {name: "older release", a: "0.3.33", b: "0.3.34", want: true}, | |||
| 64 | + | {name: "newer release", a: "0.3.35", b: "0.3.34", want: false}, | |||
| 65 | + | {name: "pseudo after prior release", a: pseudo, b: "0.3.34", want: false}, | |||
| 66 | + | {name: "pseudo before final release", a: pseudo, b: "0.3.35", want: true}, | |||
| 67 | + | {name: "prior release before pseudo", a: "0.3.34", b: pseudo, want: true}, | |||
| 68 | + | {name: "decorated build", a: "0.3.33 (abc1234, 2026-08-09)", b: "0.3.34", want: true}, | |||
| 69 | + | {name: "invalid current", a: "not-a-version", b: "0.3.34", want: false}, | |||
| 70 | + | } | |||
| 71 | + | for _, tc := range cases { | |||
| 72 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 73 | + | if got := versionLess(tc.a, tc.b); got != tc.want { | |||
| 74 | + | t.Fatalf("versionLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestBuildInfoDoesNotOfferPseudoVersionDowngrade(t *testing.T) { | |||
| 81 | + | current := "0.3.35-0.20260809165004-9e3c28d4b65" | |||
| 82 | + | info := buildInfo(current, "v0.3.34", "https://example.com/release") | |||
| 83 | + | if info.Available { | |||
| 84 | + | t.Fatalf("buildInfo(%q, %q) offered a downgrade", current, info.Latest) | |||
| 85 | + | } | |||
| 86 | + | } | |||
M packages/agent/zutfile.go +5 -1
| @@ -765,7 +765,11 @@ func checkZutfileRequirements(zf zutfileLoaded, version string) error { | |||
| 765 | 765 | if isDevVersion(version) { | |
| 766 | 766 | return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min) | |
| 767 | 767 | } | |
| 768 | - | if versionLess(version, min) { | |
| 768 | + | comparison, err := compareVersions(version, min) | |
| 769 | + | if err != nil { | |
| 770 | + | return fmt.Errorf("validate min_zut requirement: %w", err) | |
| 771 | + | } | |
| 772 | + | if comparison < 0 { | |
| 769 | 773 | return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version)) | |
| 770 | 774 | } | |
| 771 | 775 | } | |
| @@ -765,7 +765,11 @@ func checkZutfileRequirements(zf zutfileLoaded, version string) error { | |||||
| 765 | if isDevVersion(version) { | 765 | if isDevVersion(version) { | ||
| 766 | return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min) | 766 | return fmt.Errorf("agent requires zut %s or newer; unversioned development builds cannot satisfy min_zut", min) | ||
| 767 | } | 767 | } | ||
| 768 | - | if versionLess(version, min) { | 768 | + | comparison, err := compareVersions(version, min) |
| 769 | + | if err != nil { | |||
| 770 | + | return fmt.Errorf("validate min_zut requirement: %w", err) | |||
| 771 | + | } | |||
| 772 | + | if comparison < 0 { | |||
| 769 | return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version)) | 773 | return fmt.Errorf("agent requires zut %s or newer; running %s", min, versionOnly(version)) | ||
| 770 | } | 774 | } | ||
| 771 | } | 775 | } | ||
M packages/agent/zutfile_test.go +8 -0
| @@ -246,6 +246,14 @@ func TestCheckZutfileMinVersion(t *testing.T) { | |||
| 246 | 246 | if err := checkZutfileRequirements(zf, "0.3.0"); err != nil { | |
| 247 | 247 | t.Fatalf("minimum version rejected: %v", err) | |
| 248 | 248 | } | |
| 249 | + | if err := checkZutfileRequirements(zf, "not-a-version"); err == nil { | |
| 250 | + | t.Fatal("invalid zut version accepted") | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | zf.Manifest.Runtime.MinZut = "not-a-version" | |
| 254 | + | if err := checkZutfileRequirements(zf, "0.3.0"); err == nil { | |
| 255 | + | t.Fatal("invalid min_zut requirement accepted") | |
| 256 | + | } | |
| 249 | 257 | } | |
| 250 | 258 | ||
| 251 | 259 | func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) { | |
| @@ -246,6 +246,14 @@ func TestCheckZutfileMinVersion(t *testing.T) { | |||||
| 246 | if err := checkZutfileRequirements(zf, "0.3.0"); err != nil { | 246 | if err := checkZutfileRequirements(zf, "0.3.0"); err != nil { | ||
| 247 | t.Fatalf("minimum version rejected: %v", err) | 247 | t.Fatalf("minimum version rejected: %v", err) | ||
| 248 | } | 248 | } | ||
| 249 | + | if err := checkZutfileRequirements(zf, "not-a-version"); err == nil { | |||
| 250 | + | t.Fatal("invalid zut version accepted") | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | zf.Manifest.Runtime.MinZut = "not-a-version" | |||
| 254 | + | if err := checkZutfileRequirements(zf, "0.3.0"); err == nil { | |||
| 255 | + | t.Fatal("invalid min_zut requirement accepted") | |||
| 256 | + | } | |||
| 249 | } | 257 | } | ||
| 250 | 258 | ||||
| 251 | func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) { | 259 | func TestApplyZutfileModelRequirementsRejectsUnsupportedFields(t *testing.T) { | ||
M packages/provider/catalog_builtin.go +1 -0
| @@ -308,6 +308,7 @@ var builtinCatalog = []Model{ | |||
| 308 | 308 | {Provider: "opencode-go", ID: "glm-5", DisplayName: "GLM-5", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1, PriceOutput: 3.2, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"}, | |
| 309 | 309 | {Provider: "opencode-go", ID: "glm-5.1", DisplayName: "GLM-5.1", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1.4, PriceOutput: 4.4, PriceCacheRead: 0.26, BaseURL: "https://opencode.ai/zen/go/v1"}, | |
| 310 | 310 | {Provider: "opencode-go", ID: "glm-5.2", DisplayName: "GLM-5.2", ContextWindow: 1024000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.91, PriceOutput: 2.86, PriceCacheRead: 0.169, BaseURL: "https://opencode.ai/zen/go/v1"}, | |
| 311 | + | {Provider: "opencode-go", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, PriceTierInputTokens: 272000, PriceInputAbove: 0.4, PriceOutputAbove: 1.8, PriceCacheReadAbove: 0.04, PriceCacheWriteAbove: 0.5, BaseURL: "https://opencode.ai/zen/go/v1"}, | |
| 311 | 312 | {Provider: "opencode-go", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 500000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"}, | |
| 312 | 313 | {Provider: "opencode-go", ID: "kimi-k2.5", DisplayName: "Kimi K2.5", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.6, PriceOutput: 3, PriceCacheRead: 0.1, BaseURL: "https://opencode.ai/zen/go/v1"}, | |
| 313 | 314 | {Provider: "opencode-go", ID: "kimi-k2.6", DisplayName: "Kimi K2.6", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.16, BaseURL: "https://opencode.ai/zen/go/v1"}, | |
| @@ -308,6 +308,7 @@ var builtinCatalog = []Model{ | |||||
| 308 | {Provider: "opencode-go", ID: "glm-5", DisplayName: "GLM-5", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1, PriceOutput: 3.2, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"}, | 308 | {Provider: "opencode-go", ID: "glm-5", DisplayName: "GLM-5", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1, PriceOutput: 3.2, PriceCacheRead: 0.2, BaseURL: "https://opencode.ai/zen/go/v1"}, | ||
| 309 | {Provider: "opencode-go", ID: "glm-5.1", DisplayName: "GLM-5.1", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1.4, PriceOutput: 4.4, PriceCacheRead: 0.26, BaseURL: "https://opencode.ai/zen/go/v1"}, | 309 | {Provider: "opencode-go", ID: "glm-5.1", DisplayName: "GLM-5.1", ContextWindow: 202752, MaxOutput: 32768, Reasoning: true, PriceInput: 1.4, PriceOutput: 4.4, PriceCacheRead: 0.26, BaseURL: "https://opencode.ai/zen/go/v1"}, | ||
| 310 | {Provider: "opencode-go", ID: "glm-5.2", DisplayName: "GLM-5.2", ContextWindow: 1024000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.91, PriceOutput: 2.86, PriceCacheRead: 0.169, BaseURL: "https://opencode.ai/zen/go/v1"}, | 310 | {Provider: "opencode-go", ID: "glm-5.2", DisplayName: "GLM-5.2", ContextWindow: 1024000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.91, PriceOutput: 2.86, PriceCacheRead: 0.169, BaseURL: "https://opencode.ai/zen/go/v1"}, | ||
| 311 | + | {Provider: "opencode-go", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", API: APIResponses, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, PriceTierInputTokens: 272000, PriceInputAbove: 0.4, PriceOutputAbove: 1.8, PriceCacheReadAbove: 0.04, PriceCacheWriteAbove: 0.5, BaseURL: "https://opencode.ai/zen/go/v1"}, | |||
| 311 | {Provider: "opencode-go", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 500000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"}, | 312 | {Provider: "opencode-go", ID: "grok-4.5", DisplayName: "Grok 4.5", ContextWindow: 500000, MaxOutput: 500000, Reasoning: true, PriceInput: 2, PriceOutput: 6, PriceCacheRead: 0.3, BaseURL: "https://opencode.ai/zen/go/v1"}, | ||
| 312 | {Provider: "opencode-go", ID: "kimi-k2.5", DisplayName: "Kimi K2.5", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.6, PriceOutput: 3, PriceCacheRead: 0.1, BaseURL: "https://opencode.ai/zen/go/v1"}, | 313 | {Provider: "opencode-go", ID: "kimi-k2.5", DisplayName: "Kimi K2.5", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.6, PriceOutput: 3, PriceCacheRead: 0.1, BaseURL: "https://opencode.ai/zen/go/v1"}, | ||
| 313 | {Provider: "opencode-go", ID: "kimi-k2.6", DisplayName: "Kimi K2.6", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.16, BaseURL: "https://opencode.ai/zen/go/v1"}, | 314 | {Provider: "opencode-go", ID: "kimi-k2.6", DisplayName: "Kimi K2.6", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.95, PriceOutput: 4, PriceCacheRead: 0.16, BaseURL: "https://opencode.ai/zen/go/v1"}, | ||
M packages/provider/extra_providers.go +7 -2
| @@ -124,9 +124,14 @@ func NewOpenCode(apiKey, baseURL string) Client { | |||
| 124 | 124 | return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1") | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | - | // NewOpenCodeGo is the opencode-go variant. | |
| 127 | + | // NewOpenCodeGo routes each model through the wire API exposed by OpenCode Go. | |
| 128 | 128 | func NewOpenCodeGo(apiKey, baseURL string) Client { | |
| 129 | - | return NewOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1") | |
| 129 | + | const defaultBaseURL = "https://opencode.ai/zen/go/v1" | |
| 130 | + | return NewModelRouter("opencode-go", | |
| 131 | + | NewOpenAICompat("opencode-go", apiKey, baseURL, defaultBaseURL), | |
| 132 | + | map[string]Client{ | |
| 133 | + | APIResponses: NewOpenAIResponsesNamed(apiKey, firstNonEmptyString(baseURL, defaultBaseURL), "opencode-go"), | |
| 134 | + | }) | |
| 130 | 135 | } | |
| 131 | 136 | ||
| 132 | 137 | // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case | |
| @@ -124,9 +124,14 @@ func NewOpenCode(apiKey, baseURL string) Client { | |||||
| 124 | return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1") | 124 | return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1") | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | - | // NewOpenCodeGo is the opencode-go variant. | 127 | + | // NewOpenCodeGo routes each model through the wire API exposed by OpenCode Go. |
| 128 | func NewOpenCodeGo(apiKey, baseURL string) Client { | 128 | func NewOpenCodeGo(apiKey, baseURL string) Client { | ||
| 129 | - | return NewOpenAICompat("opencode-go", apiKey, baseURL, "https://opencode.ai/zen/go/v1") | 129 | + | const defaultBaseURL = "https://opencode.ai/zen/go/v1" |
| 130 | + | return NewModelRouter("opencode-go", | |||
| 131 | + | NewOpenAICompat("opencode-go", apiKey, baseURL, defaultBaseURL), | |||
| 132 | + | map[string]Client{ | |||
| 133 | + | APIResponses: NewOpenAIResponsesNamed(apiKey, firstNonEmptyString(baseURL, defaultBaseURL), "opencode-go"), | |||
| 134 | + | }) | |||
| 130 | } | 135 | } | ||
| 131 | 136 | ||||
| 132 | // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case | 137 | // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case | ||
M packages/provider/llamacpp.go +16 -2
| @@ -47,6 +47,10 @@ type LlamaCPPModel struct { | |||
| 47 | 47 | Size int64 `json:"size,omitempty"` | |
| 48 | 48 | FileType string `json:"ftype,omitempty"` | |
| 49 | 49 | } `json:"meta,omitempty"` | |
| 50 | + | // Progress is emitted at the model's top level by current llama.cpp | |
| 51 | + | // routers. Status.Progress supports versions that follow the documented | |
| 52 | + | // nested shape. | |
| 53 | + | Progress map[string]LlamaCPPBytes `json:"progress,omitempty"` | |
| 50 | 54 | } | |
| 51 | 55 | ||
| 52 | 56 | // LlamaCPPBytes describes byte progress for one downloaded file. | |
| @@ -204,7 +208,13 @@ func (c *LlamaCPPClient) watch(ctx context.Context, events chan<- llamaCPPEvent) | |||
| 204 | 208 | if c.APIKey != "" { | |
| 205 | 209 | req.Header.Set("authorization", "Bearer "+c.APIKey) | |
| 206 | 210 | } | |
| 207 | - | resp, err := c.HTTP.Do(req) | |
| 211 | + | // http.Client.Timeout covers the complete response body lifetime, which is | |
| 212 | + | // appropriate for ordinary management requests but would terminate this | |
| 213 | + | // long-lived SSE stream. Preserve the configured transport and other client | |
| 214 | + | // behavior while letting ctx control the stream lifetime. | |
| 215 | + | streamClient := *c.HTTP | |
| 216 | + | streamClient.Timeout = 0 | |
| 217 | + | resp, err := streamClient.Do(req) | |
| 208 | 218 | if err != nil { | |
| 209 | 219 | return err | |
| 210 | 220 | } | |
| @@ -408,7 +418,11 @@ func (c *LlamaCPPClient) DownloadAndWait(ctx context.Context, model string, upda | |||
| 408 | 418 | } | |
| 409 | 419 | if entry.Status.Value == "downloading" { | |
| 410 | 420 | sawDownload = true | |
| 411 | - | if progress, ok := downloadProgress(entry.Status.Progress); ok { | |
| 421 | + | files := entry.Status.Progress | |
| 422 | + | if len(files) == 0 { | |
| 423 | + | files = entry.Progress | |
| 424 | + | } | |
| 425 | + | if progress, ok := downloadProgress(files); ok { | |
| 412 | 426 | update(progress) | |
| 413 | 427 | } | |
| 414 | 428 | } else if sawDownload || polls >= 2 { | |
| @@ -47,6 +47,10 @@ type LlamaCPPModel struct { | |||||
| 47 | Size int64 `json:"size,omitempty"` | 47 | Size int64 `json:"size,omitempty"` | ||
| 48 | FileType string `json:"ftype,omitempty"` | 48 | FileType string `json:"ftype,omitempty"` | ||
| 49 | } `json:"meta,omitempty"` | 49 | } `json:"meta,omitempty"` | ||
| 50 | + | // Progress is emitted at the model's top level by current llama.cpp | |||
| 51 | + | // routers. Status.Progress supports versions that follow the documented | |||
| 52 | + | // nested shape. | |||
| 53 | + | Progress map[string]LlamaCPPBytes `json:"progress,omitempty"` | |||
| 50 | } | 54 | } | ||
| 51 | 55 | ||||
| 52 | // LlamaCPPBytes describes byte progress for one downloaded file. | 56 | // LlamaCPPBytes describes byte progress for one downloaded file. | ||
| @@ -204,7 +208,13 @@ func (c *LlamaCPPClient) watch(ctx context.Context, events chan<- llamaCPPEvent) | |||||
| 204 | if c.APIKey != "" { | 208 | if c.APIKey != "" { | ||
| 205 | req.Header.Set("authorization", "Bearer "+c.APIKey) | 209 | req.Header.Set("authorization", "Bearer "+c.APIKey) | ||
| 206 | } | 210 | } | ||
| 207 | - | resp, err := c.HTTP.Do(req) | 211 | + | // http.Client.Timeout covers the complete response body lifetime, which is |
| 212 | + | // appropriate for ordinary management requests but would terminate this | |||
| 213 | + | // long-lived SSE stream. Preserve the configured transport and other client | |||
| 214 | + | // behavior while letting ctx control the stream lifetime. | |||
| 215 | + | streamClient := *c.HTTP | |||
| 216 | + | streamClient.Timeout = 0 | |||
| 217 | + | resp, err := streamClient.Do(req) | |||
| 208 | if err != nil { | 218 | if err != nil { | ||
| 209 | return err | 219 | return err | ||
| 210 | } | 220 | } | ||
| @@ -408,7 +418,11 @@ func (c *LlamaCPPClient) DownloadAndWait(ctx context.Context, model string, upda | |||||
| 408 | } | 418 | } | ||
| 409 | if entry.Status.Value == "downloading" { | 419 | if entry.Status.Value == "downloading" { | ||
| 410 | sawDownload = true | 420 | sawDownload = true | ||
| 411 | - | if progress, ok := downloadProgress(entry.Status.Progress); ok { | 421 | + | files := entry.Status.Progress |
| 422 | + | if len(files) == 0 { | |||
| 423 | + | files = entry.Progress | |||
| 424 | + | } | |||
| 425 | + | if progress, ok := downloadProgress(files); ok { | |||
| 412 | update(progress) | 426 | update(progress) | ||
| 413 | } | 427 | } | ||
| 414 | } else if sawDownload || polls >= 2 { | 428 | } else if sawDownload || polls >= 2 { | ||
M packages/provider/llamacpp_test.go +56 -0
| @@ -32,6 +32,62 @@ func TestLlamaCPPLoadProgressStages(t *testing.T) { | |||
| 32 | 32 | } | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | func TestLlamaCPPWatchOutlivesRequestTimeout(t *testing.T) { | |
| 36 | + | requestStarted := make(chan struct{}) | |
| 37 | + | releaseEvent := make(chan struct{}) | |
| 38 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 39 | + | w.Header().Set("content-type", "text/event-stream") | |
| 40 | + | w.WriteHeader(http.StatusOK) | |
| 41 | + | w.(http.Flusher).Flush() | |
| 42 | + | close(requestStarted) | |
| 43 | + | select { | |
| 44 | + | case <-releaseEvent: | |
| 45 | + | fmt.Fprint(w, "data: {\"model\":\"test\",\"event\":\"download_progress\",\"data\":{}}\n\n") | |
| 46 | + | w.(http.Flusher).Flush() | |
| 47 | + | case <-r.Context().Done(): | |
| 48 | + | } | |
| 49 | + | })) | |
| 50 | + | defer server.Close() | |
| 51 | + | ||
| 52 | + | client, err := NewLlamaCPPClient(server.URL, "") | |
| 53 | + | if err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | client.HTTP.Timeout = 20 * time.Millisecond | |
| 57 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 58 | + | defer cancel() | |
| 59 | + | events := make(chan llamaCPPEvent, 1) | |
| 60 | + | watchDone := make(chan error, 1) | |
| 61 | + | go func() { watchDone <- client.watch(ctx, events) }() | |
| 62 | + | <-requestStarted | |
| 63 | + | ||
| 64 | + | select { | |
| 65 | + | case err := <-watchDone: | |
| 66 | + | t.Fatalf("watch stopped at ordinary request timeout: %v", err) | |
| 67 | + | case <-time.After(3 * client.HTTP.Timeout): | |
| 68 | + | } | |
| 69 | + | close(releaseEvent) | |
| 70 | + | select { | |
| 71 | + | case event := <-events: | |
| 72 | + | if event.Model != "test" || event.Event != "download_progress" { | |
| 73 | + | t.Fatalf("event = %#v", event) | |
| 74 | + | } | |
| 75 | + | case <-time.After(time.Second): | |
| 76 | + | t.Fatal("timed out waiting for delayed SSE event") | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestLlamaCPPModelAcceptsTopLevelDownloadProgress(t *testing.T) { | |
| 81 | + | var model LlamaCPPModel | |
| 82 | + | if err := json.Unmarshal([]byte(`{"id":"test","status":{"value":"downloading"},"progress":{"model.gguf":{"done":512,"total":1024}}}`), &model); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | progress, ok := downloadProgress(model.Progress) | |
| 86 | + | if !ok || progress.Ratio != 0.5 { | |
| 87 | + | t.Fatalf("progress = %#v, ok = %v", progress, ok) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 35 | 91 | func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) { | |
| 36 | 92 | var mu sync.Mutex | |
| 37 | 93 | status := "unloaded" | |
| @@ -32,6 +32,62 @@ func TestLlamaCPPLoadProgressStages(t *testing.T) { | |||||
| 32 | } | 32 | } | ||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | + | func TestLlamaCPPWatchOutlivesRequestTimeout(t *testing.T) { | |||
| 36 | + | requestStarted := make(chan struct{}) | |||
| 37 | + | releaseEvent := make(chan struct{}) | |||
| 38 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 39 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 40 | + | w.WriteHeader(http.StatusOK) | |||
| 41 | + | w.(http.Flusher).Flush() | |||
| 42 | + | close(requestStarted) | |||
| 43 | + | select { | |||
| 44 | + | case <-releaseEvent: | |||
| 45 | + | fmt.Fprint(w, "data: {\"model\":\"test\",\"event\":\"download_progress\",\"data\":{}}\n\n") | |||
| 46 | + | w.(http.Flusher).Flush() | |||
| 47 | + | case <-r.Context().Done(): | |||
| 48 | + | } | |||
| 49 | + | })) | |||
| 50 | + | defer server.Close() | |||
| 51 | + | ||||
| 52 | + | client, err := NewLlamaCPPClient(server.URL, "") | |||
| 53 | + | if err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | client.HTTP.Timeout = 20 * time.Millisecond | |||
| 57 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 58 | + | defer cancel() | |||
| 59 | + | events := make(chan llamaCPPEvent, 1) | |||
| 60 | + | watchDone := make(chan error, 1) | |||
| 61 | + | go func() { watchDone <- client.watch(ctx, events) }() | |||
| 62 | + | <-requestStarted | |||
| 63 | + | ||||
| 64 | + | select { | |||
| 65 | + | case err := <-watchDone: | |||
| 66 | + | t.Fatalf("watch stopped at ordinary request timeout: %v", err) | |||
| 67 | + | case <-time.After(3 * client.HTTP.Timeout): | |||
| 68 | + | } | |||
| 69 | + | close(releaseEvent) | |||
| 70 | + | select { | |||
| 71 | + | case event := <-events: | |||
| 72 | + | if event.Model != "test" || event.Event != "download_progress" { | |||
| 73 | + | t.Fatalf("event = %#v", event) | |||
| 74 | + | } | |||
| 75 | + | case <-time.After(time.Second): | |||
| 76 | + | t.Fatal("timed out waiting for delayed SSE event") | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestLlamaCPPModelAcceptsTopLevelDownloadProgress(t *testing.T) { | |||
| 81 | + | var model LlamaCPPModel | |||
| 82 | + | if err := json.Unmarshal([]byte(`{"id":"test","status":{"value":"downloading"},"progress":{"model.gguf":{"done":512,"total":1024}}}`), &model); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | progress, ok := downloadProgress(model.Progress) | |||
| 86 | + | if !ok || progress.Ratio != 0.5 { | |||
| 87 | + | t.Fatalf("progress = %#v, ok = %v", progress, ok) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 35 | func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) { | 91 | func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) { | ||
| 36 | var mu sync.Mutex | 92 | var mu sync.Mutex | ||
| 37 | status := "unloaded" | 93 | status := "unloaded" | ||
M packages/provider/model_router_test.go +31 -0
| @@ -48,3 +48,34 @@ func TestModelRouterRejectsMissingAPIClient(t *testing.T) { | |||
| 48 | 48 | t.Fatal("expected missing API client error") | |
| 49 | 49 | } | |
| 50 | 50 | } | |
| 51 | + | ||
| 52 | + | func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) { | |
| 53 | + | router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter) | |
| 54 | + | if got := router.fallback.(*openaiClient).baseURL; got != "https://example.com/go/v1" { | |
| 55 | + | t.Fatalf("Completions base URL = %q", got) | |
| 56 | + | } | |
| 57 | + | responses := router.byAPI[APIResponses].(*renamedClient).inner.(*codexClient) | |
| 58 | + | if got := responses.baseURL; got != "https://example.com/go/v1/responses" { | |
| 59 | + | t.Fatalf("Responses base URL = %q", got) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | completionsCapture := &routeCaptureClient{name: "opencode-go"} | |
| 63 | + | responsesCapture := &routeCaptureClient{name: "opencode-go"} | |
| 64 | + | router.fallback = completionsCapture | |
| 65 | + | router.byAPI[APIResponses] = responsesCapture | |
| 66 | + | for _, model := range []string{"gpt-5.6-luna", "kimi-k3"} { | |
| 67 | + | stream, err := router.Stream(context.Background(), Request{Model: model}) | |
| 68 | + | if err != nil { | |
| 69 | + | t.Fatal(err) | |
| 70 | + | } | |
| 71 | + | for range stream { | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | if len(responsesCapture.models) != 1 || responsesCapture.models[0] != "gpt-5.6-luna" { | |
| 76 | + | t.Fatalf("Responses models = %v", responsesCapture.models) | |
| 77 | + | } | |
| 78 | + | if len(completionsCapture.models) != 1 || completionsCapture.models[0] != "kimi-k3" { | |
| 79 | + | t.Fatalf("Completions models = %v", completionsCapture.models) | |
| 80 | + | } | |
| 81 | + | } | |
| @@ -48,3 +48,34 @@ func TestModelRouterRejectsMissingAPIClient(t *testing.T) { | |||||
| 48 | t.Fatal("expected missing API client error") | 48 | t.Fatal("expected missing API client error") | ||
| 49 | } | 49 | } | ||
| 50 | } | 50 | } | ||
| 51 | + | ||||
| 52 | + | func TestOpenCodeGoRoutesLunaToResponses(t *testing.T) { | |||
| 53 | + | router := NewOpenCodeGo("token", "https://example.com/go/v1").(*modelRouter) | |||
| 54 | + | if got := router.fallback.(*openaiClient).baseURL; got != "https://example.com/go/v1" { | |||
| 55 | + | t.Fatalf("Completions base URL = %q", got) | |||
| 56 | + | } | |||
| 57 | + | responses := router.byAPI[APIResponses].(*renamedClient).inner.(*codexClient) | |||
| 58 | + | if got := responses.baseURL; got != "https://example.com/go/v1/responses" { | |||
| 59 | + | t.Fatalf("Responses base URL = %q", got) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | completionsCapture := &routeCaptureClient{name: "opencode-go"} | |||
| 63 | + | responsesCapture := &routeCaptureClient{name: "opencode-go"} | |||
| 64 | + | router.fallback = completionsCapture | |||
| 65 | + | router.byAPI[APIResponses] = responsesCapture | |||
| 66 | + | for _, model := range []string{"gpt-5.6-luna", "kimi-k3"} { | |||
| 67 | + | stream, err := router.Stream(context.Background(), Request{Model: model}) | |||
| 68 | + | if err != nil { | |||
| 69 | + | t.Fatal(err) | |||
| 70 | + | } | |||
| 71 | + | for range stream { | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | if len(responsesCapture.models) != 1 || responsesCapture.models[0] != "gpt-5.6-luna" { | |||
| 76 | + | t.Fatalf("Responses models = %v", responsesCapture.models) | |||
| 77 | + | } | |||
| 78 | + | if len(completionsCapture.models) != 1 || completionsCapture.models[0] != "kimi-k3" { | |||
| 79 | + | t.Fatalf("Completions models = %v", completionsCapture.models) | |||
| 80 | + | } | |||
| 81 | + | } | |||
M packages/provider/openai.go +5 -0
| @@ -264,6 +264,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 264 | 264 | // don't clamp zut's "maximum" to "high" for those models. | |
| 265 | 265 | effort = OpenAICompatAnthropicEffort(reasoning) | |
| 266 | 266 | } | |
| 267 | + | if hasReasoningLevelOverride(m, req.Reasoning) { | |
| 268 | + | // Explicit model mappings describe the endpoint's actual effort | |
| 269 | + | // values and take precedence over conservative protocol defaults. | |
| 270 | + | effort = reasoning | |
| 271 | + | } | |
| 267 | 272 | if effort != "" { | |
| 268 | 273 | out.ReasoningEffort = effort | |
| 269 | 274 | } | |
| @@ -264,6 +264,11 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 264 | // don't clamp zut's "maximum" to "high" for those models. | 264 | // don't clamp zut's "maximum" to "high" for those models. | ||
| 265 | effort = OpenAICompatAnthropicEffort(reasoning) | 265 | effort = OpenAICompatAnthropicEffort(reasoning) | ||
| 266 | } | 266 | } | ||
| 267 | + | if hasReasoningLevelOverride(m, req.Reasoning) { | |||
| 268 | + | // Explicit model mappings describe the endpoint's actual effort | |||
| 269 | + | // values and take precedence over conservative protocol defaults. | |||
| 270 | + | effort = reasoning | |||
| 271 | + | } | |||
| 267 | if effort != "" { | 272 | if effort != "" { | ||
| 268 | out.ReasoningEffort = effort | 273 | out.ReasoningEffort = effort | ||
| 269 | } | 274 | } | ||
M packages/provider/openai_codex.go +5 -1
| @@ -206,7 +206,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 206 | 206 | body.ServiceTier = fastModeServiceTier | |
| 207 | 207 | } | |
| 208 | 208 | if m.Reasoning { | |
| 209 | - | if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" { | |
| 209 | + | effort := OpenAICodexReasoningEffort(reasoning, req.Model) | |
| 210 | + | if hasReasoningLevelOverride(m, req.Reasoning) { | |
| 211 | + | effort = reasoning | |
| 212 | + | } | |
| 213 | + | if effort != "" { | |
| 210 | 214 | body.Reasoning = &codexReasoningConfig{Effort: effort} | |
| 211 | 215 | } | |
| 212 | 216 | } | |
| @@ -206,7 +206,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||||
| 206 | body.ServiceTier = fastModeServiceTier | 206 | body.ServiceTier = fastModeServiceTier | ||
| 207 | } | 207 | } | ||
| 208 | if m.Reasoning { | 208 | if m.Reasoning { | ||
| 209 | - | if effort := OpenAICodexReasoningEffort(reasoning, req.Model); effort != "" { | 209 | + | effort := OpenAICodexReasoningEffort(reasoning, req.Model) |
| 210 | + | if hasReasoningLevelOverride(m, req.Reasoning) { | |||
| 211 | + | effort = reasoning | |||
| 212 | + | } | |||
| 213 | + | if effort != "" { | |||
| 210 | body.Reasoning = &codexReasoningConfig{Effort: effort} | 214 | body.Reasoning = &codexReasoningConfig{Effort: effort} | ||
| 211 | } | 215 | } | ||
| 212 | } | 216 | } | ||
M packages/provider/openai_codex_test.go +21 -0
| @@ -126,6 +126,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | + | func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) { | |
| 130 | + | SetLiveModels([]Model{{ | |
| 131 | + | Provider: "custom-responses", | |
| 132 | + | ID: "reasoning-model", | |
| 133 | + | API: APIResponses, | |
| 134 | + | Reasoning: true, | |
| 135 | + | ReasoningLevelMap: map[string]string{"max": "max"}, | |
| 136 | + | }}) | |
| 137 | + | t.Cleanup(func() { SetLiveModels(nil) }) | |
| 138 | + | ||
| 139 | + | named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient) | |
| 140 | + | c := named.inner.(*codexClient) | |
| 141 | + | wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"}) | |
| 142 | + | if err != nil { | |
| 143 | + | t.Fatal(err) | |
| 144 | + | } | |
| 145 | + | if wire.Reasoning == nil || wire.Reasoning.Effort != "max" { | |
| 146 | + | t.Fatalf("reasoning = %+v", wire.Reasoning) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 129 | 150 | func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) { | |
| 130 | 151 | named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient) | |
| 131 | 152 | c := named.inner.(*codexClient) | |
| @@ -126,6 +126,27 @@ func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) { | |||||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | + | func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) { | |||
| 130 | + | SetLiveModels([]Model{{ | |||
| 131 | + | Provider: "custom-responses", | |||
| 132 | + | ID: "reasoning-model", | |||
| 133 | + | API: APIResponses, | |||
| 134 | + | Reasoning: true, | |||
| 135 | + | ReasoningLevelMap: map[string]string{"max": "max"}, | |||
| 136 | + | }}) | |||
| 137 | + | t.Cleanup(func() { SetLiveModels(nil) }) | |||
| 138 | + | ||||
| 139 | + | named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient) | |||
| 140 | + | c := named.inner.(*codexClient) | |||
| 141 | + | wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"}) | |||
| 142 | + | if err != nil { | |||
| 143 | + | t.Fatal(err) | |||
| 144 | + | } | |||
| 145 | + | if wire.Reasoning == nil || wire.Reasoning.Effort != "max" { | |||
| 146 | + | t.Fatalf("reasoning = %+v", wire.Reasoning) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 129 | func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) { | 150 | func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) { | ||
| 130 | named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient) | 151 | named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient) | ||
| 131 | c := named.inner.(*codexClient) | 152 | c := named.inner.(*codexClient) | ||
M packages/provider/provider_test.go +14 -4
| @@ -649,10 +649,8 @@ func TestGPT56CatalogEntries(t *testing.T) { | |||
| 649 | 649 | } | |
| 650 | 650 | } | |
| 651 | 651 | ||
| 652 | - | // TestOpenCodeGoCatalog pins the Grok 4.5 and Kimi K3 entries added to | |
| 653 | - | // the OpenCode Go provider. Both are listed in the vendor's current Go | |
| 654 | - | // model lineup (https://opencode.ai/docs/go) and were missing from the | |
| 655 | - | // baked-in catalog. | |
| 652 | + | // TestOpenCodeGoCatalog pins entries added from the vendor's current Go | |
| 653 | + | // model lineup (https://opencode.ai/docs/go). | |
| 656 | 654 | func TestOpenCodeGoCatalog(t *testing.T) { | |
| 657 | 655 | cases := []struct { | |
| 658 | 656 | id string | |
| @@ -662,6 +660,7 @@ func TestOpenCodeGoCatalog(t *testing.T) { | |||
| 662 | 660 | priceOut float64 | |
| 663 | 661 | cacheRead float64 | |
| 664 | 662 | }{ | |
| 663 | + | {"gpt-5.6-luna", 1050000, 128000, 0.2, 1.2, 0.02}, | |
| 665 | 664 | {"grok-4.5", 500000, 500000, 2, 6, 0.3}, | |
| 666 | 665 | {"kimi-k3", 1048576, 131072, 3, 15, 0.3}, | |
| 667 | 666 | } | |
| @@ -680,6 +679,17 @@ func TestOpenCodeGoCatalog(t *testing.T) { | |||
| 680 | 679 | t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL) | |
| 681 | 680 | } | |
| 682 | 681 | } | |
| 682 | + | ||
| 683 | + | luna, err := FindModel("opencode-go", "gpt-5.6-luna") | |
| 684 | + | if err != nil { | |
| 685 | + | t.Fatal(err) | |
| 686 | + | } | |
| 687 | + | if luna.API != APIResponses || luna.PriceCacheWrite != 0.25 { | |
| 688 | + | t.Fatalf("opencode-go/gpt-5.6-luna API or cache-write price: %+v", luna) | |
| 689 | + | } | |
| 690 | + | if luna.PriceTierInputTokens != 272000 || luna.PriceInputAbove != 0.4 || luna.PriceOutputAbove != 1.8 || luna.PriceCacheReadAbove != 0.04 || luna.PriceCacheWriteAbove != 0.5 { | |
| 691 | + | t.Fatalf("opencode-go/gpt-5.6-luna long-context prices: %+v", luna) | |
| 692 | + | } | |
| 683 | 693 | } | |
| 684 | 694 | ||
| 685 | 695 | // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for | |
| @@ -649,10 +649,8 @@ func TestGPT56CatalogEntries(t *testing.T) { | |||||
| 649 | } | 649 | } | ||
| 650 | } | 650 | } | ||
| 651 | 651 | ||||
| 652 | - | // TestOpenCodeGoCatalog pins the Grok 4.5 and Kimi K3 entries added to | 652 | + | // TestOpenCodeGoCatalog pins entries added from the vendor's current Go |
| 653 | - | // the OpenCode Go provider. Both are listed in the vendor's current Go | 653 | + | // model lineup (https://opencode.ai/docs/go). |
| 654 | - | // model lineup (https://opencode.ai/docs/go) and were missing from the | |||
| 655 | - | // baked-in catalog. | |||
| 656 | func TestOpenCodeGoCatalog(t *testing.T) { | 654 | func TestOpenCodeGoCatalog(t *testing.T) { | ||
| 657 | cases := []struct { | 655 | cases := []struct { | ||
| 658 | id string | 656 | id string | ||
| @@ -662,6 +660,7 @@ func TestOpenCodeGoCatalog(t *testing.T) { | |||||
| 662 | priceOut float64 | 660 | priceOut float64 | ||
| 663 | cacheRead float64 | 661 | cacheRead float64 | ||
| 664 | }{ | 662 | }{ | ||
| 663 | + | {"gpt-5.6-luna", 1050000, 128000, 0.2, 1.2, 0.02}, | |||
| 665 | {"grok-4.5", 500000, 500000, 2, 6, 0.3}, | 664 | {"grok-4.5", 500000, 500000, 2, 6, 0.3}, | ||
| 666 | {"kimi-k3", 1048576, 131072, 3, 15, 0.3}, | 665 | {"kimi-k3", 1048576, 131072, 3, 15, 0.3}, | ||
| 667 | } | 666 | } | ||
| @@ -680,6 +679,17 @@ func TestOpenCodeGoCatalog(t *testing.T) { | |||||
| 680 | t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL) | 679 | t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL) | ||
| 681 | } | 680 | } | ||
| 682 | } | 681 | } | ||
| 682 | + | ||||
| 683 | + | luna, err := FindModel("opencode-go", "gpt-5.6-luna") | |||
| 684 | + | if err != nil { | |||
| 685 | + | t.Fatal(err) | |||
| 686 | + | } | |||
| 687 | + | if luna.API != APIResponses || luna.PriceCacheWrite != 0.25 { | |||
| 688 | + | t.Fatalf("opencode-go/gpt-5.6-luna API or cache-write price: %+v", luna) | |||
| 689 | + | } | |||
| 690 | + | if luna.PriceTierInputTokens != 272000 || luna.PriceInputAbove != 0.4 || luna.PriceOutputAbove != 1.8 || luna.PriceCacheReadAbove != 0.04 || luna.PriceCacheWriteAbove != 0.5 { | |||
| 691 | + | t.Fatalf("opencode-go/gpt-5.6-luna long-context prices: %+v", luna) | |||
| 692 | + | } | |||
| 683 | } | 693 | } | ||
| 684 | 694 | ||||
| 685 | // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for | 695 | // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for | ||
M packages/provider/reasoning.go +7 -5
| @@ -5,8 +5,8 @@ import "strings" | |||
| 5 | 5 | var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"} | |
| 6 | 6 | ||
| 7 | 7 | // AvailableReasoningLevels returns the distinct reasoning levels supported by | |
| 8 | - | // a model. Protocol defaults are adjusted by optional per-model overrides. The | |
| 9 | - | // empty string represents off. | |
| 8 | + | // a model. Optional per-model overrides can remove, remap, or extend protocol | |
| 9 | + | // defaults. The empty string represents off. | |
| 10 | 10 | func AvailableReasoningLevels(model Model) []string { | |
| 11 | 11 | defaults := defaultReasoningLevels(model) | |
| 12 | 12 | if !model.Reasoning || len(model.ReasoningLevelMap) == 0 { | |
| @@ -24,9 +24,6 @@ func AvailableReasoningLevels(model Model) []string { | |||
| 24 | 24 | if mapped, overridden := model.ReasoningLevelMap[level]; overridden { | |
| 25 | 25 | effective = NormalizeReasoning(mapped) | |
| 26 | 26 | } | |
| 27 | - | if effective != "" && !containsReasoningLevel(defaults, effective) { | |
| 28 | - | effective = nearestReasoningLevel(defaults, effective) | |
| 29 | - | } | |
| 30 | 27 | if reasoningLevelRank(effective) > 0 { | |
| 31 | 28 | available[effective] = true | |
| 32 | 29 | } | |
| @@ -90,6 +87,11 @@ func containsReasoningLevel(levels []string, target string) bool { | |||
| 90 | 87 | return false | |
| 91 | 88 | } | |
| 92 | 89 | ||
| 90 | + | func hasReasoningLevelOverride(model Model, level string) bool { | |
| 91 | + | _, ok := model.ReasoningLevelMap[NormalizeReasoning(level)] | |
| 92 | + | return ok | |
| 93 | + | } | |
| 94 | + | ||
| 93 | 95 | // ClampReasoningForModel maps a configured level to the nearest level exposed | |
| 94 | 96 | // for the active model. Ties prefer the higher level. | |
| 95 | 97 | func ClampReasoningForModel(model Model, level string) string { | |
| @@ -5,8 +5,8 @@ import "strings" | |||||
| 5 | var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"} | 5 | var reasoningLevelOrder = []string{"", "minimum", "low", "medium", "high", "xhigh", "max"} | ||
| 6 | 6 | ||||
| 7 | // AvailableReasoningLevels returns the distinct reasoning levels supported by | 7 | // AvailableReasoningLevels returns the distinct reasoning levels supported by | ||
| 8 | - | // a model. Protocol defaults are adjusted by optional per-model overrides. The | 8 | + | // a model. Optional per-model overrides can remove, remap, or extend protocol |
| 9 | - | // empty string represents off. | 9 | + | // defaults. The empty string represents off. |
| 10 | func AvailableReasoningLevels(model Model) []string { | 10 | func AvailableReasoningLevels(model Model) []string { | ||
| 11 | defaults := defaultReasoningLevels(model) | 11 | defaults := defaultReasoningLevels(model) | ||
| 12 | if !model.Reasoning || len(model.ReasoningLevelMap) == 0 { | 12 | if !model.Reasoning || len(model.ReasoningLevelMap) == 0 { | ||
| @@ -24,9 +24,6 @@ func AvailableReasoningLevels(model Model) []string { | |||||
| 24 | if mapped, overridden := model.ReasoningLevelMap[level]; overridden { | 24 | if mapped, overridden := model.ReasoningLevelMap[level]; overridden { | ||
| 25 | effective = NormalizeReasoning(mapped) | 25 | effective = NormalizeReasoning(mapped) | ||
| 26 | } | 26 | } | ||
| 27 | - | if effective != "" && !containsReasoningLevel(defaults, effective) { | |||
| 28 | - | effective = nearestReasoningLevel(defaults, effective) | |||
| 29 | - | } | |||
| 30 | if reasoningLevelRank(effective) > 0 { | 27 | if reasoningLevelRank(effective) > 0 { | ||
| 31 | available[effective] = true | 28 | available[effective] = true | ||
| 32 | } | 29 | } | ||
| @@ -90,6 +87,11 @@ func containsReasoningLevel(levels []string, target string) bool { | |||||
| 90 | return false | 87 | return false | ||
| 91 | } | 88 | } | ||
| 92 | 89 | ||||
| 90 | + | func hasReasoningLevelOverride(model Model, level string) bool { | |||
| 91 | + | _, ok := model.ReasoningLevelMap[NormalizeReasoning(level)] | |||
| 92 | + | return ok | |||
| 93 | + | } | |||
| 94 | + | ||||
| 93 | // ClampReasoningForModel maps a configured level to the nearest level exposed | 95 | // ClampReasoningForModel maps a configured level to the nearest level exposed | ||
| 94 | // for the active model. Ties prefer the higher level. | 96 | // for the active model. Ties prefer the higher level. | ||
| 95 | func ClampReasoningForModel(model Model, level string) string { | 97 | func ClampReasoningForModel(model Model, level string) string { | ||
M packages/provider/reasoning_test.go +34 -10
| @@ -29,7 +29,17 @@ func TestAvailableReasoningLevels(t *testing.T) { | |||
| 29 | 29 | Provider: "anthropic", Reasoning: true, | |
| 30 | 30 | ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"}, | |
| 31 | 31 | }, | |
| 32 | - | want: []string{"", "low", "medium", "xhigh"}, | |
| 32 | + | want: []string{"", "low", "medium", "xhigh", "max"}, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "explicit levels extend generic defaults", | |
| 36 | + | model: Model{ | |
| 37 | + | Provider: "custom", Reasoning: true, | |
| 38 | + | ReasoningLevelMap: map[string]string{ | |
| 39 | + | "minimum": "", "low": "", "medium": "", "high": "high", "xhigh": "xhigh", "max": "max", | |
| 40 | + | }, | |
| 41 | + | }, | |
| 42 | + | want: []string{"", "high", "xhigh", "max"}, | |
| 33 | 43 | }, | |
| 34 | 44 | } | |
| 35 | 45 | for _, tt := range tests { | |
| @@ -59,6 +69,12 @@ func TestClampReasoningForModel(t *testing.T) { | |||
| 59 | 69 | level: "minimum", | |
| 60 | 70 | want: "high", | |
| 61 | 71 | }, | |
| 72 | + | { | |
| 73 | + | name: "explicit max extends generic defaults", | |
| 74 | + | model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"max": "max"}}, | |
| 75 | + | level: "max", | |
| 76 | + | want: "max", | |
| 77 | + | }, | |
| 62 | 78 | } | |
| 63 | 79 | for _, tt := range tests { | |
| 64 | 80 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -71,20 +87,28 @@ func TestClampReasoningForModel(t *testing.T) { | |||
| 71 | 87 | ||
| 72 | 88 | func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) { | |
| 73 | 89 | SetLiveModels([]Model{{ | |
| 74 | - | Provider: "openai", | |
| 90 | + | Provider: "custom", | |
| 75 | 91 | ID: "mapped-reasoning-model", | |
| 76 | 92 | Reasoning: true, | |
| 77 | - | ReasoningLevelMap: map[string]string{"high": "low"}, | |
| 93 | + | ReasoningLevelMap: map[string]string{"high": "low", "max": "max"}, | |
| 78 | 94 | }}) | |
| 79 | 95 | t.Cleanup(func() { SetLiveModels(nil) }) | |
| 80 | 96 | ||
| 81 | - | client := NewOpenAI("test", "").(*openaiClient) | |
| 82 | - | request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"}) | |
| 83 | - | if err != nil { | |
| 84 | - | t.Fatal(err) | |
| 85 | - | } | |
| 86 | - | if request.ReasoningEffort != "low" { | |
| 87 | - | t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort) | |
| 97 | + | client := NewOpenAICompat("custom", "test", "", "").(*openaiClient) | |
| 98 | + | for _, tt := range []struct { | |
| 99 | + | requested string | |
| 100 | + | want string | |
| 101 | + | }{ | |
| 102 | + | {requested: "high", want: "low"}, | |
| 103 | + | {requested: "max", want: "max"}, | |
| 104 | + | } { | |
| 105 | + | request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: tt.requested}) | |
| 106 | + | if err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | if request.ReasoningEffort != tt.want { | |
| 110 | + | t.Errorf("reasoning effort for %q = %q, want %q", tt.requested, request.ReasoningEffort, tt.want) | |
| 111 | + | } | |
| 88 | 112 | } | |
| 89 | 113 | } | |
| 90 | 114 | ||
| @@ -29,7 +29,17 @@ func TestAvailableReasoningLevels(t *testing.T) { | |||||
| 29 | Provider: "anthropic", Reasoning: true, | 29 | Provider: "anthropic", Reasoning: true, | ||
| 30 | ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"}, | 30 | ReasoningLevelMap: map[string]string{"minimum": "low", "high": "", "max": "max"}, | ||
| 31 | }, | 31 | }, | ||
| 32 | - | want: []string{"", "low", "medium", "xhigh"}, | 32 | + | want: []string{"", "low", "medium", "xhigh", "max"}, |
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "explicit levels extend generic defaults", | |||
| 36 | + | model: Model{ | |||
| 37 | + | Provider: "custom", Reasoning: true, | |||
| 38 | + | ReasoningLevelMap: map[string]string{ | |||
| 39 | + | "minimum": "", "low": "", "medium": "", "high": "high", "xhigh": "xhigh", "max": "max", | |||
| 40 | + | }, | |||
| 41 | + | }, | |||
| 42 | + | want: []string{"", "high", "xhigh", "max"}, | |||
| 33 | }, | 43 | }, | ||
| 34 | } | 44 | } | ||
| 35 | for _, tt := range tests { | 45 | for _, tt := range tests { | ||
| @@ -59,6 +69,12 @@ func TestClampReasoningForModel(t *testing.T) { | |||||
| 59 | level: "minimum", | 69 | level: "minimum", | ||
| 60 | want: "high", | 70 | want: "high", | ||
| 61 | }, | 71 | }, | ||
| 72 | + | { | |||
| 73 | + | name: "explicit max extends generic defaults", | |||
| 74 | + | model: Model{Reasoning: true, ReasoningLevelMap: map[string]string{"max": "max"}}, | |||
| 75 | + | level: "max", | |||
| 76 | + | want: "max", | |||
| 77 | + | }, | |||
| 62 | } | 78 | } | ||
| 63 | for _, tt := range tests { | 79 | for _, tt := range tests { | ||
| 64 | t.Run(tt.name, func(t *testing.T) { | 80 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -71,20 +87,28 @@ func TestClampReasoningForModel(t *testing.T) { | |||||
| 71 | 87 | ||||
| 72 | func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) { | 88 | func TestOpenAIRequestUsesReasoningLevelMap(t *testing.T) { | ||
| 73 | SetLiveModels([]Model{{ | 89 | SetLiveModels([]Model{{ | ||
| 74 | - | Provider: "openai", | 90 | + | Provider: "custom", |
| 75 | ID: "mapped-reasoning-model", | 91 | ID: "mapped-reasoning-model", | ||
| 76 | Reasoning: true, | 92 | Reasoning: true, | ||
| 77 | - | ReasoningLevelMap: map[string]string{"high": "low"}, | 93 | + | ReasoningLevelMap: map[string]string{"high": "low", "max": "max"}, |
| 78 | }}) | 94 | }}) | ||
| 79 | t.Cleanup(func() { SetLiveModels(nil) }) | 95 | t.Cleanup(func() { SetLiveModels(nil) }) | ||
| 80 | 96 | ||||
| 81 | - | client := NewOpenAI("test", "").(*openaiClient) | 97 | + | client := NewOpenAICompat("custom", "test", "", "").(*openaiClient) |
| 82 | - | request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: "high"}) | 98 | + | for _, tt := range []struct { |
| 83 | - | if err != nil { | 99 | + | requested string |
| 84 | - | t.Fatal(err) | 100 | + | want string |
| 85 | - | } | 101 | + | }{ |
| 86 | - | if request.ReasoningEffort != "low" { | 102 | + | {requested: "high", want: "low"}, |
| 87 | - | t.Fatalf("reasoning effort = %q, want low", request.ReasoningEffort) | 103 | + | {requested: "max", want: "max"}, |
| 104 | + | } { | |||
| 105 | + | request, err := client.buildRequest(Request{Model: "mapped-reasoning-model", Reasoning: tt.requested}) | |||
| 106 | + | if err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | if request.ReasoningEffort != tt.want { | |||
| 110 | + | t.Errorf("reasoning effort for %q = %q, want %q", tt.requested, request.ReasoningEffort, tt.want) | |||
| 111 | + | } | |||
| 88 | } | 112 | } | ||
| 89 | } | 113 | } | ||
| 90 | 114 | ||||