From 8b56cf2d571248653ab64b9c8d37d94c8a9e764e Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 14:34:31 +0800 Subject: [PATCH] provider: an Antigravity or Gemini CLI sign-in's models can be tested, asked through the gateway's translator (lc on Discord) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lc: "antigravity的测试按钮不可用,别的供应商正常" — even after restarting magpie and signing in again. A Google sign-in speaks only Code Assist, an API whose envelope (project, model variant, request wrapper) the gateway builds for each agent request. The provider test only sends its own tiny request to a Chat/Responses/Anthropic/Gemini URL, so ModelTest() returned "own-api" and the GUI turned Test models and each model's test off. Now the gateway registers provider.ProbeTranslatedVia, the same pattern as ProbeClaudeVia. For a Google Code Assist account, Test and TestModels ask the model a "hi" (max 16 tokens) through askTranslated, the same path an agent's request takes. The reply is read up to the first word, thought or tool call, or until the vendor's error, which is reported with its status. ModelTest() is "" for such an account, so the GUI's buttons are on. Plugin providers and other own-API sign-ins (Kiro, Cursor…) are unchanged. Run: - TestAntigravityModelTest (internal/gateway): an Antigravity login and a fake Code Assist upstream. ModelTest is "". TestModels is OK on CodeAssist with the account named, and sent one :streamGenerateContent with project p1 and "hi". Provider Test is OK. A 403 PERMISSION_DENIED comes back as status 403 with its message. With testsTranslated forced false by hand (the old behaviour), it fails: "own-api". - go test -tags nogui ./internal/{provider,gateway,gui} under a temp HOME with pinned caches: ok. gofmt -l clean. go vet darwin/linux/windows on those packages: ok. - Not tried against a real Antigravity account. Live state was read only. --- internal/gateway/translated_probe.go | 41 +++++++++ internal/gateway/translated_probe_test.go | 103 ++++++++++++++++++++++ internal/provider/test.go | 11 ++- internal/provider/translated_probe.go | 51 +++++++++++ 4 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 internal/gateway/translated_probe.go create mode 100644 internal/gateway/translated_probe_test.go create mode 100644 internal/provider/translated_probe.go diff --git a/internal/gateway/translated_probe.go b/internal/gateway/translated_probe.go new file mode 100644 index 00000000..7ad179d0 --- /dev/null +++ b/internal/gateway/translated_probe.go @@ -0,0 +1,41 @@ +package gateway + +import ( + "context" + "errors" + "net/http" + + "github.com/yetone/magpie/internal/provider" +) + +// probeServer is the gateway a translated test is asked through; a var so +// a test can give it a client of its own. +var probeServer = New + +// probeTranslated asks model on p, on proto, the smallest "hi" as an +// agent's request to it is asked — built in the account's envelope by the +// translator — and reads the reply to the model's first word, thought or +// tool call, or its error (provider.ProbeTranslatedVia). +func probeTranslated(ctx context.Context, p provider.Provider, proto provider.Protocol, model string) (int, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() // the reader stops once the first word is in + req := &Request{MaxTokens: 16, Messages: []Message{{Role: "user", Parts: []Part{{Kind: Text, Text: "hi"}}}}} + events, code, msg := probeServer().askTranslated(p, proto, model, http.Header{}, http.Header{})(ctx, req) + if events == nil { + return code, errors.New(msg) + } + for ev := range events { + switch ev.Kind { + case KError: + return ev.Status, errors.New(ev.Text) + case KText, KThink, KToolStart, KImage: + return 0, nil + } + } + if err := ctx.Err(); err != nil { + return 0, err + } + return 0, nil +} + +func init() { provider.ProbeTranslatedVia(probeTranslated) } diff --git a/internal/gateway/translated_probe_test.go b/internal/gateway/translated_probe_test.go new file mode 100644 index 00000000..3007b70b --- /dev/null +++ b/internal/gateway/translated_probe_test.go @@ -0,0 +1,103 @@ +package gateway + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/yetone/magpie/internal/provider" +) + +// An Antigravity account's models can be tested (lc on Discord: +// "antigravity的测试按钮不可用,别的供应商正常"): Test models was off for +// every Google sign-in, which speaks Code Assist alone, an API the gateway +// builds each request for. The test is now that request, the smallest +// "hi" built by the translator in the account's envelope, and says what +// the vendor answered — a word, or its refusal. +func TestAntigravityModelTest(t *testing.T) { + fresh(t) + dir := filepath.Dir(provider.Path()) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + logins := []map[string]any{{"agent": "antigravity", "user": "u@example.com", "on": true, + "auth": map[string]any{"access_token": "tok", "refresh_token": "ref", "project": "p1", "expiry_date": time.Now().Add(time.Hour).UnixMilli()}}} + if err := os.WriteFile(filepath.Join(dir, "logins.json"), mustJSON(logins), 0o600); err != nil { + t.Fatal(err) + } + old := http.DefaultClient + http.DefaultClient = &http.Client{Transport: countTransport(func(r *http.Request) (*http.Response, error) { + var body string + switch { + case strings.HasSuffix(r.URL.Path, "latest-arm64-mac.yml"): + body = "version: 2.9.1\n" + case strings.Contains(r.URL.Path, ":loadCodeAssist"): + body = `{"currentTier":{"id":"standard-tier"},"cloudaicompanionProject":"p1"}` + case strings.Contains(r.URL.Path, ":fetchAvailableModels"): + body = `{"models":{"gemini-3-flash":{}}}` + default: + return nil, fmt.Errorf("unexpected request to %s", r.URL) + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })} + t.Cleanup(func() { http.DefaultClient = old }) + + var asked []string + var say func(w http.ResponseWriter) + oldServer := probeServer + probeServer = func() *Server { + s := New() + s.client = &http.Client{Transport: countTransport(func(r *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(r.Body) + asked = append(asked, r.URL.Path+" "+string(b)) + rec := httptest.NewRecorder() + say(rec) + return rec.Result(), nil + })} + return s + } + t.Cleanup(func() { probeServer = oldServer }) + + p, err := provider.Find("antigravity") + if err != nil { + t.Fatal(err) + } + if _, err := p.Fetch(t.Context()); err != nil { + t.Fatal(err) + } + if why := p.ModelTest(); why != "" { + t.Fatalf("Antigravity's models can't be tested: %q", why) + } + + say = func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi\"}]}}]}}\n\n") + } + r := p.TestModels(t.Context(), []string{"gemini-3-flash"})[0] + if !r.OK || r.Protocol != provider.CodeAssist || r.Account != "u@example.com" { + t.Fatalf("test: %+v", r) + } + if len(asked) != 1 || !strings.Contains(asked[0], ":streamGenerateContent") || !strings.Contains(asked[0], `"project":"p1"`) || !strings.Contains(asked[0], `"hi"`) { + t.Errorf("asked: %q", asked) + } + // the provider's own Test, as the TUI's + if rs := p.Test(t.Context()); len(rs) != 1 || !rs[0].OK || rs[0].Model != "gemini-3-flash" { + t.Errorf("provider test: %+v", rs) + } + + say = func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `{"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}`) + } + r = p.TestModels(t.Context(), []string{"gemini-3-flash"})[0] + if r.OK || r.Status != 403 || !strings.Contains(r.Error, "does not have permission") { + t.Errorf("refused: %+v", r) + } +} diff --git a/internal/provider/test.go b/internal/provider/test.go index 69179f3a..3735abe6 100644 --- a/internal/provider/test.go +++ b/internal/provider/test.go @@ -56,6 +56,9 @@ func (p Provider) Test(ctx context.Context) []Result { if p.isClaudeAccount() { return []Result{p.testClaude(ctx, p.testModel(p, Anthropic))} } + if p.testsTranslated() { + return []Result{p.testTranslated(ctx, p.testModel(p, CodeAssist))} + } var out []Result for _, proto := range p.Speaks() { q, ok := p.keyFor(proto) @@ -124,7 +127,8 @@ func clineProbe(body string) string { // for them; "own-api" for a sign-in reached // through its agent's own API (Cursor, Devin, Kiro, Zed, Qoder, a Google // sign-in), which the gateway translates every request for, so a probe -// has no endpoint to go to. +// has no endpoint to go to. A Google sign-in's are asked through the +// gateway's translator (testsTranslated). func (p Provider) ModelTest() string { if p.DecideOnly() { if p.AsksDecideModels() { @@ -132,7 +136,7 @@ func (p Provider) ModelTest() string { } return "decide" } - if p.isClaudeAccount() { + if p.isClaudeAccount() || p.testsTranslated() { return "" } for _, pr := range p.Speaks() { @@ -246,6 +250,9 @@ func (p Provider) testOne(ctx context.Context, model string) Result { if p.isClaudeAccount() { return p.testClaude(ctx, model) } + if p.testsTranslated() { + return p.testTranslated(ctx, model) + } var protos []Protocol for _, pr := range p.Speaks() { if pr == Chat || pr == Responses || pr == Anthropic || pr == Gemini { diff --git a/internal/provider/translated_probe.go b/internal/provider/translated_probe.go new file mode 100644 index 00000000..d763b392 --- /dev/null +++ b/internal/provider/translated_probe.go @@ -0,0 +1,51 @@ +package provider + +import ( + "context" + "time" +) + +// A Google sign-in (Antigravity, Gemini CLI) speaks Code Assist alone, an +// API the gateway builds every request for in the account's envelope, so +// a test had no request of its own to send: Test models was off and said +// to ask the model from an agent (lc on Discord: Antigravity's test button +// was unusable while every other provider's worked). Its test is asked as +// an agent's request is, by the gateway's own translator (TranslatedProbe), +// the smallest "hi" in that envelope, read to the model's first word. + +// translatedProbe asks model on proto through the gateway's translator, +// and says how it failed: status, when the vendor answered one, and its +// message. The gateway sets it (ProbeTranslatedVia). +var translatedProbe func(ctx context.Context, p Provider, proto Protocol, model string) (status int, err error) + +// ProbeTranslatedVia sets how a model only the gateway can ask is tested. +func ProbeTranslatedVia(f func(ctx context.Context, p Provider, proto Protocol, model string) (int, error)) { + translatedProbe = f +} + +// testsTranslated is whether p's models are tested through the gateway's +// translator: a Google sign-in, on Code Assist, when the gateway is there +// to ask it. +func (p Provider) testsTranslated() bool { + return translatedProbe != nil && p.Account != nil && !p.IsPlugin() && p.Account.codeAssist != "" +} + +// testTranslated is the test of model on a provider testsTranslated. +func (p Provider) testTranslated(ctx context.Context, model string) Result { + r := Result{Protocol: CodeAssist, Model: model, Account: p.Account.User} + if model == "" { + r.Error = "no model to try: expose one, or refresh the model list" + return r + } + ctx, cancel := context.WithTimeout(ctx, testWait) + defer cancel() + start := time.Now() + status, err := translatedProbe(ctx, p, CodeAssist, model) + r.Millis = time.Since(start).Milliseconds() + if err != nil { + r.Status, r.Error = status, err.Error() + return r + } + r.OK, r.Status = true, 200 + return r +}