diff --git a/internal/gateway/continuation.go b/internal/gateway/continuation.go index 89f9e70f..4426017b 100644 --- a/internal/gateway/continuation.go +++ b/internal/gateway/continuation.go @@ -322,6 +322,9 @@ func (s *Server) streamTranslated(w http.ResponseWriter, r *http.Request, p prov b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) res.Body.Close() failed, failedStatus = p.Explain(p.Name+": "+provider.APIError(b, res.Status), res.StatusCode, b), res.StatusCode + if wrongEndpoint(res.StatusCode, b) { + failed += wrongAPINote(p, model, res) + } if res.StatusCode == http.StatusTooManyRequests && accountAgent(p) == "antigravity" && antigravityTurnsAway(request.System) { failed += " — " + antigravityTurnedAwayHint } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 09588752..81e147b5 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -3025,6 +3025,7 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider. u.RequestID = "" // another endpoint answers, with its own id return res.StatusCode, msg, false } + msg += wrongAPINote(p, model, res) } if p.Preset == "openrouter" && openRouterSharedPool(b) { markOpenRouterSharedPool(w) @@ -3728,6 +3729,39 @@ func wrongEndpoint(status int, body []byte) bool { return false } +// wrongAPINote goes after an upstream's word that the model isn't served +// on the API it was asked on (wrongEndpoint), when no other API is left to +// ask: the URL asked, and the APIs the vendor's list serves the model on +// that the provider has no URL for, so "Model does not support this +// protocol" says which protocol, and what to set (#1215). +func wrongAPINote(p provider.Provider, model string, res *http.Response) string { + note := "" + if res != nil && res.Request != nil && res.Request.URL != nil { + u := *res.Request.URL + u.RawQuery, u.User = "", nil + note = " (asked at " + u.String() + } + paths := map[provider.Protocol]string{provider.Chat: "/v1/chat/completions", provider.Responses: "/v1/responses", provider.Anthropic: "/v1/messages"} + var missing []string + for _, a := range p.APIs(model) { + if paths[a] != "" && p.Base(a) == "" { + missing = append(missing, paths[a]) + } + } + if len(missing) > 0 { + if note == "" { + note = " (" + } else { + note += "; " + } + note += model + " is served on " + strings.Join(missing, " or ") + ", which " + p.Name + " has no URL for" + } + if note != "" { + note += ")" + } + return note +} + // translate serves a client API the provider lacks by speaking another // one to it. The provider is always streamed; the client gets whichever // it asked for. @@ -3785,6 +3819,9 @@ func (s *Server) translate(w http.ResponseWriter, r *http.Request, p provider.Pr if res.StatusCode >= 400 { b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) msg := p.Explain(p.Name+": "+provider.APIError(b, res.Status), res.StatusCode, b) + if wrongEndpoint(res.StatusCode, b) { + msg += wrongAPINote(p, model, res) + } if res.StatusCode == http.StatusTooManyRequests && accountAgent(p) == "antigravity" && antigravityTurnsAway(request.System) { msg += " — " + antigravityTurnedAwayHint markAntigravityTurnsAway(w) diff --git a/internal/gateway/opencode_wire_test.go b/internal/gateway/opencode_wire_test.go new file mode 100644 index 00000000..d22a3725 --- /dev/null +++ b/internal/gateway/opencode_wire_test.go @@ -0,0 +1,167 @@ +package gateway + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// openCodeGoCatalog is models.dev's opencode-go as it lists the models of +// #1215 (https://models.dev/api.json, 2026-10-08): GPT, Grok and Muse +// Spark through @ai-sdk/openai, MiniMax through @ai-sdk/anthropic, GLM +// with no provider of its own (the provider's @ai-sdk/openai-compatible). +const openCodeGoCatalog = `{ + "opencode-go": {"id":"opencode-go","npm":"@ai-sdk/openai-compatible","api":"https://opencode.ai/zen/go/v1","models": { + "gpt-6-luna": {"id":"gpt-6-luna","name":"GPT-6 Luna","provider":{"npm":"@ai-sdk/openai"}}, + "grok-4.7": {"id":"grok-4.7","name":"Grok 4.7","provider":{"npm":"@ai-sdk/openai"}}, + "muse-spark-1.3-contributor": {"id":"muse-spark-1.3-contributor","name":"Muse Spark 1.3","provider":{"npm":"@ai-sdk/openai"}}, + "minimax-m2.7": {"id":"minimax-m2.7","name":"MiniMax M2.7","provider":{"npm":"@ai-sdk/anthropic"}}, + "glm-5.3": {"id":"glm-5.3","name":"GLM-5.3"}}}, + "openrouter": {"id":"openrouter","models": { + "gpt-6-luna": {"id":"gpt-6-luna","name":"GPT-6 Luna","provider":{"npm":"@ai-sdk/openai"}}}} +}` + +func writeCatalog(t *testing.T, data string) { + t.Helper() + os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755) + if err := os.WriteFile(catalog.CachePath(), []byte(data), 0o644); err != nil { + t.Fatal(err) + } + catalog.Reset() + t.Cleanup(catalog.Reset) +} + +// #1215: an OpenCode Go provider saved with its chat URL alone (added by +// hand, as the reporter's "OpenCode Go 1x" was) sent every model to +// /zen/go/v1/chat/completions, and Go answers GPT, Grok, Muse Spark and +// MiniMax there with 400 "Model does not support this protocol." whatever +// API the client spoke. Each model now goes on the API Go serves it on, +// as models.dev's opencode-go says, from all three of magpie's APIs; GLM +// keeps going on the client's own. +func TestOpenCodeGoModelsGoOnTheirOwnAPI(t *testing.T) { + fresh(t) + writeCatalog(t, openCodeGoCatalog) + if err := provider.Save(provider.Provider{ID: "opencode-go", Name: "OpenCode Go 1x", Key: "k", Chat: "https://opencode.ai/zen/go/v1", + Models: []string{"gpt-6-luna", "grok-4.7", "muse-spark-1.3-contributor", "minimax-m2.7", "glm-5.3"}}); err != nil { + t.Fatal(err) + } + // Go's own answers: the model's API takes it (here 401, as Go answers + // a request with no key it knows, 2026-10-08), the other two turn it + // away as Go does with a key + own := map[string]string{"gpt-6-luna": "/zen/go/v1/responses", "grok-4.7": "/zen/go/v1/responses", + "muse-spark-1.3-contributor": "/zen/go/v1/responses", "minimax-m2.7": "/zen/go/v1/messages"} + var mu sync.Mutex + asked := map[string][]string{} + s := New() + s.client = &http.Client{Transport: countTransport(func(r *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(r.Body) + r.Body.Close() + model := modelOf(b) + mu.Lock() + asked[model] = append(asked[model], r.URL.Host+r.URL.Path) + mu.Unlock() + reply := func(code int, body string) (*http.Response, error) { + return &http.Response{StatusCode: code, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + } + if r.Method != http.MethodPost { + return reply(404, `{}`) + } + if want, ok := own[model]; ok && r.URL.Path != want { + return reply(400, `{"type":"error","error":{"type":"invalid_request_error","message":"Model does not support this protocol."}}`) + } + return reply(401, `{"type":"error","error":{"type":"AuthError","message":"Missing API key."}}`) + })} + clients := map[string]string{ + "/v1/chat/completions": `{"model":"opencode-go/%s","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`, + "/v1/messages": `{"model":"opencode-go/%s","max_tokens":8,"messages":[{"role":"user","content":"hi"}]}`, + "/v1/responses": `{"model":"opencode-go/%s","input":"hi","max_output_tokens":16}`, + } + for _, model := range []string{"gpt-6-luna", "grok-4.7", "muse-spark-1.3-contributor", "minimax-m2.7", "glm-5.3"} { + for path, body := range clients { + mu.Lock() + asked = map[string][]string{} + mu.Unlock() + req := httptest.NewRequest("POST", path, strings.NewReader(strings.Replace(body, "%s", model, 1))) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + mu.Lock() + got := asked[model] + mu.Unlock() + if len(got) == 0 { + t.Errorf("%s on %s: upstream never asked (%d %s)", model, path, rec.Code, rec.Body) + continue + } + want := "opencode.ai" + own[model] + if own[model] == "" { + // GLM: on the API the client spoke + want = "opencode.ai/zen/go" + path + } + if got[0] != want { + t.Errorf("%s on %s: asked %v, want %s first (%d %s)", model, path, got, want, rec.Code, rec.Body) + } + if strings.Contains(rec.Body.String(), "does not support this protocol") { + t.Errorf("%s on %s: %d %s", model, path, rec.Code, rec.Body) + } + } + } +} + +// The rule is OpenCode's gateway's alone: another relay saved with its +// chat URL alone keeps it, and its models stay on it, though models.dev +// says OpenAI's SDK serves the same model elsewhere. +func TestOtherRelayKeepsItsOneAPI(t *testing.T) { + fresh(t) + writeCatalog(t, openCodeGoCatalog) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Catalog: "openrouter", Chat: "https://relay.test/v1", Models: []string{"gpt-6-luna"}}); err != nil { + t.Fatal(err) + } + p, err := provider.Find("relay") + if err != nil { + t.Fatal(err) + } + if p.Responses != "" || p.Anthropic != "" || p.Catalog != "openrouter" { + t.Fatalf("relay changed: %+v", p) + } + if apis := p.APIs("gpt-6-luna"); apis != nil { + t.Errorf("relay gpt-6-luna APIs %v, want none named", apis) + } + if got := nativeEndpoints(provider.Entry{Provider: *p, Model: "gpt-6-luna", ID: "relay/gpt-6-luna"}); len(got) != 1 || got[0] != "/v1/chat/completions" { + t.Errorf("relay native_endpoints %v", got) + } +} + +// When the upstream says the model isn't served on the API asked and no +// other is left, the error says which URL was asked, and which API the +// vendor's list serves the model on that the provider has no URL for +// (#1215 asked for the error to be diagnosable). A key in the query isn't +// repeated. +func TestWrongAPINote(t *testing.T) { + fresh(t) + writeCatalog(t, openCodeGoCatalog) + // as a provider is before normalize fills in Go's other APIs: one the + // user cleared by hand, say, or a remote's + p := provider.Provider{ID: "opencode-go", Name: "OpenCode Go", Catalog: "opencode-go", Chat: "https://opencode.ai/zen/go/v1"} + u, _ := url.Parse("https://opencode.ai/zen/go/v1/chat/completions?key=secret") + res := &http.Response{Request: &http.Request{URL: u}} + got := wrongAPINote(p, "gpt-6-luna", res) + for _, want := range []string{"asked at https://opencode.ai/zen/go/v1/chat/completions", "gpt-6-luna is served on /v1/responses, which OpenCode Go has no URL for"} { + if !strings.Contains(got, want) { + t.Errorf("note %q lacks %q", got, want) + } + } + if strings.Contains(got, "secret") { + t.Errorf("note repeats the query: %q", got) + } + if got := wrongAPINote(p, "glm-5.3", res); got != " (asked at https://opencode.ai/zen/go/v1/chat/completions)" { + t.Errorf("glm note %q", got) + } +} diff --git a/internal/provider/account.go b/internal/provider/account.go index 7db21449..f513c9ed 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -160,9 +160,10 @@ func (p Provider) ListedAPIs(model string) []Protocol { } // OpenCode serves some models on OpenAI's Responses API only (Grok, // GPT) or Anthropic's (Claude, MiniMax), and turns the others away: - // models.dev says which + // models.dev says which, in the list of the gateway its URL is at, + // whatever catalog the provider was saved with (#1215) if p.IsOpenCode() { - for _, c := range p.Catalogs() { + for _, c := range p.openCodeCatalogs() { if a := catalog.APIOf(c, model); a != "" { return []Protocol{Protocol(a)} } diff --git a/internal/provider/opencode_endpoints.go b/internal/provider/opencode_endpoints.go new file mode 100644 index 00000000..38cc763d --- /dev/null +++ b/internal/provider/opencode_endpoints.go @@ -0,0 +1,70 @@ +package provider + +import ( + "cmp" + "net/url" + "strings" +) + +// openCodeRoot is the root of OpenCode's gateway a base URL is at — Zen's +// (https://opencode.ai/zen) or Go's (…/zen/go), given with /v1 or without — +// and the models.dev provider that lists its models: opencode or +// opencode-go. Both are "" for any other URL. +func openCodeRoot(base string) (root, catalogID string) { + u, err := url.Parse(strings.TrimRight(strings.TrimSpace(base), "/")) + if err != nil || u.Scheme == "" { + return "", "" + } + if h := strings.ToLower(u.Hostname()); h != "opencode.ai" && !strings.HasSuffix(h, ".opencode.ai") { + return "", "" + } + switch path := strings.TrimSuffix(strings.TrimRight(u.Path, "/"), "/v1"); path { + case "/zen/go": + return u.Scheme + "://" + u.Host + path, "opencode-go" + case "/zen": + return u.Scheme + "://" + u.Host + path, "opencode" + } + return "", "" +} + +// openCodeEndpoints fills in the APIs of OpenCode's gateway a provider at +// it was saved without. Zen and Go serve each model on one API of three, +// all under one root — chat completions and Responses at /v1, Anthropic's +// Messages at the root — and turn it away on the other two. OpenCode asks +// a model through the AI SDK package models.dev's per-model provider.npm +// names: @ai-sdk/openai (Responses) for GPT, Grok and Muse Spark, +// @ai-sdk/anthropic (Messages) for Claude, MiniMax and some Qwen, and +// @ai-sdk/openai-compatible (chat) for the rest. A provider at Go added by +// hand or brought from another app with its chat URL alone had nowhere +// but chat to send GPT, Grok, MiniMax or Muse Spark, which Go answers +// with "Model does not support this protocol" (#1215). A URL the user gave +// an API keeps it, and so does a catalog they named. +func (p *Provider) openCodeEndpoints() { + root, catalogID := "", "" + for _, u := range []string{p.Chat, p.Responses, p.Anthropic} { + if root, catalogID = openCodeRoot(u); root != "" { + break + } + } + if root == "" { + return + } + p.Chat = cmp.Or(p.Chat, root+"/v1") + p.Responses = cmp.Or(p.Responses, root+"/v1") + p.Anthropic = cmp.Or(p.Anthropic, root) + p.Catalog = cmp.Or(p.Catalog, catalogID) +} + +// openCodeCatalogs are the models.dev providers that say which API a model +// of OpenCode's gateway is served on: the list of the gateway its URL is +// at (Go's for Go, Zen's for Zen) alone, whatever catalog the provider was +// saved with — the two serve one model on different APIs (MiniMax M2.7 on +// Messages at Go, on chat at Zen) — or, at another path, its own. +func (p Provider) openCodeCatalogs() []string { + for _, u := range []string{p.Chat, p.Responses, p.Anthropic} { + if _, id := openCodeRoot(u); id != "" { + return []string{id} + } + } + return p.Catalogs() +} diff --git a/internal/provider/opencode_endpoints_test.go b/internal/provider/opencode_endpoints_test.go new file mode 100644 index 00000000..e688a6bc --- /dev/null +++ b/internal/provider/opencode_endpoints_test.go @@ -0,0 +1,78 @@ +package provider + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/yetone/magpie/internal/catalog" +) + +// #1215: a provider at OpenCode's gateway saved with one URL gets the +// other two of that gateway's, and the models.dev list of the gateway its +// URL is at says which API each model goes on — Go's list for Go and +// Zen's for Zen, even when it was saved with the other's catalog. A URL +// or catalog the user gave stays theirs, and other hosts are left alone. +func TestOpenCodeEndpointsFilledIn(t *testing.T) { + isolate(t) + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cache := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cache) + os.MkdirAll(filepath.Join(cache, "magpie"), 0o755) + // as models.dev lists them (2026-10-08): MiniMax M2.7 is Anthropic's + // SDK on Go and the OpenAI-compatible one on Zen + if err := os.WriteFile(catalog.CachePath(), []byte(`{ + "opencode-go": {"models": { + "gpt-6-luna": {"id":"gpt-6-luna","provider":{"npm":"@ai-sdk/openai"}}, + "minimax-m2.7": {"id":"minimax-m2.7","provider":{"npm":"@ai-sdk/anthropic"}}, + "glm-5.3": {"id":"glm-5.3"}}}, + "opencode": {"models": { + "claude-opus-5": {"id":"claude-opus-5","provider":{"npm":"@ai-sdk/anthropic"}}, + "minimax-m2.7": {"id":"minimax-m2.7"}}} + }`), 0o644); err != nil { + t.Fatal(err) + } + catalog.Reset() + t.Cleanup(catalog.Reset) + + for _, tc := range []struct { + name string + in Provider + chat, responses, anthropic, catlog string + apis map[string]Protocol // "" for none named + }{ + {"go, chat alone", Provider{ID: "opencode-go", Chat: "https://opencode.ai/zen/go/v1"}, + "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go", "opencode-go", + map[string]Protocol{"gpt-6-luna": Responses, "minimax-m2.7": Anthropic, "glm-5.3": ""}}, + {"go, anthropic alone with /v1", Provider{ID: "ocgo", Anthropic: "https://opencode.ai/zen/go/v1"}, + "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go", "opencode-go", + map[string]Protocol{"gpt-6-luna": Responses}}, + {"zen saved with go's catalog", Provider{ID: "opencode-go", Catalog: "opencode-go", Chat: "https://opencode.ai/zen/v1"}, + "https://opencode.ai/zen/v1", "https://opencode.ai/zen/v1", "https://opencode.ai/zen", "opencode-go", + map[string]Protocol{"claude-opus-5": Anthropic, "minimax-m2.7": ""}}, + {"a URL of the user's own stays", Provider{ID: "oc", Chat: "https://opencode.ai/zen/go/v1", Responses: "https://proxy.test/go/v1"}, + "https://opencode.ai/zen/go/v1", "https://proxy.test/go/v1", "https://opencode.ai/zen/go", "opencode-go", nil}, + {"another relay", Provider{ID: "relay", Catalog: "opencode-go", Chat: "https://relay.test/v1"}, + "https://relay.test/v1", "", "", "opencode-go", + map[string]Protocol{"gpt-6-luna": ""}}, + {"another path at opencode.ai", Provider{ID: "oc-other", Chat: "https://opencode.ai/other/v1"}, + "https://opencode.ai/other/v1", "", "", "", nil}, + } { + p := normalize(tc.in) + if p.Chat != tc.chat || p.Responses != tc.responses || p.Anthropic != tc.anthropic || p.Catalog != tc.catlog { + t.Errorf("%s: chat %q responses %q anthropic %q catalog %q", tc.name, p.Chat, p.Responses, p.Anthropic, p.Catalog) + } + for model, want := range tc.apis { + got := p.APIs(model) + if want == "" && got != nil || want != "" && !slices.Equal(got, []Protocol{want}) { + t.Errorf("%s: %s APIs %v, want %q", tc.name, model, got, want) + } + if want != "" && p.Native(model) != want { + t.Errorf("%s: %s native %q, want %q", tc.name, model, p.Native(model), want) + } + } + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8ab8d609..dec68aba 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -915,6 +915,8 @@ func normalize(p Provider) Provider { // Azure OpenAI's resource, however its endpoint was pasted, is asked // on its v1 API, chat completions and Responses both (azure.go) p.azureEndpoints() + // OpenCode Zen's or Go's other APIs, beside the one it was given (#1215) + p.openCodeEndpoints() // a preset's provider keeps its headers too: the preset gives the // endpoints and catalog, the headers say which workspace or app it is p.Headers = cleanHeaders(p.Headers)