diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index cbc336e1..92cb21b2 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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())) { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index c6b1c39d..6fb9f089 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/tests/balance-fix.test.cjs b/internal/gui/tests/balance-fix.test.cjs index af6fd5ad..4bc46f4c 100644 --- a/internal/gui/tests/balance-fix.test.cjs +++ b/internal/gui/tests/balance-fix.test.cjs @@ -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, []); + }); + } + } + }); }