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:
1 parent
df2b44ef6c
commit
8b56cf2d57
4 files changed
+204
-2
No files matched your search
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user