Files
magpie/internal/gateway/model_api_test.go
T
yetone 14b9928d33 providers: a relay's APIs found at a click, and a model asked on the one API its vendor serves it on (01huadalang on Discord: 能不能加个供应商检测协议功能,类似 cc 的功能一键检测支持什么协议,发一个最小请求看是否返回200,还有每个模型自定义协议,例如有的供应商一个 api 里有很多模型但是不同协议) — a custom provider was given one base URL and a protocol picked by hand, and which of Chat Completions, Responses and Anthropic Messages a relay answered was learned by trying, one Save and Test at a time. A model's API came only from the vendor's list or a built-in (Copilot, Factory, Bedrock, OpenCode), so a relay serving some models on chat completions and others only on /v1/messages behind one key had every model tried on chat first, and the ones it had no channel for there failed before the gateway got to Anthropic's. The editor's Base URL now has Detect APIs, with a model to try beside it: it posts provider/detect with the form as it stands (the key typed, or the saved one), and Provider.Detect sends each of the three APIs Test's smallest request (tiny: 16 tokens, not streamed) at the URL the provider has for it, else at the URL typed as that API takes it (DetectBase: an endpoint pasted whole cut to its base, …/v1 for OpenAI's two, the root for Anthropic's), in parallel, and says what each answered. With no model typed, one is picked from the provider's list, else from the vendor's /models (asked then and kept nowhere): a Claude model for Anthropic's, an OpenAI one for Responses. Only the URLs typed are asked, only at the click, and nothing is saved. The editor shows each API's URL with its time or its status and error; "Use these" sets the URLs of those that answered (one found missing there, 404 or 405, cleared) and moves the Base URL's protocol to one that did, with no redraw and no scroll. A model's API is new in Names & levels (settings.ModelAPIs, "<provider>/<model>"): for a key's provider with URLs for two or more APIs, each model can be set to Auto or to one of them. It is staged with the rest and made with the Save (ModelPref.API, SetModelAPI), which refuses an API the provider has no URL for and a sign-in's provider. Once set, APIs() returns that API alone, so the gateway, Native and a model's test all ask it there; it is ignored once its URL is gone. After a detection with a model typed where only some APIs answered, "Ask <model> on <API> only" stages the same thing. Gemini isn't probed, since magpie never asks a vendor on it.
The tests run against fake httptest upstreams. TestDetectProtocols covers a bare host, …/v1/ and a pasted …/v1/chat/completions: chat at /v1 and Anthropic at the root are found with gpt-5.5 and claude-sonnet-5 picked from the list, Responses' 404 is reported, the key is sent, no request asks for more than 16 tokens or streams, and nothing is saved. TestDetectModelAndOwnURLs: a typed model is used everywhere and the list isn't fetched, the provider's own Anthropic URL is asked instead of the typed one, and an empty URL or one with no scheme is refused. TestModelAPI: the refusals, the override via SetModelPrefs, APIs/Native/TestModels following it with one request to /v1/messages, the other model unaffected, and "" clearing it. TestModelAPIRoutes: through the gateway, a chat request for the model set to Anthropic reaches only /v1/messages while chat answers "no available channel"; another model still goes to chat. TestProviderDetect covers /api/provider/detect for a new provider's typed key and a saved provider's key, and a Save's modelPrefs.api kept and reported back. TestModelAPI and TestModelAPIRoutes fail without the APIs() override ("apis []"; the upstream asked chat three times). provider-detect.test.cjs runs in WebKit, en and zh, and fails without the change: Detect sends the typed URL and model, the three results are shown, Use these fills Anthropic's URL and keeps chat's, the per-model API picker appears with two URLs and stages only away from Auto, Ask on one API stages it, and Save carries anthropic plus modelPrefs {"m-mixed":{"api":"anthropic"}}. No click moves the page, there is no left-border accent, and every string has Chinese. TestSettingsSaveKeepsWhatItDoesNotSend now covers ModelAPIs. click-scroll, model-test-one, model-test-why, provider-levels-save, provider-levels-scroll, editor-short-window, azure-endpoint, nokey-editor, provider-search, provider-typed, provider-picks-stay and provider-proxy pass in WebKit. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-02 21:54:07 +08:00

94 lines
3.6 KiB
Go

package gateway
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// A relay whose one key serves a model only on Anthropic's messages, while
// its chat completions take the others and answer that one with no channel
// (01huadalang on Discord): with Anthropic picked for the model in its
// provider's editor, an agent's chat request for it goes to /v1/messages,
// translated, and chat completions is never tried; another model still
// goes to chat.
func TestModelAPIRoutes(t *testing.T) {
var mu sync.Mutex
var hits []string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Model string `json:"model"`
}
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &body)
mu.Lock()
hits = append(hits, r.URL.Path+" "+body.Model)
mu.Unlock()
switch {
case r.URL.Path == "/v1/chat/completions" && body.Model == "plain":
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, sse(`data: {"id":"c","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"from chat"}}]}`,
`data: {"id":"c","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}`,
`data: [DONE]`))
case r.URL.Path == "/v1/chat/completions":
http.Error(w, `{"error":{"message":"no available channel for model `+body.Model+` under group default"}}`, 503)
case r.URL.Path == "/v1/messages" && body.Model == "mixed":
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, sse(`event: message_start
data: {"type":"message_start","message":{"id":"m","type":"message","role":"assistant","model":"mixed","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`,
`event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"from messages"}}`,
`event: content_block_stop
data: {"type":"content_block_stop","index":0}`,
`event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
`event: message_stop
data: {"type":"message_stop"}`))
default:
http.NotFound(w, r)
}
}))
defer up.Close()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: up.URL + "/v1", Anthropic: up.URL, Models: []string{"mixed", "plain"}}); err != nil {
t.Fatal(err)
}
if err := provider.SetModelAPI("relay/mixed", "anthropic"); err != nil {
t.Fatal(err)
}
gw := httptest.NewServer(New().Handler())
defer gw.Close()
for _, c := range []struct{ model, want, hit string }{
{"mixed", "from messages", "/v1/messages mixed"},
{"plain", "from chat", "/v1/chat/completions plain"},
} {
mu.Lock()
hits = nil
mu.Unlock()
res, err := http.Post(gw.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"relay/`+c.model+`","stream":true,"messages":[{"role":"user","content":"hi"}]}`))
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
mu.Lock()
h := append([]string(nil), hits...)
mu.Unlock()
if res.StatusCode != 200 || !strings.Contains(string(b), c.want) {
t.Fatalf("%s: %d %s (upstream %v)", c.model, res.StatusCode, b, h)
}
if len(h) != 1 || h[0] != c.hit {
t.Fatalf("%s: upstream asked %v", c.model, h)
}
}
}