- {$t( - 'Rotate model API keys. Exhausted accounts retry next month; a successful request confirms recovery.', - )} -
+{$t('Rotate model API keys across accounts.')}
{error}
{/if} {#each pools as pool (pool.id)}+
{$t('Monthly retry timezone')}: {pool.timezone} · {$t('Day 1, 00:00')}
+ {#if !pool.configured}
{$t('Encrypted credential storage is not configured.')}
{/if} - {#if pool.accounts.length === 0}+ {#if pool.accounts.length === 0} +
| {$t('Account')} | {$t('Status')} | {$t('Usage (last 24 hours)')} | {$t('Actions')} |
|---|---|---|---|
{account.name}
- {account.mask} |
- {stateLabel(account)}
- {#if account.reason}{account.reason} {/if}
- {#if account.nextRetryAt}
- {$t('Next recovery attempt')}: {new Date(
- account.nextRetryAt,
- ).toLocaleString()}
- {/if}
- {#if account.probeUntil && account.probeUntil > Date.now()}
- {$t('Recovery request in progress')}
- {/if}
- |
- {usage?.requests ?? 0}
- {$t('requests')} · {usage?.tokens ?? 0} tokens {$t('Estimated cost')}: {usage?.costUsd == - null - ? '—' - : `$${usage.costUsd.toFixed(4)}`} |
-
-
-
-
-
- |
-
| {$t('Account')} | +{$t('Status')} | +{$t('Usage (last 24 hours)')} | + {#if pool.strategy === 'manual_priority'}{$t('Priority')} | {/if} +{$t('Actions')} | +
|---|---|---|---|---|
{account.name}
+ {account.mask} |
+
+ {stateLabel(account)}
+ {#if account.reason} + {account.reason} + {/if} + {#if account.nextRetryAt}+ {$t('Next recovery attempt')}: {new Date( + account.nextRetryAt, + ).toLocaleString($locale)} + {/if} + {#if account.probeUntil && account.probeUntil > Date.now()}+ {$t('Recovery request in progress')} + {/if} + |
+
+
+ {$t('{requests} requests · {tokens} tokens', {
+ requests: formatCount(usage?.requests ?? 0),
+ tokens: formatTokens(usage?.tokens ?? 0),
+ })}
+
+
+ {formatUsd(usage?.costUsd)}
+
+ |
+ {#if pool.strategy === 'manual_priority'}{account.priority} | {/if} +
+
+
+
+
+
+ |
+
{$t('Keys are encrypted on the server. Admin keys are not used for model requests.')}
- +{$t('Delete API key account')}: {deleting.account.name}?
- + +- {$t( - 'Connect Claude, ChatGPT, or GitHub Copilot subscriptions so Helm can route to them. You can connect several accounts per provider.', - )} - {$t('xAI/SuperGrok is also available.')} -
+{$t('Manage subscriptions and API key accounts in one place.')}
localStorage.setItem("locale", "en"));
+ await page.addInitScript(() => {
+ if (!localStorage.getItem("helm_admin_locale"))
+ localStorage.setItem("helm_admin_locale", "en");
+ });
await page.goto(url);
await expect(page).toHaveURL(url);
await expect(page).toHaveTitle(/Helm/i);
@@ -67,15 +71,28 @@ test("API key provider import, pause, edit and delete through the real admin UI"
await page.getByRole("button", { name: "Save API keys" }).click();
await expect(section.getByText("API 1", { exact: true })).toBeVisible();
await expect(section.getByText("API 2", { exact: true })).toBeVisible();
+ for (const name of ["API 1", "API 2"]) {
+ const accountRow = section.getByRole("row").filter({ hasText: name });
+ await accountRow.getByRole("button", { name: "Test", exact: true }).click();
+ const dialog = page.getByRole("dialog", { name: "Test API key" });
+ await expect(dialog.getByText(name, { exact: true })).toBeVisible();
+ await dialog.getByRole("button", { name: "Run test", exact: true }).click();
+ await expect(dialog.getByText("Test passed", { exact: true })).toBeVisible();
+ await expect(dialog.getByText("hello from mock", { exact: true })).toBeVisible();
+ await dialog.getByRole("button", { name: "Close", exact: true }).click();
+ }
let row = section.getByRole("row").filter({ hasText: "API 1" });
+ await expect(row.getByRole("button", { name: "Delete", exact: true })).not.toBeVisible();
+ await row.locator("summary").click();
await row.getByRole("button", { name: "Pause", exact: true }).click();
await expect(row.getByText("Paused manually")).toBeVisible();
- await expect(row.getByRole("button", { name: "Try recovery" })).toBeDisabled();
- await row.getByRole("button", { name: "Edit", exact: true }).click();
+ await expect(row.getByRole("button", { name: "Try recovery" })).toHaveCount(0);
+ await row.getByRole("button", { name: "Manage", exact: true }).click();
await page.getByRole("dialog").getByLabel("Name", { exact: true }).fill("Primary account");
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
row = section.getByRole("row").filter({ hasText: "Primary account" });
await expect(row).toBeVisible();
+ await row.locator("summary").click();
await row.getByRole("button", { name: "Resume", exact: true }).click();
await expect(row.getByText("Available", { exact: true })).toBeVisible();
await section.getByLabel("API key rotation strategy").selectOption("manual_priority");
@@ -87,23 +104,118 @@ test("API key provider import, pause, edit and delete through the real admin UI"
expect(text).not.toContain("sk-proj-ui-test");
expect(JSON.parse(text).pools[0].accounts).toHaveLength(2);
expect(JSON.parse(text).pools[0].strategy).toBe("manual_priority");
+ await row.locator("summary").click();
+ await row.getByRole("button", { name: "Delete", exact: true }).click();
+ await page.getByRole("button", { name: "Confirm delete" }).click();
+ await expect(section.getByText("Primary account", { exact: true })).toHaveCount(0);
const evidenceDir = process.env.HELM_UI_EVIDENCE_DIR;
if (evidenceDir) {
mkdirSync(evidenceDir, { recursive: true });
+ // Synthetic presentation fixtures; no production account data in screenshots.
+ await page.route("**/admin/api/api-key-pools", (route) =>
+ route.fulfill({
+ json: {
+ pools: [
+ {
+ id: "openai",
+ configured: true,
+ timezone: "Asia/Shanghai",
+ strategy: "round_robin",
+ models: ["gpt-6-luna"],
+ accounts: Array.from({ length: 4 }, (_, i) => ({
+ id: `sample-${i}`,
+ name: `OpenAI ${i + 1}`,
+ mask: "••••demo",
+ enabled: true,
+ priority: 50,
+ models: null,
+ state: "ready",
+ reason: null,
+ nextRetryAt: null,
+ probeUntil: null,
+ deniedModels: [],
+ })),
+ usage: Array.from({ length: 4 }, (_, i) => ({
+ account: `sample-${i}`,
+ requests: 120 + i * 10,
+ tokens: 42000 + i * 1000,
+ costUsd: i === 0 ? 0.000003 : 0.024 + i * 0.003,
+ })),
+ },
+ ],
+ },
+ }),
+ );
+ const providerId = "openai-codex";
+ await page.route("**/admin/api/oauth/overview*", (route) =>
+ route.fulfill({
+ json: {
+ configured: true,
+ selectionStrategy: "balanced",
+ providers: [
+ {
+ id: providerId,
+ name: "ChatGPT Plus/Pro (Codex)",
+ flow: "device_code",
+ accounts: ["Team account", "Personal account"].map((account) => ({
+ account,
+ healthy: true,
+ expiresAt: null,
+ updatedAt: Date.now(),
+ priority: 50,
+ schedulable: true,
+ fastMode: false,
+ models: ["gpt-6.1-sol", "gpt-6-astra", "gpt-6.1-terra"],
+ })),
+ },
+ ],
+ usage: ["Team account", "Personal account"].map((account) => ({
+ providerId,
+ account,
+ requests: 156,
+ tokens: 20300000,
+ costUsd: 25.27,
+ rpm: 3.87,
+ })),
+ quota: [],
+ refresh: {
+ state: "idle",
+ jobId: null,
+ requestedAt: null,
+ startedAt: null,
+ finishedAt: null,
+ lastSuccessAt: null,
+ nextAllowedAt: null,
+ error: null,
+ },
+ },
+ }),
+ );
+ await page.evaluate(() => localStorage.setItem("helm_admin_locale", "zh-hans"));
+ await page.reload();
+ await expect(page.getByRole("heading", { name: "提供商", exact: true })).toBeVisible();
+ await expect(page.getByText("OpenAI 4", { exact: true })).toBeVisible();
+ await page.setViewportSize({ width: 1440, height: 1100 });
+ const lastAccount = page.getByRole("row").filter({ hasText: "OpenAI 4" });
+ await lastAccount.locator("summary").click();
+ await expect(lastAccount.getByRole("button", { name: "删除", exact: true })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(
+ lastAccount.getByRole("button", { name: "删除", exact: true }),
+ ).not.toBeVisible();
await page.screenshot({
path: join(evidenceDir, "api-key-pools-desktop.png"),
fullPage: false,
});
await page.setViewportSize({ width: 390, height: 844 });
- await expect(section.getByRole("button", { name: "Add API keys" })).toBeVisible();
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBe(true);
await page.screenshot({
path: join(evidenceDir, "api-key-pools-mobile.png"),
- fullPage: false,
+ fullPage: true,
});
}
- await row.getByRole("button", { name: "Delete", exact: true }).click();
- await page.getByRole("button", { name: "Confirm delete" }).click();
- await expect(section.getByText("Primary account", { exact: true })).toHaveCount(0);
expect(errors).toEqual([]);
} finally {
await handle?.dispose?.();
diff --git a/apps/gateway/src/api-key-pool.integration.test.ts b/apps/gateway/src/api-key-pool.integration.test.ts
index 94f67ec7..81dc6144 100644
--- a/apps/gateway/src/api-key-pool.integration.test.ts
+++ b/apps/gateway/src/api-key-pool.integration.test.ts
@@ -10,6 +10,7 @@ afterEach(() => vi.unstubAllEnvs());
describe("API key pools through the real gateway", () => {
it("protects admin writes, rotates Chat and Responses credentials, persists quota and attributes usage", async () => {
const calls: string[] = [];
+ let failTest = false;
const events: unknown[] = [];
const upstream = createServer(async (req, res) => {
let raw = "";
@@ -21,7 +22,10 @@ describe("API key pools through the real gateway", () => {
};
const key = req.headers.authorization ?? "";
calls.push(key);
- if (body.messages?.some((m) => m.content === "exhaust") && key.endsWith("first")) {
+ if (
+ failTest ||
+ (body.messages?.some((m) => m.content === "exhaust") && key.endsWith("first"))
+ ) {
res.writeHead(429, { "content-type": "application/json" });
res.end(
JSON.stringify({ error: { code: "credit_balance_exhausted", message: "out of credit" } }),
@@ -195,6 +199,35 @@ describe("API key pools through the real gateway", () => {
).json()) as { pools: Array<{ usage: Array<{ requests: number }> }> };
expect(body.pools[0]?.usage.reduce((n, row) => n + row.requests, 0)).toBe(4);
});
+ const accountId = JSON.parse(text).pools[0].accounts[0].id;
+ const testPath = `/admin/api/api-key-pools/openai/accounts/${accountId}/test`;
+ expect(
+ (await request(testPath, { method: "POST", body: JSON.stringify({ model: "test-chat" }) }))
+ .status,
+ ).toBe(401);
+ for (const model of ["test-chat", "test-responses"]) {
+ const before = calls.length;
+ const result = await request(testPath, {
+ method: "POST",
+ headers: admin,
+ body: JSON.stringify({ model }),
+ });
+ expect(await result.json()).toMatchObject({ ok: true, model, response: "OK" });
+ expect(calls.slice(before)).toEqual(["Bearer sk-proj-mock-first"]);
+ }
+ failTest = true;
+ const before = calls.length;
+ const failed = await request(testPath, {
+ method: "POST",
+ headers: admin,
+ body: JSON.stringify({ model: "test-chat" }),
+ });
+ expect(await failed.json()).toMatchObject({
+ ok: false,
+ error: "credit_balance_exhausted",
+ upstreamStatus: 429,
+ });
+ expect(calls.slice(before)).toEqual(["Bearer sk-proj-mock-first"]);
} finally {
await handle?.dispose?.();
await new Promise