diff --git a/internal/provider/opencode_client.go b/internal/provider/opencode_client.go index 73d3405e..19068a18 100644 --- a/internal/provider/opencode_client.go +++ b/internal/provider/opencode_client.go @@ -15,6 +15,10 @@ import ( // "OpenCode 1.18.0 or newer is required to use the free tier"). const OpenCodeVersion = "1.18.34" +// OpenCodeAnonymousKey is the key OpenCode asks Zen with when no one is +// signed in to it, which Zen's free models answer. +const OpenCodeAnonymousKey = "public" + // OpenCodeClient makes a request to OpenCode's gateway (Zen or Go) carry // what OpenCode itself sends there (packages/opencode/src/session/llm/ // request.ts, for a provider whose id starts with "opencode"): its diff --git a/internal/provider/opencode_zen_test.go b/internal/provider/opencode_zen_test.go index 028b7751..5d0c9b36 100644 --- a/internal/provider/opencode_zen_test.go +++ b/internal/provider/opencode_zen_test.go @@ -2,6 +2,7 @@ package provider import ( "encoding/json" + "path/filepath" "testing" "github.com/yetone/magpie/internal/catalog" @@ -43,3 +44,42 @@ func TestZenFreeProbe(t *testing.T) { } } } + +// #572: an OpenCode Zen provider added with no key asks with the one +// OpenCode sends signed out, which Zen's free models answer; a key of the +// user's own is kept. +func TestOpenCodeZenNeedsNoKey(t *testing.T) { + isolate(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + p, err := FromPreset("opencode-zen") + if err != nil { + t.Fatal(err) + } + p.Models = []string{"mimo-v2.6-flash-free"} + id, err := Add(p) + if err != nil { + t.Fatal(err) + } + got, err := Find(id) + if err != nil { + t.Fatal(err) + } + if got.Key != OpenCodeAnonymousKey { + t.Fatalf("key %q", got.Key) + } + p.Name, p.Key = "Zen paid", "sk-own" + if id, err = Add(p); err != nil { + t.Fatal(err) + } + if got, _ = Find(id); got.Key != "sk-own" { + t.Fatalf("own key %q", got.Key) + } + // another provider still needs one + if _, err := Add(Provider{Name: "Relay", Chat: "https://api.relay.example/v1", Models: []string{"m"}}); err == nil { + t.Fatal("a relay with no key") + } +} diff --git a/internal/provider/presets.go b/internal/provider/presets.go index 4d4038db..a9fa6531 100644 --- a/internal/provider/presets.go +++ b/internal/provider/presets.go @@ -312,8 +312,9 @@ var presets = []PresetDef{ Models: []string{"cline-pass/glm-5.3", "cline-pass/glm-5.3-flash", "cline-pass/kimi-k3", "cline-pass/deepseek-v4-pro", "cline-pass/deepseek-v4.1-flash", "cline-pass/mimo-v2.5", "cline-pass/mimo-v2.5-pro", "cline-pass/minimax-m3", "cline-pass/muse-spark-1.3-contributor", "cline-pass/qwen3.8-max", "cline-pass/qwen3.7-max", "cline-pass/qwen3.7-plus"}}, - {ID: "opencode-zen", Name: "OpenCode Zen", Icon: "opencode", Kind: KindRelay, Catalog: "opencode", - Chat: "https://opencode.ai/zen/v1", Responses: "https://opencode.ai/zen/v1", Anthropic: "https://opencode.ai/zen", + {ID: "opencode-zen", Name: "OpenCode Zen", Icon: "opencode", Kind: KindRelay, Catalog: "opencode", NoKey: true, + KeyHint: "optional: free models need no key", + Chat: "https://opencode.ai/zen/v1", Responses: "https://opencode.ai/zen/v1", Anthropic: "https://opencode.ai/zen", // its free models (-free) are served to OpenCode alone, which // magpie asks them as (OpenCodeFree) Website: "https://opencode.ai/docs/zen", KeysURL: "https://opencode.ai/auth"}, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f47744fb..c648777e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -694,6 +694,12 @@ func normalize(p Provider) Provider { if p.Preset == "qianfan-token-plan" { p.Preset = "baidu-qianfan" } + // OpenCode Zen serves its free models (-free) signed out, to the key + // OpenCode itself sends then: a Zen provider saved with no key of its + // own asks with that one + if p.Preset == "opencode-zen" && p.Key == "" { + p.Key = OpenCodeAnonymousKey + } if p.Routing != Ordered && p.Routing != Rotate && p.Routing != LeastUsed && p.Routing != Pace { p.Routing = "" }