provider, gui: Detect asks a Gemini API too, at the URL's …/v1beta, with the key in x-goog-api-key and a gemini model from the list, and the editor shows Detect on a Gemini pick (#1346, NagaseMinato: 「检测」不会探测 Gemini 端点,为什么呢?)

Detect asked OpenAI chat, Responses and Anthropic only, and the editor hid
Detect when Gemini was picked. Now detectProtocols adds Gemini:
- a typed …/v1 or a bare host is asked at …/v1beta; a …/v1beta base is
  kept; a pasted …/models/x:generateContent URL is cut back to its base
  (for every API, so the others aren't asked under ":generateContent");
- the provider's own Gemini URL is asked as it is;
- the probe is the same tinyBody a Gemini test sends, x-goog-api-key, no
  Authorization; a model id with "gemini" in it is preferred.
Nothing is saved; "Use these" keeps the Gemini pick and its URL.

Run: go test -tags nogui ./... (all ok), go vet and GOOS=linux/windows
go vet on ./internal/provider ./internal/gui. GUI, Chromium and WebKit:
provider-gemini 8/8 (900px and 440px, en/zh/ja/de labels), provider-detect,
provider-detect-models, provider-base-api, provider-url-each-api,
provider-add-models, gui-ja, gui-de, gui-zh-tw, click-scroll.
Without the fix TestDetectGemini fails ("no model to try") and
provider-gemini fails 4 of 4 in Chromium.
Real: aihubmix (owner's key, sandbox HOME) at https://aihubmix.com/gemini/v1beta
with gemini-2.5-flash: Gemini ok 200; at https://aihubmix.com/v1 chat,
responses and anthropic ok and Gemini 400 (aihubmix serves Gemini under
/gemini). Not tried with a real Google AI Studio key.
This commit is contained in:
yetone committed 2026-10-09 02:20:30 +08:00
1 parent b946e018cf
commit e20e1e9eea
9 files changed
+203 -68

No files matched your search

+1 -1
View File
@@ -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
+5 -11
View File
@@ -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;
+4 -4
View File
@@ -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…",
+7 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -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",
+45 -12
View File
@@ -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);
+38 -19
View File
@@ -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)
}
+100 -18
View File
@@ -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)
}
}
@@ -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)