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:
1 parent
b946e018cf
commit
e20e1e9eea
9 files changed
+203
-68
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user