detached da49ca5

feat: integrate upstream provider and proxy improvements

commit
da49ca5bf7fb146a6814b4fd8d7427a9dab548ac
author
brice · · 2026-08-10 13:50 UTC
committer
brice · · 2026-08-11 07:21 UTC
parent
59c2524
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +271 -8
M README.md +12 -0
@@ -151,6 +151,18 @@ Ponytail coding mode is enabled by default, including when an existing `config.j
151151
152152 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.
153153
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+
154166 ## Persistent instructions (AGENTS.md)
155167
156168 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.
@@ -151,6 +151,18 @@ Ponytail coding mode is enabled by default, including when an existing `config.j
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.
M docs/providers.md +14 -0
@@ -4,6 +4,20 @@ zut ships with built-in providers and a model catalog. You can select models
44 with `/model`, list them with `zut --list-models`, and add private models in
55 `$ZUT_HOME/models.json`.
66
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+
721 ## Login methods
822
923 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 in4 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 methods21 ## 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 packages/agent/cli.go +4 -0
@@ -765,6 +765,10 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
765765
766766 // Run is the top-level entrypoint for the zut binary.
767767 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+
768772 // Extension installs can invoke external build and clone processes.
769773 // Give those processes a cancellation context without changing signal
770774 // handling for the interactive modes below.
@@ -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 signal773 // Give those processes a cancellation context without changing signal
770 // handling for the interactive modes below.774 // handling for the interactive modes below.
M packages/agent/config.go +24 -0
@@ -181,6 +181,10 @@ type Config struct {
181181 // Insecure skips TLS verification for custom inference endpoints.
182182 Insecure bool `json:"insecure,omitempty"`
183183
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+
184188 // LastChangelogShown is the version whose release-notes
185189 // dialog the user has already seen. When the running binary's
186190 // version differs, the next interactive run shows the
@@ -332,6 +336,26 @@ func SaveConfig(c Config) error {
332336 return os.WriteFile(ConfigPath(), b, 0o644)
333337 }
334338
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+
335359 // AuthStoreFor returns the auth.Store backed by AuthPath().
336360 func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
337361
@@ -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-notes188 // LastChangelogShown is the version whose release-notes
185 // dialog the user has already seen. When the running binary's189 // dialog the user has already seen. When the running binary's
186 // version differs, the next interactive run shows the190 // 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+}
M packages/provider/catalog_builtin.go +1 -0
@@ -308,6 +308,7 @@ var builtinCatalog = []Model{
308308 {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"},
309309 {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"},
310310 {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"},
311312 {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"},
312313 {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"},
313314 {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 {
124124 return NewOpenAICompat("opencode", apiKey, baseURL, "https://opencode.ai/zen/v1")
125125 }
126126
127-// NewOpenCodeGo is the opencode-go variant.
127+// NewOpenCodeGo routes each model through the wire API exposed by OpenCode Go.
128128 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+ })
130135 }
131136
132137 // 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 case137 // NewMinimaxOpenAI is the OpenAI-completions flavor of MiniMax, in case
M packages/provider/llamacpp.go +16 -2
@@ -47,6 +47,10 @@ type LlamaCPPModel struct {
4747 Size int64 `json:"size,omitempty"`
4848 FileType string `json:"ftype,omitempty"`
4949 } `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"`
5054 }
5155
5256 // LlamaCPPBytes describes byte progress for one downloaded file.
@@ -204,7 +208,13 @@ func (c *LlamaCPPClient) watch(ctx context.Context, events chan<- llamaCPPEvent)
204208 if c.APIKey != "" {
205209 req.Header.Set("authorization", "Bearer "+c.APIKey)
206210 }
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)
208218 if err != nil {
209219 return err
210220 }
@@ -408,7 +418,11 @@ func (c *LlamaCPPClient) DownloadAndWait(ctx context.Context, model string, upda
408418 }
409419 if entry.Status.Value == "downloading" {
410420 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 {
412426 update(progress)
413427 }
414428 } 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 err219 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 = true420 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) {
3232 }
3333 }
3434
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+
3591 func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) {
3692 var mu sync.Mutex
3793 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.Mutex92 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) {
4848 t.Fatal("expected missing API client error")
4949 }
5050 }
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/provider_test.go +14 -4
@@ -649,10 +649,8 @@ func TestGPT56CatalogEntries(t *testing.T) {
649649 }
650650 }
651651
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).
656654 func TestOpenCodeGoCatalog(t *testing.T) {
657655 cases := []struct {
658656 id string
@@ -662,6 +660,7 @@ func TestOpenCodeGoCatalog(t *testing.T) {
662660 priceOut float64
663661 cacheRead float64
664662 }{
663+ {"gpt-5.6-luna", 1050000, 128000, 0.2, 1.2, 0.02},
665664 {"grok-4.5", 500000, 500000, 2, 6, 0.3},
666665 {"kimi-k3", 1048576, 131072, 3, 15, 0.3},
667666 }
@@ -680,6 +679,17 @@ func TestOpenCodeGoCatalog(t *testing.T) {
680679 t.Fatalf("opencode-go/%s baseURL: %s", tc.id, m.BaseURL)
681680 }
682681 }
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+ }
683693 }
684694
685695 // 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 to652+// TestOpenCodeGoCatalog pins entries added from the vendor's current Go
653-// the OpenCode Go provider. Both are listed in the vendor's current Go653+// 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 string656 id string
@@ -662,6 +660,7 @@ func TestOpenCodeGoCatalog(t *testing.T) {
662 priceOut float64660 priceOut float64
663 cacheRead float64661 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 for695 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close