feat(gui): add Sub2API balance autofill on account balance hint (#1059)

* feat(gui): add Sub2API balance autofill

Offer Sub2API alongside NewAPI in the custom provider's balance-token hint.
Fill /api/v1/user/profile and $data.balance, and describe both endpoints in
the existing translations.

Verified: native macOS build; 10 existing browser and translation checks;
Sub2API flows in Chromium and WebKit, four languages, at 900px and 440px;
live web assets. The original code fails the temporary two-shortcut check.
Full Go suite, cross-platform builds and live-account validation not run.

* fix(gui): preserve custom balance fields in account shortcuts

Use the same replacement rule for both account balance shortcuts, keeping
user-written expressions. Describe both endpoints in the key-only URL
warning and restore the rationale for flagging missing and key-only URLs.

Add browser cases for both URL states, new providers, saved form values,
narrow layouts and all four languages.

Validation: 111/111 related checks across Chromium and WebKit; native macOS
build; 38/38 after baseline failure checks. New button cases fail against
d9b2cdc5 app.js, and custom-field checks fail against cf3f3d9d app.js.
Full Go suite, cross-platform builds and live-account validation not run.
This commit is contained in:
mslxl authored and GitHub committed 2026-10-07 19:11:01 +08:00
1 parent 92e4fc32fa
commit cd8d64a9fc
3 files changed
+157 -40

No files matched your search

+24 -27
View File
@@ -10919,14 +10919,9 @@ function balanceError(err) {
return "";
}
// balanceFix: under a custom provider's balance token, what its Balance URL
// wants in place of what is there, with the one click that sets it:
// new-api's /api/usage/token is asked with the key alone and never with the
// token (balance.go doesn't send it there), and a token without a URL is
// asked nowhere; new-api's /api/user/self takes the token, with the user's
// id in New-Api-User, which is said too while no such header is set. A URL
// of any other kind (a sub2api panel's) is left alone: a token's shape
// can't tell the two apart, new-api's own sign-ins being JWTs too.
// An account token is unused without a Balance URL and can't authenticate
// /api/usage/token, which takes API keys only. Other URLs may belong to
// another relay and are left alone; both new-api and sub2api issue JWTs.
function balanceFix(p) {
const box = el("div", "bal-fix");
const origin = (u) => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) ? x.origin : ""; } catch { return ""; } };
@@ -10939,30 +10934,32 @@ function balanceFix(p) {
let path = "";
try { path = new URL(u).pathname.replace(/\/+$/, ""); } catch {}
let why = "";
if (path === "/api/usage/token") why = t("…/api/usage/token takes the API key, not this token: a new-api relay tells the account's balance to the token at /api/user/self.");
else if (!u) why = t("The token needs a Balance URL: a new-api relay tells the account's balance to it at /api/user/self.");
if (path === "/api/usage/token") why = t("…/api/usage/token takes the API key, not this token: for the account's balance, new-api uses /api/user/self; sub2api uses /api/v1/user/profile.");
else if (!u) why = t("The token needs a Balance URL: a new-api relay uses /api/user/self; a sub2api panel uses /api/v1/user/profile to read the account's balance.");
if (why) {
box.classList.add("warn");
box.append(el("span", "", why));
const site = origin(u) || origin(draft.chat || draft.anthropic || draft.responses || "");
if (!site) return;
const to = site + "/api/user/self";
const use = el("button", "text action", t("Use {url}", { url: to }));
use.onclick = () => {
draft.balanceURL = to;
// /api/usage/token's fields aren't in /api/user/self's reply; the
// account's quota is, $1 to 500000 of it
if (!(draft.balancePath || "").trim() || /total_(available|granted|used)|unlimited_quota/.test(draft.balancePath)) draft.balancePath = "$data.quota / 500000";
const ed = box.closest(".editor");
const set = (sel, v) => { const i = ed?.querySelector(sel); if (i) i.value = v; };
set(".bal-url", draft.balanceURL);
set(".bal-path", draft.balancePath);
// what changed, in view below
const more = ed?.querySelector("details.more");
if (more) more.open = true;
box.refresh();
};
box.append(use);
for (const path of ["/api/user/self", "/api/v1/user/profile"]) {
const to = site + path;
const use = el("button", "text action", t("Use {url}", { url: to }));
use.onclick = () => {
draft.balanceURL = to;
// An unrelated expression belongs to the user, so changing the
// endpoint must not overwrite it.
if (!(draft.balancePath || "").trim() || /total_(available|granted|used)|unlimited_quota/.test(draft.balancePath)) draft.balancePath = balanceFieldOf(to);
const ed = box.closest(".editor");
const bal = ed?.querySelector(".bal-url");
const field = ed?.querySelector(".bal-path");
if (bal) bal.value = draft.balanceURL;
if (field) { field.value = draft.balancePath; field.placeholder = balanceFieldOf(draft.balanceURL); }
const more = ed?.querySelector("details.more");
if (more) more.open = true;
box.refresh();
};
box.append(use);
}
return;
}
if (path === "/api/user/self" && !(draft.headers || []).some((h) => (h[0] || "").trim().toLowerCase() === "new-api-user" && (h[1] || "").trim())) {
+6 -6
View File
@@ -2200,8 +2200,8 @@ const I18N = {
"saved · paste a new one to replace it": "已保存 · 粘贴新的即可替换",
"optional · the account's system access token": "可选 · 账户的系统访问令牌",
"What the Balance URL is asked with in place of the key, when it wants the account's own token: a new-api relay's System Access Token, or a sub2api panel's login token (a JWT, sent as a Bearer); it is used for nothing else.": "余额接口需要账户自己的令牌时,用它代替 Key 去请求:new-api 中转站填「系统访问令牌」,sub2api 面板填登录令牌(JWT,会以 Bearer 发送);不作他用。",
"…/api/usage/token takes the API key, not this token: a new-api relay tells the account's balance to the token at /api/user/self.": "…/api/usage/token 只认 API Key,不认这个令牌:new-api 中转站用 /api/user/self 凭令牌查账户余额。",
"The token needs a Balance URL: a new-api relay tells the account's balance to it at /api/user/self.": "令牌还需要一个余额查询地址:new-api 中转站用 /api/user/self 凭令牌查账户余额。",
"…/api/usage/token takes the API key, not this token: for the account's balance, new-api uses /api/user/self; sub2api uses /api/v1/user/profile.": "…/api/usage/token 只认 API Key,不认这个令牌:查账户余额时,new-api 使用 /api/user/self,sub2api 使用 /api/v1/user/profile。",
"The token needs a Balance URL: a new-api relay uses /api/user/self; a sub2api panel uses /api/v1/user/profile to read the account's balance.": "令牌还需要一个余额查询地址:new-api 中转站用 /api/user/self 凭令牌查账户余额;sub2api 面板用 /api/v1/user/profile。",
"Use {url}": "改用 {url}",
"A new-api relay also wants the header New-Api-User = your user ID (shown in the site's personal settings): add it under Headers.": "new-api 中转站还需请求头 New-Api-User = 你的用户 ID(见站点个人设置),请在「请求头」中添加。",
"The Balance URL …/api/usage/token takes the API key, not the access token — set it to …/api/user/self in the provider's settings": "余额查询地址 …/api/usage/token 只认 API Key,不认访问令牌,请在供应商设置里改为 …/api/user/self",
@@ -5791,8 +5791,8 @@ const I18N = {
"saved · paste a new one to replace it": "保存済み · 新しいものを貼り付けると置き換えます",
"optional · the account's system access token": "任意 · アカウントのシステムアクセストークン",
"What the Balance URL is asked with in place of the key, when it wants the account's own token: a new-api relay's System Access Token, or a sub2api panel's login token (a JWT, sent as a Bearer); it is used for nothing else.": "残高 URL がアカウント自身のトークンを求める場合に、キーの代わりに使うものです。new-api 中継サイトでは System Access Token、sub2api パネルではログイントークン(JWT、Bearer として送信)を入力します。それ以外には使いません。",
"…/api/usage/token takes the API key, not this token: a new-api relay tells the account's balance to the token at /api/user/self.": "…/api/usage/token が受け付けるのは API キーで、このトークンではありません。new-api 中継サイトでは /api/user/self でトークンによりアカウント残高を取得します。",
"The token needs a Balance URL: a new-api relay tells the account's balance to it at /api/user/self.": "トークンには残高 URL が必要です。new-api 中継サイトでは /api/user/self でトークンによりアカウント残高を取得します。",
"…/api/usage/token takes the API key, not this token: for the account's balance, new-api uses /api/user/self; sub2api uses /api/v1/user/profile.": "…/api/usage/token が受け付けるのは API キーで、このトークンではありません。アカウント残高の取得には new-api は /api/user/self、sub2api は /api/v1/user/profile を使います。",
"The token needs a Balance URL: a new-api relay uses /api/user/self; a sub2api panel uses /api/v1/user/profile to read the account's balance.": "トークンには残高 URL が必要です。new-api 中継サイトでは /api/user/self、sub2api パネルでは /api/v1/user/profile でトークンによりアカウント残高を取得します。",
"Use {url}": "{url} を使う",
"A new-api relay also wants the header New-Api-User = your user ID (shown in the site's personal settings): add it under Headers.": "new-api 中継サイトではヘッダー New-Api-User = あなたのユーザー ID(サイトの個人設定に表示)も必要です。「ヘッダー」に追加してください。",
"The Balance URL …/api/usage/token takes the API key, not the access token — set it to …/api/user/self in the provider's settings": "残高 URL …/api/usage/token が受け付けるのは API キーで、アクセストークンではありません — プロバイダの設定で …/api/user/self に変更してください",
@@ -9380,8 +9380,8 @@ const I18N = {
"saved · paste a new one to replace it": "gespeichert · zum Ersetzen ein neues einfügen",
"optional · the account's system access token": "optional · das System-Zugriffstoken des Kontos",
"What the Balance URL is asked with in place of the key, when it wants the account's own token: a new-api relay's System Access Token, or a sub2api panel's login token (a JWT, sent as a Bearer); it is used for nothing else.": "Damit wird die Guthaben-URL statt mit dem Schlüssel abgefragt, wenn sie das eigene Token des Kontos verlangt: das System Access Token eines new-api-Relays oder das Login-Token eines sub2api-Panels (ein JWT, als Bearer gesendet); es wird für nichts anderes verwendet.",
"…/api/usage/token takes the API key, not this token: a new-api relay tells the account's balance to the token at /api/user/self.": "…/api/usage/token erwartet den API-Schlüssel, nicht dieses Token: Ein new-api-Relay liefert das Kontoguthaben für das Token unter /api/user/self.",
"The token needs a Balance URL: a new-api relay tells the account's balance to it at /api/user/self.": "Das Token braucht eine Guthaben-URL: Ein new-api-Relay liefert das Kontoguthaben dafür unter /api/user/self.",
"…/api/usage/token takes the API key, not this token: for the account's balance, new-api uses /api/user/self; sub2api uses /api/v1/user/profile.": "…/api/usage/token erwartet den API-Schlüssel, nicht dieses Token: Für das Kontoguthaben verwendet new-api /api/user/self und sub2api /api/v1/user/profile.",
"The token needs a Balance URL: a new-api relay uses /api/user/self; a sub2api panel uses /api/v1/user/profile to read the account's balance.": "Das Token braucht eine Guthaben-URL: Ein new-api-Relay fragt das Kontoguthaben mit dem Token unter /api/user/self ab, ein sub2api-Panel unter /api/v1/user/profile.",
"Use {url}": "{url} verwenden",
"A new-api relay also wants the header New-Api-User = your user ID (shown in the site's personal settings): add it under Headers.": "Ein new-api-Relay verlangt zusätzlich den Header New-Api-User = Ihre Benutzer-ID (in den persönlichen Einstellungen der Website zu sehen): Fügen Sie ihn unter „Header“ hinzu.",
"The Balance URL …/api/usage/token takes the API key, not the access token — set it to …/api/user/self in the provider's settings": "Die Guthaben-URL …/api/usage/token erwartet den API-Schlüssel, nicht das Zugriffstoken – stellen Sie sie in den Anbietereinstellungen auf …/api/user/self",
+127 -7
View File
@@ -21,14 +21,15 @@ const relay = {
const providers = { providers: [relay], presets: [], excluded: [], gateway: { running: true, window: true } };
const state = { agents: [], profiles: [], settings: { lang: "en", theme: "light" } };
function server(posted) {
function server(posted, { lang = "en", provider = relay } = {}) {
const testProviders = { ...providers, providers: [provider] };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: 'window.bootPrefs = {lang:"en",theme:"light",web:true};' });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = ${JSON.stringify({ lang, theme: "light", web: true })};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/state") return json({ ...state, settings: { ...state.settings, lang } });
if (url.pathname === "/api/providers") return json(testProviders);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname.startsWith("/api/provider/")) {
const body = route.request().postDataJSON();
@@ -38,7 +39,7 @@ function server(posted) {
if (body.headers?.["New-Api-User"]) return json({ ok: true, amount: "$3.00" });
return json({ ok: true, amount: "", error: "401 Unauthorized: add the header New-Api-User = your user ID (shown in the site's personal settings) to this provider's Headers; the relay said: 无权进行此操作,未提供 New-Api-User" });
}
return json(providers);
return json(testProviders);
}
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
@@ -122,8 +123,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.match(said[1], /^Add the header New-Api-User = your user ID/);
assert.equal(said[2], "Allowance unavailable");
const missing = await page.evaluate(() => [
"…/api/usage/token takes the API key, not this token: a new-api relay tells the account's balance to the token at /api/user/self.",
"The token needs a Balance URL: a new-api relay tells the account's balance to it at /api/user/self.",
"…/api/usage/token takes the API key, not this token: for the account's balance, new-api uses /api/user/self; sub2api uses /api/v1/user/profile.",
"The token needs a Balance URL: a new-api relay uses /api/user/self; a sub2api panel uses /api/v1/user/profile to read the account's balance.",
"Use {url}", "Check balance", "Ask the Balance URL now, as the form has it", "No Balance URL to ask",
"A new-api relay also wants the header New-Api-User = your user ID (shown in the site's personal settings): add it under Headers.",
"The Balance URL …/api/usage/token takes the API key, not the access token — set it to …/api/user/self in the provider's settings",
@@ -158,4 +159,123 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.equal(await page.locator(".editor .bal-res").count(), 0, "a provider not yet added has nothing to check with");
assert.deepEqual(errors, []);
});
for (const lang of ["en", "zh", "ja", "de"]) {
for (const width of [900, 440]) {
test(`${engine}: Sub2API balance shortcut saves the profile endpoint (${lang}, ${width}px)`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const context = await browser.newContext({ viewport: { width, height: 760 }, reducedMotion: "reduce" });
t.after(() => browser.close());
for (const scenario of [
{ name: "saved token without a Balance URL", balanceURL: "", balancePath: "" },
{ name: "saved token with the key-only Balance URL", balanceURL: relay.balanceURL, balancePath: relay.balancePath },
{ name: "new provider with a token and no Balance URL", isNew: true },
]) {
await t.test(scenario.name, async (st) => {
const page = await context.newPage();
page.setDefaultTimeout(5000);
st.after(() => page.close());
const errors = [], posted = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(posted, { lang, provider: scenario.isNew ? relay : { ...relay, balanceURL: scenario.balanceURL, balancePath: scenario.balancePath } }));
await page.goto("http://magpie.test/?view=providers");
await page.locator(".row.provider", { hasText: "Relay" }).waitFor();
if (scenario.isNew) {
await page.evaluate(() => { adding = true; editing = { custom: true }; draft = null; renderProviders(); });
const namePlaceholder = await page.evaluate(() => t("e.g. My Relay"));
await page.getByPlaceholder(namePlaceholder, { exact: true }).fill("Sub2API");
await page.locator(".editor input[type=url]").first().fill("https://relay.example.com/v1");
assert.equal(await page.locator(".editor .bal-fix").textContent(), "", "no token, no warning");
await page.locator(".editor input[type=password]").nth(1).fill("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln");
} else {
await page.locator(".row.provider", { hasText: "Relay" }).click();
}
const fix = page.locator(".editor .bal-fix");
await fix.waitFor();
assert.equal(await fix.getByRole("button").count(), 2, "both account balance shortcuts are offered");
assert.match(await fix.textContent(), /\/api\/user\/self/);
assert.match(await fix.textContent(), /\/api\/v1\/user\/profile/);
const to = "https://relay.example.com/api/v1/user/profile";
const label = await page.evaluate((url) => t("Use {url}", { url }), to);
const use = fix.getByRole("button", { name: label, exact: true });
await use.scrollIntoViewIfNeeded();
const layout = await fix.evaluate((e) => {
const bounds = e.getBoundingClientRect();
return {
fits: e.scrollWidth <= e.clientWidth + 1,
buttonsFit: [...e.querySelectorAll("button")].every((b) => {
const r = b.getBoundingClientRect();
return r.left >= bounds.left - 1 && r.right <= bounds.right + 1;
}),
pageFits: document.documentElement.scrollWidth <= innerWidth,
};
});
assert.deepEqual(layout, { fits: true, buttonsFit: true, pageFits: true }, "the shortcuts fit without sideways scroll");
assert.equal(posted.length, 0, "showing the shortcuts writes nothing");
await use.click();
assert.equal(await page.locator(".editor .bal-url").inputValue(), to);
assert.equal(await page.locator(".editor .bal-path").inputValue(), "$data.balance");
assert(await page.locator(".editor details.more").evaluate((d) => d.open));
assert.equal(await fix.textContent(), "", "the warning clears without asking for New-Api-User");
assert.equal(posted.length, 0, "the shortcut changes only the draft");
const save = await page.evaluate((isNew) => t(isNew ? "Add" : "Save"), !!scenario.isNew);
const response = page.waitForResponse((r) => new URL(r.url()).pathname === "/api/provider/save");
await page.locator(".editor .bar").getByRole("button", { name: save, exact: true }).click();
await response;
const saved = posted.find((p) => p.action === "save").body;
assert.equal(saved.balanceURL, to);
assert.equal(saved.balancePath, "$data.balance");
assert.equal(saved.new, !!scenario.isNew);
if (scenario.isNew) assert.equal(saved.balanceToken, "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln");
assert.deepEqual(errors, []);
});
}
});
}
}
test(`${engine}: account balance shortcuts preserve a user-written Balance field`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const context = await browser.newContext({ viewport: { width: 440, height: 760 }, reducedMotion: "reduce" });
t.after(() => browser.close());
for (const endpoint of ["/api/user/self", "/api/v1/user/profile"]) {
for (const [typed, replace] of [
["", true],
[" ", true],
["$data.total_available / 500000", true],
["$data.total_granted - data.total_used", true],
["unlimited_quota", true],
["$credits.remaining", false],
["$data.balance * 0.5", false],
]) {
await t.test(`${endpoint}: ${JSON.stringify(typed)}`, async (st) => {
const page = await context.newPage();
page.setDefaultTimeout(5000);
st.after(() => page.close());
const posted = [], errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", server(posted));
await page.goto("http://magpie.test/?view=providers");
await page.locator(".row.provider", { hasText: "Relay" }).click();
await page.locator(".editor details.more summary").click();
await page.locator(".editor .bal-path").fill(typed);
const to = "https://relay.example.com" + endpoint;
await page.locator(".editor .bal-fix").getByRole("button", { name: "Use " + to, exact: true }).click();
const wanted = replace ? (endpoint === "/api/user/self" ? "$data.quota / 500000" : "$data.balance") : typed;
assert.equal(await page.locator(".editor .bal-url").inputValue(), to);
assert.equal(await page.locator(".editor .bal-path").inputValue(), wanted, "a user expression survives the URL change");
const response = page.waitForResponse((r) => new URL(r.url()).pathname === "/api/provider/save");
await page.locator(".editor .bar").getByRole("button", { name: "Save", exact: true }).click();
await response;
const saved = posted.find((p) => p.action === "save").body;
assert.equal(saved.balanceURL, to);
assert.equal(saved.balancePath, wanted);
assert.deepEqual(errors, []);
});
}
}
});
}