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 +}