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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 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. | |
| @@ -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 | |||
| 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 packages/agent/cli.go +4 -0
| @@ -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. | |
| @@ -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. | ||
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 | + | } | |||
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/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 | ||