providers: an OpenCode Go or Zen provider saved with its chat URL alone sends GPT, Grok, Muse Spark and MiniMax on the API OpenCode serves them on, not chat, and "Model does not support this protocol" says which URL was asked (#1215)

The reporter's "OpenCode Go 1x" had only chat=https://opencode.ai/zen/go/v1
and no models.dev catalog (its native_endpoints were /v1/chat/completions
for every model). Go serves each model on one API of three, as models.dev's
per-model provider.npm says and OpenCode itself asks it (@ai-sdk/openai ->
/responses, @ai-sdk/anthropic -> /messages, the rest chat), so with chat the
only URL, gpt-6-luna, gpt-5.6-luna, grok-4.6/4.7, minimax-m2.7 and
muse-spark-1.2/1.3-contributor had nowhere else to go and Go answered 400
on all three of magpie's entry points.

- normalize fills in the other two of Zen's or Go's APIs (/v1 for chat and
  Responses, the root for Messages) and its catalog, only where the
  provider has none of its own (openCodeEndpoints); other hosts unchanged.
- ListedAPIs reads the per-model API from the list of the gateway the URL
  is at (opencode-go for /zen/go, opencode for /zen), whatever catalog was
  saved: Go and Zen serve MiniMax M2.7 on different APIs, and an import at
  /zen/v1 could be saved with opencode-go's.
- A wrong-endpoint error with no other API left now ends "(asked at <url>;
  <model> is served on /v1/responses, which <provider> has no URL for)".

Verified: TestOpenCodeGoModelsGoOnTheirOwnAPI (gateway, all 3 client APIs x
5 models through the handler) and TestOpenCodeEndpointsFilledIn fail without
the change with the reporter's exact "OpenCode Go 1x: Model does not support
this protocol."; TestOtherRelayKeepsItsOneAPI and the relay cases keep
other providers as they were. go test -tags nogui ./... green under a temp
HOME; go vet, go build, GOOS=linux/windows builds. Real upstream: probed
https://opencode.ai/zen/go/v1 with no key only (grok-4.7 is "not supported
for format oa-compat" on chat and "format anthropic" on messages, passes to
the key check on responses); no keyed request was made.
This commit is contained in:
yetone committed 2026-10-08 03:18:08 +08:00
1 parent 75aa03e1a2
commit b0d456c3b2
7 files changed
+360 -2

No files matched your search

+3
View File
@@ -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
}
+37
View File
@@ -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)
+167
View File
@@ -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)
}
}
+3 -2
View File
@@ -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)}
}
+70
View File
@@ -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()
}
@@ -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)
}
}
}
}
+2
View File
@@ -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)