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:
1 parent
92e4fc32fa
commit
cd8d64a9fc
3 files changed
+157
-40
No files matched your search
+24
-27
@@ -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())) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user