diff --git a/docs/subsystems/providers-accounts.md b/docs/subsystems/providers-accounts.md index 1ffe45f7..1f8cbeba 100644 --- a/docs/subsystems/providers-accounts.md +++ b/docs/subsystems/providers-accounts.md @@ -31,7 +31,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi | A key's own windows | A sub2api key given a spending limit per 5 hours, day or 7 days answers its `/v1/usage` with `rate_limits` and no `remaining` (`readSub2APIKeyLimits`). With the Balance URL set to it, the key's card shows them as the windows *5 hours*, *1 day*, *7 days*, in USD, beside a balance field the user wrote; *Check balance* and `magpie provider show` give what its fullest window has left (`Balance`). `KeyAllowance` gives routing the same windows: never waited for, read in the background at most once a minute, a failed read keeping the last, and seeded from the card's last reading (`quotas.json`) after a restart. A key bought as a plan (Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code: `planQuotaSourceOf`) gives routing its card's windows the same way (`planKeyWindows`, Zhipu's team quota behind a key with none of its own); the Usage page's read of its card is taken at once (`noteKeyAllowance`), and a key there that turns out pay-as-you-go, with no windows, is asked again only every 30 minutes (`keyNoWindowsAge`). StepFun's Step Plan is read through a sign-in, not the key, and isn't one of them. | [`sub2api_usage.go`](../../internal/provider/sub2api_usage.go), [`key_allowance.go`](../../internal/provider/key_allowance.go) | | Last served | `served.json` records when each account, plan or key last answered, shown as `lastServedAt` (#570). | [`served.go`](../../internal/provider/served.go) | | Partners | Services that pay to be listed first in the add sheet, under a heading of their own ("Partners · magpie's sponsors"). The list is `site/partners.js`, served at `usemagpie.ai/api/partners`: a change reaches users with a deploy, not a release. `Partners` never waits: it returns what was fetched last and fetches again behind the call every 6 hours (10 minutes after a failure), keeping the list in `partners.json` in the cache folder. Each entry is checked again on every read (`validPartners`): its own id, never a built-in preset's, a name, an endpoint, https only, at most 12; what only magpie's presets may set (a decision API, no key, a user endpoint) is cleared. Its logo is fetched through `FetchIcon` and kept as an uploaded icon. A partner is a preset to the rest of magpie (`Preset`, `FromPreset`, the editor), and one no longer listed still is to the providers added from it (`Seen`), without the sponsored mark. The window lists a partner in the languages it names (`langs`) only and shows its tagline in the page's language (`notes`, else `en`). `MAGPIE_PARTNERS` points it elsewhere, `off` lists none; tests list none unless they name a list. How often each listed partner was shown, opened (its row, its key page) and added is counted per UTC day (`CountPartner`, capped at 20 of each and 5 adds, kept in `partner-counts.json` in the cache folder) and sent with the next day's stats event as `magpie partner` events (`internal/stats`); nothing is counted with the stats or their usage part off. A listed partner the add sheet (or the TUI's, or `magpie providers presets`) hasn't shown yet is new (`NoticePartners` keeps those shown, as `noticed` in `partners.json`, whatever the stats); the window's add button carries a small dot for it, with its name in the title, until the sheet shows it. | [`partners.go`](../../internal/provider/partners.go), [`partner_events.go`](../../internal/provider/partner_events.go), [`site/partners.js`](../../site/partners.js) | -| Protocol detection | Works out which of Chat, Responses and Anthropic Messages a base URL speaks when a provider is added; a Gemini URL is not detected, and the editor hides Detect while Gemini is the Base URL's API. Detection, endpoint tests and model tests share `probe`, which adds `anthropic-version` only for Anthropic Messages; OpenAI probes omit that default so a relay that refuses it can answer. User-supplied headers still apply through `Sign`. | [`detect.go`](../../internal/provider/detect.go), [`test.go`](../../internal/provider/test.go) | +| Protocol detection | Works out which of Chat, Responses, Anthropic Messages and Gemini generateContent a base URL speaks when a provider is added. Each is asked at the URL as it takes it (`DetectBase`); Gemini's at the `…/v1beta` beside a typed `…/v1` or bare host, a Gemini model from the list picked for it, with the key in `x-goog-api-key` (#1346). Detection, endpoint tests and model tests share `probe`, which adds `anthropic-version` only for Anthropic Messages; OpenAI probes omit that default so a relay that refuses it can answer. User-supplied headers still apply through `Sign`. | [`detect.go`](../../internal/provider/detect.go), [`test.go`](../../internal/provider/test.go) | ## Runtime path diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 88875420..9cee9404 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -7821,7 +7821,6 @@ function drawEditor(p, presetID) { // the Redaction row, shown while every address is on this computer or // the local network let showLocal = () => {}; - let showDetect = () => {}; if (custom) { name = input(draft.name, t("e.g. My Relay")); name.oninput = () => { draft.name = name.value; if (isNew) draft.id = slug(name.value); }; @@ -7865,7 +7864,6 @@ function drawEditor(p, presetID) { slide(seg, "api"); url.placeholder = urlHint(v); fillEndpoints(); - showDetect(); showSearch(); showLocal(); }; @@ -7892,12 +7890,8 @@ function drawEditor(p, presetID) { showApi(v || draft.api); draft.onModelPrefs?.(); // the APIs a model can be given follow the URLs }; - // Detect asks the three OpenAI and Anthropic APIs, which a Gemini - // API's URL has none of - const detect = detectAPIs(p, () => url.value, useDetected); - urlWrap.append(detect); - showDetect = () => { detect.hidden = draft.api === "gemini"; }; - showDetect(); + // Detect asks OpenAI's, Anthropic's and Gemini's APIs (#1346) + urlWrap.append(detectAPIs(p, () => url.value, useDetected)); ed.append(...field("Base URL", urlWrap)); } @@ -9037,7 +9031,7 @@ function detectAPIs(p, base, use) { const box = el("div", "detect"); const row = el("div", "detect-row"); const go = el("button", "text action", t("Detect APIs")); - go.title = t("Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages) at this URL, to see which answer"); + go.title = t("Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages, Gemini generateContent) at this URL, to see which answer"); const each = el("button", "text action detect-each", t("Each picked model")); each.title = t("Ask every model picked below on each API, to see which API serves which model (up to {n}, a few at a time)", { n: DETECT_MAX }); const model = input(draft.detectModel || "", t("model to try · empty picks one from the vendor's list")); @@ -9067,7 +9061,7 @@ function detectAPIs(p, base, use) { }; const ask = async (btn, extra) => { const typedBase = (base() || "").trim(); - if (!typedBase && !(draft.chat || draft.responses || draft.anthropic || "").trim()) { status(t("Type the base URL first"), "warn"); return null; } + if (!typedBase && !(draft.chat || draft.responses || draft.anthropic || draft.gemini || "").trim()) { status(t("Type the base URL first"), "warn"); return null; } btn.classList.add("busy"); out.hidden = false; out.replaceChildren(el("span", "hint", t("Asking each API…"))); @@ -9075,7 +9069,7 @@ function detectAPIs(p, base, use) { // the URL typed is asked as each API takes it, not as the field // holding it would send it; URLs given under More endpoints as they are const body = { ...asTyped(), id: p?.id, base: typedBase, ...extra }; - for (const k of ["chat", "responses", "anthropic"]) if (body[k] === typedBase) body[k] = ""; + for (const k of ["chat", "responses", "anthropic", "gemini"]) if (body[k] === typedBase) body[k] = ""; const r = await api("provider/detect", body); out.replaceChildren(); return r; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 416f20a1..a2fd818e 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1712,7 +1712,7 @@ const I18N = { "Answered via {api} in {took}": "通过 {api} 协议在 {took}内响应", "{model} answered via {api} in {took}": "{model} 通过 {api} 协议在 {took}内响应", "{model} didn't answer via {api}: {error}": "{model} 通过 {api} 协议没有响应:{error}", - "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages) at this URL, to see which answer": "向此地址的每种协议(OpenAI Chat Completions、Responses、Anthropic Messages)各发一个最小请求,检测哪些可用", + "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages, Gemini generateContent) at this URL, to see which answer": "向此地址的每种协议(OpenAI Chat Completions、Responses、Anthropic Messages、Gemini generateContent)各发一个最小请求,检测哪些可用", "model to try · empty picks one from the vendor's list": "试用的模型 · 留空则从供应商的模型列表中挑选", "Type the base URL first": "请先填写 Base URL", "Asking each API…": "正在逐个检测协议…", @@ -5492,7 +5492,7 @@ const I18N = { "Answered via {api} in {took}": "透過 {api} 協議在 {took}內回應", "{model} answered via {api} in {took}": "{model} 透過 {api} 協議在 {took}內回應", "{model} didn't answer via {api}: {error}": "{model} 透過 {api} 協議沒有回應:{error}", - "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages) at this URL, to see which answer": "向此位址的每種協議(OpenAI Chat Completions、Responses、Anthropic Messages)各發一個最小請求,檢測哪些可用", + "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages, Gemini generateContent) at this URL, to see which answer": "向此位址的每種協議(OpenAI Chat Completions、Responses、Anthropic Messages、Gemini generateContent)各發一個最小請求,檢測哪些可用", "model to try · empty picks one from the vendor's list": "試用的模型 · 留空則從供應商的模型列表中挑選", "Type the base URL first": "請先填寫 Base URL", "Asking each API…": "正在逐個檢測協議…", @@ -9250,7 +9250,7 @@ const I18N = { "Answered via {api} in {took}": "{api} 経由で {took} で応答", "{model} answered via {api} in {took}": "{model} が {api} 経由で {took} で応答しました", "{model} didn't answer via {api}: {error}": "{model} は {api} 経由で応答しませんでした:{error}", - "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages) at this URL, to see which answer": "この URL の各 API(OpenAI Chat Completions、Responses、Anthropic Messages)に最小のリクエストを送り、どれが応答するか調べます", + "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages, Gemini generateContent) at this URL, to see which answer": "この URL の各 API(OpenAI Chat Completions、Responses、Anthropic Messages、Gemini generateContent)に最小のリクエストを送り、どれが応答するか調べます", "model to try · empty picks one from the vendor's list": "試すモデル · 空欄ならベンダーの一覧から選択", "Type the base URL first": "先にベース URL を入力してください", "Asking each API…": "各 API に問い合わせ中…", @@ -13027,7 +13027,7 @@ const I18N = { "Answered via {api} in {took}": "Antwort über {api} in {took}", "{model} answered via {api} in {took}": "{model} hat über {api} in {took} geantwortet", "{model} didn't answer via {api}: {error}": "{model} hat über {api} nicht geantwortet: {error}", - "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages) at this URL, to see which answer": "Eine minimale Anfrage an jede API (OpenAI Chat Completions, Responses, Anthropic Messages) unter dieser URL senden, um zu sehen, welche antworten", + "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages, Gemini generateContent) at this URL, to see which answer": "Eine minimale Anfrage an jede API (OpenAI Chat Completions, Responses, Anthropic Messages, Gemini generateContent) unter dieser URL senden, um zu sehen, welche antworten", "model to try · empty picks one from the vendor's list": "zu testendes Modell · leer wählt eines aus der Modellliste des Anbieters", "Type the base URL first": "Geben Sie zuerst die Basis-URL ein", "Asking each API…": "Jede API wird angefragt…", diff --git a/internal/gui/providers_detect_test.go b/internal/gui/providers_detect_test.go index cb24fc45..00bca6db 100644 --- a/internal/gui/providers_detect_test.go +++ b/internal/gui/providers_detect_test.go @@ -28,7 +28,12 @@ func TestProviderDetect(t *testing.T) { up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.Copy(io.Discard, r.Body) mu.Lock() - auths = append(auths, r.Method+" "+r.URL.Path+" "+r.Header.Get("Authorization")) + // Gemini's API is asked with the key in x-goog-api-key + auth := r.Header.Get("Authorization") + if g := r.Header.Get("x-goog-api-key"); g != "" { + auth = "goog " + g + } + auths = append(auths, r.Method+" "+r.URL.Path+" "+auth) mu.Unlock() switch r.URL.Path { case "/v1/chat/completions": @@ -87,7 +92,7 @@ func TestProviderDetect(t *testing.T) { } mu.Lock() for _, a := range auths { - if !strings.HasSuffix(a, "Bearer sk-saved") { + if !strings.HasSuffix(a, "Bearer sk-saved") && !strings.HasSuffix(a, "goog sk-saved") { t.Fatalf("asked %s, not with the saved key", a) } } diff --git a/internal/gui/tests/provider-detect.test.cjs b/internal/gui/tests/provider-detect.test.cjs index 3aaec7a5..993cff41 100644 --- a/internal/gui/tests/provider-detect.test.cjs +++ b/internal/gui/tests/provider-detect.test.cjs @@ -144,7 +144,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(save.body.modelPrefs, { "m-mixed": { api: "anthropic" } }); const missing = await page.evaluate(() => [ - "Detect APIs", "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages) at this URL, to see which answer", + "Detect APIs", "Send the smallest request to each API (OpenAI chat completions, Responses, Anthropic messages, Gemini generateContent) at this URL, to see which answer", "model to try · empty picks one from the vendor's list", "Type the base URL first", "Asking each API…", "model {model}", "None answered: check the URL and the key, or type a model the vendor serves", "Use these", "Set the URLs of the APIs that answered; one not found there is cleared", "Taken · save to keep", "Ask {model} on {api} only", "Staged in Names & levels and made with the Save; Auto there gives it back", "{model} is asked on {api} once saved", diff --git a/internal/gui/tests/provider-gemini.test.cjs b/internal/gui/tests/provider-gemini.test.cjs index 1a1540ef..deaa74cc 100644 --- a/internal/gui/tests/provider-gemini.test.cjs +++ b/internal/gui/tests/provider-gemini.test.cjs @@ -1,11 +1,13 @@ // Run with Node's test runner and Playwright on the module path; see README.md. // A custom provider's Base URL can be a Gemini API (#1346, NagaseMinato): // "Gemini compatible" beside the other APIs. A provider saved with a Gemini -// URL opens on it, with no Detect (which asks OpenAI's and Anthropic's -// APIs); picked for a provider that has a chat URL, the Gemini URL typed is -// saved as its own, the chat URL kept, and the Base URL said to be Gemini's. -// The pick fits the editor at a narrow width. In English, Chinese, -// Japanese and German, Chromium and WebKit. +// URL opens on it, and Detect asks a Gemini API too (NagaseMinato: 「检测」 +// 不会探测 Gemini 端点,为什么呢?): the URL typed is sent as the base, its +// Gemini row shown with the others, and Use these keeps Gemini's URL; +// picked for a provider that has a chat URL, the Gemini URL typed is saved +// as its own, the chat URL kept, and the Base URL said to be Gemini's. The +// pick and Detect's rows fit the editor at a narrow width. In English, +// Chinese, Japanese and German, Chromium and WebKit. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -32,6 +34,16 @@ function serve(lang, posts) { if (url.pathname === "/api/state") return json(state); if (url.pathname === "/api/providers") return json(providers); if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/provider/detect") { + const body = route.request().postDataJSON(); + posts.push({ path: url.pathname, body }); + return json({ results: [ + { protocol: "chat", ok: false, status: 404, error: "Not Found", model: "gemini-2.5-pro", base: G }, + { protocol: "responses", ok: false, status: 404, error: "Not Found", model: "gemini-2.5-pro", base: G }, + { protocol: "anthropic", ok: false, status: 404, error: "Not Found", model: "gemini-2.5-pro", base: G }, + { protocol: "gemini", ok: true, ms: 412, model: "gemini-2.5-pro", base: G }, + ] }); + } if (url.pathname.startsWith("/api/provider/")) { posts.push({ path: url.pathname, body: route.request().postDataJSON() }); return json(providers); @@ -43,10 +55,10 @@ function serve(lang, posts) { } const L = { - en: { gemini: "Gemini compatible", openai: "OpenAI compatible", save: "Save", more: "Gemini URL" }, - zh: { gemini: "Gemini 兼容", openai: "OpenAI 兼容", save: "保存", more: "Gemini 地址" }, - ja: { gemini: "Gemini 互換", openai: "OpenAI 互換", save: "保存", more: "Gemini URL" }, - de: { gemini: "Gemini-kompatibel", openai: "OpenAI-kompatibel", save: "Speichern", more: "Gemini-URL" }, + en: { gemini: "Gemini compatible", openai: "OpenAI compatible", save: "Save", more: "Gemini URL", detect: "Detect APIs", use: "Use these" }, + zh: { gemini: "Gemini 兼容", openai: "OpenAI 兼容", save: "保存", more: "Gemini 地址", detect: "检测协议", use: "使用检测结果" }, + ja: { gemini: "Gemini 互換", openai: "OpenAI 互換", save: "保存", more: "Gemini URL", detect: "API を検出", use: "これを使う" }, + de: { gemini: "Gemini-kompatibel", openai: "OpenAI-kompatibel", save: "Speichern", more: "Gemini-URL", detect: "APIs erkennen", use: "Diese verwenden" }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -70,12 +82,33 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const on = () => ed.locator(".segs:has(.opt[data-api]) .opt.on").textContent(); const detect = ed.locator(".detect"); - // saved with a Gemini URL: opens on it, no Detect + // saved with a Gemini URL: opens on it, and Detect asks it await open("gem"); assert.equal(await on(), S.gemini); assert.equal(await ed.locator(".base-url").inputValue(), G); assert.equal(await ed.locator(".base-url").getAttribute("placeholder"), "https://…/v1beta"); - assert.equal(await detect.isVisible(), false, "Detect asks no Gemini API"); + assert.equal(await detect.isVisible(), true, "Detect is there for a Gemini URL"); + await detect.getByRole("button", { name: S.detect, exact: true }).click(); + const gemRow = detect.locator('.detect-out .ep[data-api="gemini"]'); + await gemRow.locator(".res.ok").waitFor(); + const asked = posts.find((p) => p.path === "/api/provider/detect"); + assert.equal(asked.body.base, G, "the URL typed is the base each API is asked at"); + assert.equal(asked.body.gemini, "", "the Gemini field is the URL typed, so it is asked as Gemini takes it"); + assert.equal(await detect.locator(".detect-out .ep").count(), 4); + assert.equal(await gemRow.locator(".pl").textContent(), "Gemini"); + for (const width of [900, 440]) { + await page.setViewportSize({ width, height: 760 }); + await page.waitForTimeout(100); + const over = await detect.evaluate((d) => { + const er = d.closest(".editor").getBoundingClientRect(); + return [...d.querySelectorAll(".detect-out .ep, .detect-row > *")].map((e) => e.getBoundingClientRect().right - er.right).filter((x) => x > 1); + }); + assert.deepEqual(over, [], `${width}px: Detect's rows overflow the editor`); + } + await page.setViewportSize({ width: 900, height: 760 }); + await detect.getByRole("button", { name: S.use, exact: true }).click(); + assert.equal(await on(), S.gemini, "Gemini answered: the Base URL stays Gemini's"); + assert.equal(await ed.locator(".base-url").inputValue(), G); // picked for a provider with a chat URL: the Gemini URL is its own await open("plain"); @@ -84,7 +117,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await ed.locator('.segs .opt[data-api="gemini"]').click(); assert.equal(await on(), S.gemini); assert.equal(await ed.locator(".base-url").inputValue(), "", "the saved chat URL stays chat's"); - assert.equal(await detect.isVisible(), false); + assert.equal(await detect.isVisible(), true); await ed.locator("details.more > summary").click(); const labels = await ed.locator("details.more label").allTextContents(); assert(!labels.includes(S.more), "the Base URL's API is not under More endpoints: " + labels); diff --git a/internal/provider/detect.go b/internal/provider/detect.go index 84878aea..7bf98e47 100644 --- a/internal/provider/detect.go +++ b/internal/provider/detect.go @@ -17,32 +17,47 @@ import ( // 请求看是否返回200): each of the three magpie speaks upstream — Chat // Completions, Responses and Anthropic Messages — is sent the smallest // request Test sends, at that URL as the API takes it, and what answered -// is what the provider can be given. Gemini's isn't among them: magpie -// serves it to clients but never asks a vendor on it, so a URL for it -// would be one the provider has nowhere to keep. +// is what the provider can be given. Since a custom provider can keep a +// Gemini API's URL (#1346), Gemini's generateContent is asked too, at the +// …/v1beta a relay serves it under (NagaseMinato: 「检测」不会探测 Gemini +// 端点,为什么呢?). + +// detectProtocols are the APIs a detection asks, in the order it says +// what they answered: magpie's three, then Gemini's. +var detectProtocols = []Protocol{Chat, Responses, Anthropic, Gemini} // Detection is what one API answered at the URL it was asked at. type Detection struct { Result // Base is the URL the provider keeps for it, as the editor's fields - // take it: …/v1 for OpenAI's two, the root for Anthropic's. + // take it: …/v1 for OpenAI's two, the root for Anthropic's, …/v1beta + // for Gemini's. Base string `json:"base"` } // DetectBase is base as proto takes it: an endpoint pasted whole (…/v1/chat/ -// completions) cut to its base, then the root for Anthropic, which adds -// /v1/messages itself, and …/v1 for OpenAI's two when base is a bare host — +// completions, …/models/gemini-2.5-pro:generateContent) cut to its base, then the root for Anthropic, which adds +// /v1/messages itself, …/v1 for OpenAI's two when base is a bare host, and +// for Gemini's the …/v1beta beside a …/v1 or on a bare host (GeminiBase) — // the editor's respellURL, in Go. func DetectBase(base string, proto Protocol) string { u := strings.TrimRight(strings.TrimSpace(base), "/") for _, end := range []string{"/chat/completions", "/responses", "/v1/messages", "/messages"} { u = strings.TrimSuffix(u, end) } + // a Gemini endpoint or a model list pasted whole + u = strings.TrimRight(geminiMethod.ReplaceAllString(u, ""), "/") if u == "" { return "" } - if proto == Anthropic { + switch proto { + case Anthropic: return strings.TrimSuffix(u, "/v1") + case Gemini: + if pu, err := url.Parse(u); err == nil && pu.Host != "" && strings.Trim(pu.Path, "/") == "v1" { + return u + "beta" + } + return GeminiBase(u) } if pu, err := url.Parse(u); err == nil && strings.Trim(pu.Path, "/") == "" { return u + "/v1" @@ -53,7 +68,7 @@ func DetectBase(base string, proto Protocol) string { // ErrNoURL is a detection with no URL to ask at. var ErrNoURL = errors.New("type the base URL first") -// Detect asks each of Chat, Responses and Anthropic at the URL p has for +// Detect asks each of Chat, Responses, Anthropic and Gemini at the URL p has for // it, else at base as it takes it (DetectBase), the smallest request, and // says what each answered, in that order. model is asked on all three; // with none, one from p's list is picked for each — a Claude model on @@ -72,9 +87,9 @@ func (p Provider) Detect(ctx context.Context, base, model string) ([]Detection, if model == "" { ids = q.detectModels(ctx) } - out := make([]Detection, len(Protocols)) + out := make([]Detection, len(detectProtocols)) var wg sync.WaitGroup - for i, proto := range Protocols { + for i, proto := range detectProtocols { m := model if m == "" { m = detectModel(ids, proto) @@ -101,7 +116,7 @@ func (p Provider) detecting(base string) (Provider, error) { return p, errors.New("a sign-in is asked on its agent's own API; there is nothing to detect") } q := p - for _, proto := range Protocols { + for _, proto := range detectProtocols { u := strings.TrimSpace(p.Base(proto)) if u == "" { u = DetectBase(base, proto) @@ -118,6 +133,8 @@ func (p Provider) detecting(base string) (Provider, error) { q.Responses = u case Anthropic: q.Anthropic = u + case Gemini: + q.Gemini = u } } if len(q.Speaks()) == 0 { @@ -197,8 +214,8 @@ const ( ) // DetectModels asks each of models (the first DetectMax, an image model -// left out: it is asked on the images API) on each of Chat, Responses and -// Anthropic at the URL p has for it, else at base as it takes it, and +// left out: it is asked on the images API) on each of Chat, Responses, +// Anthropic and Gemini at the URL p has for it, else at base as it takes it, and // says what each answered, model by model; and, by API, the first answer // from a model it served, else the first model's — what Detect says for // one. A probe not started before ctx ends says so, unasked. @@ -224,8 +241,8 @@ func (p Provider) DetectModels(ctx context.Context, base string, models []string sem := make(chan struct{}, DetectWide) var wg sync.WaitGroup for i, m := range ids { - out[i] = ModelDetection{Model: m, Results: make([]Detection, len(Protocols))} - for j, proto := range Protocols { + out[i] = ModelDetection{Model: m, Results: make([]Detection, len(detectProtocols))} + for j, proto := range detectProtocols { d, ask := q.detectOne(proto, m, detectWait) if ask != nil && catalog.ImagesAPI(m) { d.Error, ask = "an image model: asked on the images API, not here", nil @@ -253,8 +270,8 @@ func (p Provider) DetectModels(ctx context.Context, base string, models []string } } wg.Wait() - sum := make([]Detection, len(Protocols)) - for j := range Protocols { + sum := make([]Detection, len(detectProtocols)) + for j := range detectProtocols { sum[j] = out[0].Results[j] for _, md := range out { if md.Results[j].OK { @@ -293,8 +310,8 @@ func (p Provider) detectModels(ctx context.Context) []string { } // detectModel is the model of ids to ask proto for: one made for it — a -// Claude model on Anthropic's, an OpenAI one on Responses, any other on -// Chat — else the first that chats. +// Claude model on Anthropic's, an OpenAI one on Responses, a Gemini one on +// Gemini's, any other on Chat — else the first that chats. func detectModel(ids []string, proto Protocol) string { made := func(id string) bool { switch proto { @@ -302,6 +319,8 @@ func detectModel(ids []string, proto Protocol) string { return isClaude(id) case Responses: return openAIModel(id) + case Gemini: + return strings.Contains(strings.ToLower(id), "gemini") } return !isClaude(id) } diff --git a/internal/provider/detect_test.go b/internal/provider/detect_test.go index 89334c65..d41bbce6 100644 --- a/internal/provider/detect_test.go +++ b/internal/provider/detect_test.go @@ -47,7 +47,7 @@ func relay(t *testing.T) (*httptest.Server, func() []asked) { json.Unmarshal(b, &body) m, _ := body["model"].(string) mu.Lock() - got = append(got, asked{r.Method, r.URL.Path, r.Header.Get("Authorization") + r.Header.Get("x-api-key"), m, body}) + got = append(got, asked{r.Method, r.URL.Path, r.Header.Get("Authorization") + r.Header.Get("x-api-key") + r.Header.Get("x-goog-api-key"), m, body}) mu.Unlock() switch r.Method + " " + r.URL.Path { case "GET /v1/models": @@ -81,7 +81,7 @@ func TestDetectProtocols(t *testing.T) { if err != nil { t.Fatal(err) } - if len(res) != 3 { + if len(res) != 4 { t.Fatalf("%s: %+v", typed, res) } want := []struct { @@ -93,6 +93,9 @@ func TestDetectProtocols(t *testing.T) { {Chat, true, srv.URL + "/v1", "gpt-5.5"}, {Responses, false, srv.URL + "/v1", "gpt-5.5"}, {Anthropic, true, srv.URL, "claude-sonnet-5"}, + // no Gemini model listed: the first that chats, at the + // …/v1beta beside the …/v1, where this relay serves none + {Gemini, false, srv.URL + "/v1beta", "gpt-5.5"}, } for i, w := range want { r := res[i] @@ -123,7 +126,7 @@ func TestDetectProtocols(t *testing.T) { } } slices.Sort(posts) - if !slices.Equal(posts, []string{"/v1/chat/completions", "/v1/messages", "/v1/responses"}) { + if !slices.Equal(posts, []string{"/v1/chat/completions", "/v1/messages", "/v1/responses", "/v1beta/models/gpt-5.5:generateContent"}) { t.Fatalf("%s: asked %v", typed, posts) } } @@ -179,14 +182,14 @@ func TestResponsesProbesUseArrayInput(t *testing.T) { } t.Run("Detect", func(t *testing.T) { rs, err := p.Detect(ctx, srv.URL, model) - if err != nil || len(rs) != len(Protocols) { + if err != nil || len(rs) != len(detectProtocols) { t.Fatalf("Detect: %v, %+v", err, rs) } check(t, rs[1].Result) }) t.Run("DetectModels", func(t *testing.T) { each, sum, err := p.DetectModels(ctx, srv.URL, []string{model}) - if err != nil || len(each) != 1 || len(each[0].Results) != len(Protocols) || len(sum) != len(Protocols) { + if err != nil || len(each) != 1 || len(each[0].Results) != len(detectProtocols) || len(sum) != len(detectProtocols) { t.Fatalf("DetectModels: %v, %+v, %+v", err, each, sum) } check(t, each[0].Results[1].Result) @@ -357,24 +360,24 @@ func TestDetectModels(t *testing.T) { if err != nil { t.Fatal(err) } - want := map[string][3]bool{ // chat, responses, anthropic - "gpt-both": {true, true, false}, - "gpt-resp": {false, true, false}, - "glm-chat": {true, false, false}, - "claude-only": {false, false, true}, - "gpt-image-2": {false, false, false}, + want := map[string][4]bool{ // chat, responses, anthropic, gemini + "gpt-both": {true, true, false, false}, + "gpt-resp": {false, true, false, false}, + "glm-chat": {true, false, false, false}, + "claude-only": {false, false, true, false}, + "gpt-image-2": {false, false, false, false}, } if len(each) != len(want) { t.Fatalf("asked %d models: %+v", len(each), each) } for _, md := range each { w, ok := want[md.Model] - if !ok || len(md.Results) != 3 { + if !ok || len(md.Results) != 4 { t.Fatalf("%+v", md) } for i, r := range md.Results { - if r.OK != w[i] || r.Model != md.Model || r.Protocol != Protocols[i] { - t.Fatalf("%s on %s: %+v, want ok=%v", md.Model, Protocols[i], r, w[i]) + if r.OK != w[i] || r.Model != md.Model || r.Protocol != detectProtocols[i] { + t.Fatalf("%s on %s: %+v, want ok=%v", md.Model, detectProtocols[i], r, w[i]) } if !r.OK && md.Model != "gpt-image-2" && (r.Status != 400 || !strings.Contains(r.Error, "not supported")) { t.Fatalf("%s on %s said %+v", md.Model, r.Protocol, r) @@ -384,14 +387,14 @@ func TestDetectModels(t *testing.T) { if each[len(each)-1].Results[0].Error == "" { t.Fatal("an image model was asked on chat") } - if asked != 12 { - t.Fatalf("%d requests for 4 models on 3 APIs", asked) + if asked != 16 { + t.Fatalf("%d requests for 4 models on 4 APIs", asked) } for i, w := range []struct { model, base string }{{"gpt-both", srv.URL + "/v1"}, {"gpt-both", srv.URL + "/v1"}, {"claude-only", srv.URL}} { if !sum[i].OK || sum[i].Model != w.model || sum[i].Base != w.base { - t.Fatalf("by API %s: %+v", Protocols[i], sum[i]) + t.Fatalf("by API %s: %+v", detectProtocols[i], sum[i]) } } @@ -407,7 +410,7 @@ func TestDetectModels(t *testing.T) { if err != nil { t.Fatal(err) } - if len(each) != DetectMax || asked != 3*DetectMax || most > DetectWide { + if len(each) != DetectMax || asked != 4*DetectMax || most > DetectWide { t.Fatalf("%d models, %d requests, %d at once", len(each), asked, most) } if _, _, err := (Provider{Key: "k"}).DetectModels(context.Background(), srv.URL, []string{" "}); err == nil { @@ -439,3 +442,82 @@ func TestDetectModelsOutOfTime(t *testing.T) { t.Fatalf("%d sent past the time", n) } } + +// A Gemini API is detected too (#1346, NagaseMinato: 「检测」不会探测 Gemini +// 端点,为什么呢?): typed as the relay's …/v1, the bare host, Google's +// …/v1beta or a generateContent URL pasted whole, it is asked at the +// …/v1beta, a Gemini model from its own list, the key in x-goog-api-key, the +// smallest request generateContent takes; a provider's own Gemini URL is +// asked as it is. +func TestDetectGemini(t *testing.T) { + detectHome(t) + var mu sync.Mutex + var gem []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v1beta/models" && r.Method == "GET": + if r.Header.Get("x-goog-api-key") != "AIza-k" { + http.Error(w, `{"error":{"code":401,"message":"API key not valid","status":"UNAUTHENTICATED"}}`, http.StatusUnauthorized) + return + } + io.WriteString(w, `{"models":[{"name":"models/text-embedding-004","supportedGenerationMethods":["embedContent"]},{"name":"models/gemini-2.5-flash","supportedGenerationMethods":["generateContent","countTokens"]}]}`) + case strings.HasPrefix(r.URL.Path, "/v1beta/models/") && r.Method == "POST": + b, _ := io.ReadAll(r.Body) + mu.Lock() + gem = append(gem, r.URL.Path+" "+r.Header.Get("x-goog-api-key")+" "+r.Header.Get("Authorization")+" "+string(b)) + mu.Unlock() + if r.Header.Get("x-goog-api-key") != "AIza-k" { + http.Error(w, `{"error":{"code":401,"message":"API key not valid","status":"UNAUTHENTICATED"}}`, http.StatusUnauthorized) + return + } + io.WriteString(w, `{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"finishReason":"MAX_TOKENS","index":0}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2},"modelVersion":"gemini-2.5-flash"}`) + default: + http.Error(w, `{"error":{"code":404,"message":"Not Found","status":"NOT_FOUND"}}`, http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + for _, typed := range []string{srv.URL + "/v1", srv.URL, srv.URL + "/v1beta", srv.URL + "/v1beta/models/gemini-2.5-flash:generateContent"} { + mu.Lock() + gem = nil + mu.Unlock() + res, err := Provider{Key: "AIza-k"}.Detect(context.Background(), typed, "") + if err != nil { + t.Fatal(err) + } + g := res[len(res)-1] + if g.Protocol != Gemini || !g.OK || g.Base != srv.URL+"/v1beta" || g.Model != "gemini-2.5-flash" { + t.Fatalf("%s: gemini = %+v", typed, g) + } + mu.Lock() + if len(gem) != 1 || gem[0] != `/v1beta/models/gemini-2.5-flash:generateContent AIza-k {"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":16}}` { + t.Fatalf("%s: asked %q", typed, gem) + } + mu.Unlock() + for _, r := range res[:len(res)-1] { + if r.OK { + t.Fatalf("%s: %s answered on a Gemini-only relay: %+v", typed, r.Protocol, r) + } + } + } + + // the provider's own Gemini URL, and a model typed, asked as they are + res, err := Provider{Key: "AIza-k", Gemini: srv.URL + "/v1beta"}.Detect(context.Background(), "https://elsewhere.invalid/v1", "gemini-2.5-flash") + if err != nil { + t.Fatal(err) + } + if g := res[3]; !g.OK || g.Base != srv.URL+"/v1beta" { + t.Fatalf("own URL: %+v", g) + } + + // each model on each API: Gemini's the fourth + each, sum, err := Provider{Key: "AIza-k"}.DetectModels(context.Background(), srv.URL+"/v1beta", []string{"gemini-2.5-flash"}) + if err != nil { + t.Fatal(err) + } + if len(each) != 1 || len(each[0].Results) != 4 || !each[0].Results[3].OK || !sum[3].OK || sum[3].Base != srv.URL+"/v1beta" { + t.Fatalf("each %+v, sum %+v", each, sum) + } + if ps := All(); len(ps) != 0 { + t.Fatalf("detecting saved %+v", ps) + } +} diff --git a/internal/provider/test_protocol_headers_test.go b/internal/provider/test_protocol_headers_test.go index f5dd6a54..6093f03a 100644 --- a/internal/provider/test_protocol_headers_test.go +++ b/internal/provider/test_protocol_headers_test.go @@ -45,6 +45,7 @@ func TestProviderProbesUseProtocolHeaders(t *testing.T) { results = p.TestModels(ctx, []string{"gpt-6-sol"}) want = 1 case "detect": + want = len(detectProtocols) // Gemini's too, without anthropic-version detected, err := p.Detect(ctx, srv.URL, "gpt-6-sol") if err != nil { t.Fatal(err) @@ -53,6 +54,7 @@ func TestProviderProbesUseProtocolHeaders(t *testing.T) { results = append(results, d.Result) } case "detect-models": + want = len(detectProtocols) models, _, err := p.DetectModels(ctx, srv.URL, []string{"gpt-6-sol"}) if err != nil { t.Fatal(err)