provider: an Antigravity or Gemini CLI sign-in's models can be tested, asked through the gateway's translator (lc on Discord)

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.
This commit is contained in:
yetone committed 2026-10-10 15:08:14 +08:00
1 parent df2b44ef6c
commit 8b56cf2d57
4 files changed
+204 -2

No files matched your search

+41
View File
@@ -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) }
+103
View File
@@ -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)
}
}
+9 -2
View File
@@ -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 {
+51
View File
@@ -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
}