diff --git a/apps/admin/src/lib/components/ApiKeyPools.svelte b/apps/admin/src/lib/components/ApiKeyPools.svelte index c7fc217b..386186cf 100644 --- a/apps/admin/src/lib/components/ApiKeyPools.svelte +++ b/apps/admin/src/lib/components/ApiKeyPools.svelte @@ -1,7 +1,14 @@ -
-
-
+
+
+

{$t('API key providers')}

-

- {$t( - 'Rotate model API keys. Exhausted accounts retry next month; a successful request confirms recovery.', - )} -

+

{$t('Rotate model API keys across accounts.')}

- -
+ + {#if error}{/if} {#each pools as pool (pool.id)}
-
-
-

{pool.id}

-

+

+
+

+ {pool.id === 'openai' ? 'OpenAI' : pool.id} +

+

{$t('Monthly retry timezone')}: {pool.timezone} · {$t('Day 1, 00:00')}

-
+
- {#if !pool.configured}

+ {#if !pool.configured}

{$t('Encrypted credential storage is not configured.')}

{/if} - {#if pool.accounts.length === 0}

+ {#if pool.accounts.length === 0} +

{$t( 'No managed API keys. Existing environment credentials remain in use only until the pool is first configured.', )} -

{/if} -
- - - - {#each pool.accounts as account (account.id)} - {@const usage = pool.usage.find((row) => row.account === account.id)} - - - - - - - {/each} - -
{$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)}`}
- - - - -
-
+
+ {:else} +
+ + + + + + {#if pool.strategy === 'manual_priority'}{/if} + + + + {#each pool.accounts as account (account.id)} + {@const usage = pool.usage.find((row) => row.account === account.id)} + + + + + {#if pool.strategy === 'manual_priority'}{/if} + + + {/each} + +
{$t('Account')}{$t('Status')}{$t('Usage (last 24 hours)')}{$t('Priority')}{$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)} +
+
{account.priority} +
+ + + +
+
+
+ {/if}
{/each}
@@ -276,7 +399,17 @@

{$t('Keys are encrypted on the server. Admin keys are not used for model requests.')}

- +
+ +
{/if} @@ -326,7 +459,17 @@ bind:value={models} > - +
+ +
{/if} @@ -338,16 +481,83 @@ }} >

{$t('Delete API key account')}: {deleting.account.name}?

- + + +{/if} + +{#if testTarget} + +

{$t('Test API key')}

+
{ + event.preventDefault(); + void testAccount(); + }} > +
+

{testTarget.account.name}

+ {testTarget.account.mask} +
+

+ {$t( + 'Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.', + )} +

+ + {#if testError}{/if} + {#if testResult} +
+
+ {testResult.ok ? $t('Test passed') : $t('Test failed')} + {testResult.durationMs} ms +
+ {#if testResult.ok}
{testResult.response}
+ {:else}

+ {testResult.error}{testResult.upstreamStatus + ? ` · HTTP ${testResult.upstreamStatus}` + : ''} +

{/if} +
+ {/if} +
+ + +
+
{/if} diff --git a/apps/admin/src/lib/components/ApiKeyPools.test.ts b/apps/admin/src/lib/components/ApiKeyPools.test.ts index 32bba322..16cff278 100644 --- a/apps/admin/src/lib/components/ApiKeyPools.test.ts +++ b/apps/admin/src/lib/components/ApiKeyPools.test.ts @@ -1,7 +1,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { setLocale } from '$lib/i18n'; import ApiKeyPools from './ApiKeyPools.svelte'; -afterEach(() => vi.unstubAllGlobals()); +afterEach(async () => { + vi.unstubAllGlobals(); + await setLocale('en'); +}); describe('API key provider management', () => { it('imports multiple keys and clears the credential input after success', async () => { const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => @@ -71,8 +75,111 @@ describe('API key provider management', () => { await screen.findByText('Account A'); expect(screen.getByText('Paused manually')).toBeTruthy(); expect(screen.getByText('insufficient_quota')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Try recovery' }).hasAttribute('disabled')).toBe( - true, - ); + expect(screen.queryByRole('button', { name: 'Try recovery' })).toBeNull(); }); + it.each([{ deniedModels: [] }, { deniedModels: ['m'] }])( + 'uses localized usage and a compact action menu ($deniedModels)', + async ({ deniedModels }) => { + await setLocale('zh-hans'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + pools: [ + { + id: 'openai', + configured: true, + timezone: 'Asia/Shanghai', + strategy: 'round_robin', + models: ['m'], + usage: [{ account: 'a', requests: 3, tokens: 1250, costUsd: 0.000003 }], + accounts: [ + { + id: 'a', + name: 'Primary', + mask: '••••1234', + enabled: true, + priority: 50, + models: null, + state: 'ready', + reason: null, + nextRetryAt: null, + probeUntil: null, + deniedModels, + }, + ], + }, + ], + }), + ), + ); + render(ApiKeyPools); + await screen.findByText('Primary'); + expect(screen.getByRole('button', { name: '管理' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '删除' }).closest('details')?.open).toBe(false); + expect(screen.queryByRole('button', { name: '尝试恢复' }) !== null).toBe( + deniedModels.length > 0, + ); + expect(screen.getByText('$0.000003')).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/requests|Pause|tokens/); + const menu = screen.getByText('更多操作').closest('details')!; + await fireEvent.click(menu.querySelector('summary')!); + menu.open = true; + expect(screen.getByRole('button', { name: '暂停' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); + }, + ); }); + +it.each([true, false])( + 'tests the selected account and shows the actual result (ok=%s)', + async (ok) => { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => + url.endsWith('/test') + ? Response.json({ + ok, + model: 'm', + durationMs: 42, + ...(ok ? { response: 'OK' } : { error: 'insufficient_quota', upstreamStatus: 429 }), + }) + : Response.json({ + pools: [ + { + id: 'openai', + configured: true, + timezone: 'Asia/Shanghai', + strategy: 'round_robin', + models: ['m'], + usage: [], + accounts: [ + { + id: 'selected', + name: 'Selected key', + mask: '••••1234', + enabled: false, + priority: 50, + models: null, + state: 'ready', + reason: null, + nextRetryAt: null, + probeUntil: null, + deniedModels: [], + }, + ], + }, + ], + }), + ); + vi.stubGlobal('fetch', fetchMock); + render(ApiKeyPools); + await screen.findByText('Selected key'); + await fireEvent.click(screen.getByRole('button', { name: 'Test' })); + expect(screen.getByRole('dialog').textContent).toContain('Selected key'); + await fireEvent.click(screen.getByRole('button', { name: 'Run test' })); + await screen.findByText(ok ? 'OK' : /insufficient_quota/); + expect(fetchMock).toHaveBeenCalledWith( + '/admin/api/api-key-pools/openai/accounts/selected/test', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ model: 'm' }) }), + ); + }, +); diff --git a/apps/admin/src/locales/en.json b/apps/admin/src/locales/en.json index 7d81a1d8..446cf1e7 100644 --- a/apps/admin/src/locales/en.json +++ b/apps/admin/src/locales/en.json @@ -1179,5 +1179,13 @@ "Save API keys": "Save API keys", "Try recovery": "Try recovery", "Usage (last 24 hours)": "Usage (last 24 hours)", - "requests": "requests" + "requests": "requests", + "Rotate model API keys across accounts.": "Rotate model API keys across accounts.", + "Manage subscriptions and API key accounts in one place.": "Manage subscriptions and API key accounts in one place.", + "{requests} requests · {tokens} tokens": "{requests} requests · {tokens} tokens", + "Test API key": "Test API key", + "Test passed": "Test passed", + "Test failed": "Test failed", + "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.": "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.", + "API key test could not start. Refresh and try again.": "API key test could not start. Refresh and try again." } diff --git a/apps/admin/src/locales/es.json b/apps/admin/src/locales/es.json index 82ae3278..97734940 100644 --- a/apps/admin/src/locales/es.json +++ b/apps/admin/src/locales/es.json @@ -1179,5 +1179,13 @@ "Save API keys": "Guardar claves API", "Try recovery": "Intentar recuperación", "Usage (last 24 hours)": "Uso (últimas 24 horas)", - "requests": "solicitudes" + "requests": "solicitudes", + "Rotate model API keys across accounts.": "Alterna las claves API de modelos entre cuentas.", + "Manage subscriptions and API key accounts in one place.": "Gestiona suscripciones y cuentas con claves API en un solo lugar.", + "{requests} requests · {tokens} tokens": "{requests} solicitudes · {tokens} tokens", + "Test API key": "Probar clave API", + "Test passed": "Prueba correcta", + "Test failed": "Prueba fallida", + "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.": "Envía una solicitud breve solo con esta clave. Consume cuota del proveedor; no cambia la rotación ni la pausa.", + "API key test could not start. Refresh and try again.": "No se pudo iniciar la prueba. Actualiza e inténtalo de nuevo." } diff --git a/apps/admin/src/locales/ja.json b/apps/admin/src/locales/ja.json index 2ac1cfc5..9b2e48df 100644 --- a/apps/admin/src/locales/ja.json +++ b/apps/admin/src/locales/ja.json @@ -1179,5 +1179,13 @@ "Save API keys": "API キーを保存", "Try recovery": "復旧を試す", "Usage (last 24 hours)": "使用量(過去 24 時間)", - "requests": "リクエスト" + "requests": "リクエスト", + "Rotate model API keys across accounts.": "複数のアカウントでモデル用 API キーを順番に使用します。", + "Manage subscriptions and API key accounts in one place.": "サブスクリプションと API キーアカウントをまとめて管理します。", + "{requests} requests · {tokens} tokens": "{requests} リクエスト · {tokens} トークン", + "Test API key": "API キーをテスト", + "Test passed": "テスト成功", + "Test failed": "テスト失敗", + "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.": "このキーだけで短いリクエストを1回送信します。上流の利用枠を消費しますが、ローテーションや一時停止の設定は変更しません。", + "API key test could not start. Refresh and try again.": "テストを開始できませんでした。更新して再試行してください。" } diff --git a/apps/admin/src/locales/ko.json b/apps/admin/src/locales/ko.json index 990b3300..ecf53f4a 100644 --- a/apps/admin/src/locales/ko.json +++ b/apps/admin/src/locales/ko.json @@ -1179,5 +1179,13 @@ "Save API keys": "API 키 저장", "Try recovery": "복구 시도", "Usage (last 24 hours)": "사용량 (최근 24시간)", - "requests": "요청" + "requests": "요청", + "Rotate model API keys across accounts.": "여러 계정의 모델 API 키를 순환 사용합니다.", + "Manage subscriptions and API key accounts in one place.": "구독과 API 키 계정을 한곳에서 관리합니다.", + "{requests} requests · {tokens} tokens": "요청 {requests}회 · 토큰 {tokens}개", + "Test API key": "API 키 테스트", + "Test passed": "테스트 성공", + "Test failed": "테스트 실패", + "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.": "이 키만 사용해 짧은 요청을 한 번 보냅니다. 공급자 사용량이 차감되며 순환 및 일시 중지 설정은 변경되지 않습니다.", + "API key test could not start. Refresh and try again.": "테스트를 시작할 수 없습니다. 새로고침 후 다시 시도하세요." } diff --git a/apps/admin/src/locales/pt.json b/apps/admin/src/locales/pt.json index d185438a..7dee945d 100644 --- a/apps/admin/src/locales/pt.json +++ b/apps/admin/src/locales/pt.json @@ -1179,5 +1179,13 @@ "Save API keys": "Salvar chaves API", "Try recovery": "Tentar recuperação", "Usage (last 24 hours)": "Uso (últimas 24 horas)", - "requests": "solicitações" + "requests": "solicitações", + "Rotate model API keys across accounts.": "Alterna as chaves API de modelos entre contas.", + "Manage subscriptions and API key accounts in one place.": "Gerencie assinaturas e contas com chaves API em um só lugar.", + "{requests} requests · {tokens} tokens": "{requests} solicitações · {tokens} tokens", + "Test API key": "Testar chave API", + "Test passed": "Teste aprovado", + "Test failed": "Teste falhou", + "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.": "Envia uma solicitação curta apenas com esta chave. Consome a cota do provedor; não altera a rotação nem a pausa.", + "API key test could not start. Refresh and try again.": "Não foi possível iniciar o teste. Atualize e tente novamente." } diff --git a/apps/admin/src/locales/zh-hans.json b/apps/admin/src/locales/zh-hans.json index cb76ee43..248bfcf4 100644 --- a/apps/admin/src/locales/zh-hans.json +++ b/apps/admin/src/locales/zh-hans.json @@ -1167,17 +1167,25 @@ "Monthly retry timezone": "每月重试时区", "Next recovery attempt": "下次恢复尝试", "No managed API keys. Existing environment credentials remain in use only until the pool is first configured.": "尚无托管密钥。首次配置密钥池之前,继续使用已有环境变量凭证。", - "Pause": "Pause", + "Pause": "暂停", "Paused manually": "已手动暂停", "Quota exhausted": "额度不足", "Recovery request in progress": "正在验证恢复", "Refresh API keys": "刷新密钥状态", "Replacement API key (optional)": "替换密钥(可选)", - "Resume": "Resume", + "Resume": "恢复", "Rotate model API keys. Exhausted accounts retry next month; a successful request confirms recovery.": "模型请求在多个密钥间轮换。额度不足的账号下月重试,实际请求成功后才恢复使用。", "Round robin": "均匀轮换", "Save API keys": "保存 API 密钥", "Try recovery": "尝试恢复", "Usage (last 24 hours)": "最近 24 小时用量", - "requests": "requests" + "requests": "次请求", + "Rotate model API keys across accounts.": "在多个账户间轮换模型 API 密钥。", + "Manage subscriptions and API key accounts in one place.": "统一管理订阅和 API 密钥账户。", + "{requests} requests · {tokens} tokens": "{requests} 次请求 · {tokens} Token", + "Test API key": "测试 API 密钥", + "Test passed": "测试通过", + "Test failed": "测试失败", + "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.": "仅使用当前密钥发送一次简短请求,会消耗上游额度,不会改变轮换或暂停设置。", + "API key test could not start. Refresh and try again.": "无法启动密钥测试,请刷新后重试。" } diff --git a/apps/admin/src/locales/zh-hant.json b/apps/admin/src/locales/zh-hant.json index def59249..fcf0514a 100644 --- a/apps/admin/src/locales/zh-hant.json +++ b/apps/admin/src/locales/zh-hant.json @@ -1179,5 +1179,13 @@ "Save API keys": "儲存 API 金鑰", "Try recovery": "嘗試恢復", "Usage (last 24 hours)": "用量(最近 24 小時)", - "requests": "次請求" + "requests": "次請求", + "Rotate model API keys across accounts.": "在多個帳戶間輪換模型 API 金鑰。", + "Manage subscriptions and API key accounts in one place.": "統一管理訂閱和 API 金鑰帳戶。", + "{requests} requests · {tokens} tokens": "{requests} 次請求 · {tokens} Token", + "Test API key": "測試 API 金鑰", + "Test passed": "測試通過", + "Test failed": "測試失敗", + "Sends one short request using only this key. Uses upstream quota; does not change rotation or pause settings.": "僅使用目前金鑰傳送一次簡短請求,會消耗上游額度,不會改變輪替或暫停設定。", + "API key test could not start. Refresh and try again.": "無法啟動金鑰測試,請重新整理後再試。" } diff --git a/apps/admin/src/routes/providers/+page.svelte b/apps/admin/src/routes/providers/+page.svelte index 10c442d7..115d34dd 100644 --- a/apps/admin/src/routes/providers/+page.svelte +++ b/apps/admin/src/routes/providers/+page.svelte @@ -915,13 +915,8 @@
-

{$t('Subscription Providers')}

-

- {$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('Providers')}

+

{$t('Manage subscriptions and API key accounts in one place.')}

@@ -941,6 +936,8 @@ +

{$t('Subscription Providers')}

+ {#if refreshStatusLabel(overview.refresh)}

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((resolve) => upstream.close(() => resolve())); diff --git a/apps/gateway/src/routes/admin/api-key-pools.test.ts b/apps/gateway/src/routes/admin/api-key-pools.test.ts index 9fad37b5..bb31261c 100644 --- a/apps/gateway/src/routes/admin/api-key-pools.test.ts +++ b/apps/gateway/src/routes/admin/api-key-pools.test.ts @@ -1,7 +1,7 @@ import type { ConfigStore } from "@helm/core"; -import { ApiKeyAccounts } from "@helm/core"; +import { ApiKeyAccounts, UpstreamError } from "@helm/core"; import { Hono } from "hono"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AppEnv } from "../../app.js"; import { registerApiKeyPoolRoutes } from "./api-key-pools.js"; import type { AdminApiDeps } from "./deps.js"; @@ -25,13 +25,29 @@ function setup() { poolId: "openai", timezone: "Asia/Shanghai", }); + const chatCompletion = vi.fn(async () => ({ + choices: [{ message: { content: "OK" }, finish_reason: "stop" }], + usage: { total_tokens: 4 }, + })); + const createTestClient = vi.fn(() => ({ chatCompletion })); + const recordUsage = vi.fn(async () => {}); const app = new Hono(); registerApiKeyPoolRoutes(app, { + oauthUsage: { record: recordUsage, queryRange: async () => [] }, apiKeyPools: new Map([ - ["openai", { accounts, models: ["m"], timezone: "Asia/Shanghai", strategy: "round_robin" }], + [ + "openai", + { + accounts, + models: ["m"], + timezone: "Asia/Shanghai", + strategy: "round_robin", + createTestClient, + }, + ], ]), } as unknown as AdminApiDeps); - return { app, accounts, rows }; + return { app, accounts, rows, chatCompletion, createTestClient, recordUsage }; } const body = (value: unknown) => ({ method: "POST", @@ -93,3 +109,96 @@ describe("admin API key pools", () => { ); }); }); + +describe("API key account connectivity test", () => { + it("tests only the selected key, records usage and preserves a manual pause", async () => { + const { app, accounts, createTestClient, recordUsage } = setup(); + await accounts.add([ + { name: "one", key: "sk-proj-first-test" }, + { name: "two", key: "sk-proj-second-test" }, + ]); + const id = (await accounts.list())[1]!.id; + await accounts.update(id, { enabled: false }); + const res = await app.request( + `/admin/api/api-key-pools/openai/accounts/${id}/test`, + body({ model: "m" }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ ok: true, model: "m", response: "OK" }); + expect(createTestClient).toHaveBeenCalledExactlyOnceWith("sk-proj-second-test", "m"); + expect(recordUsage).toHaveBeenCalledWith( + expect.objectContaining({ + providerId: "openai", + account: expect.stringMatching(new RegExp(`^${id}@`)), + tokens: 4, + }), + ); + expect((await accounts.list())[1]!.enabled).toBe(false); + }); + it("rejects unknown accounts, invalid payloads and disallowed models before sending", async () => { + const { app, accounts, createTestClient } = setup(); + await accounts.add([{ name: "one", key: "sk-proj-first-test" }]); + const id = (await accounts.list())[0]!.id; + for (const [account, payload, status] of [ + ["missing", { model: "m" }, 404], + [id, {}, 400], + [id, { model: "unconfigured" }, 400], + [id, { model: "m", key: "secret" }, 400], + ] as const) { + expect( + ( + await app.request( + `/admin/api/api-key-pools/openai/accounts/${account}/test`, + body(payload), + ) + ).status, + ).toBe(status); + } + await accounts.update(id, { models: [] }); + expect( + ( + await app.request( + `/admin/api/api-key-pools/openai/accounts/${id}/test`, + body({ model: "m" }), + ) + ).status, + ).toBe(400); + expect(createTestClient).not.toHaveBeenCalled(); + }); + it("returns a safe upstream failure without trying another key or changing routing state", async () => { + const { app, accounts, chatCompletion, createTestClient } = setup(); + await accounts.add([ + { name: "one", key: "sk-proj-first-test" }, + { name: "two", key: "sk-proj-second-test" }, + ]); + const id = (await accounts.list())[0]!.id; + chatCompletion.mockRejectedValue( + new UpstreamError( + "upstream_error", + "secret sk-proj-first-test", + { error: { code: "insufficient_quota", message: "secret" } }, + 429, + ), + ); + const res = await app.request( + `/admin/api/api-key-pools/openai/accounts/${id}/test`, + body({ model: "m" }), + ); + const result = await res.json(); + expect(result).toMatchObject({ ok: false, error: "insufficient_quota", upstreamStatus: 429 }); + expect(JSON.stringify(result)).not.toMatch(/secret|sk-proj/); + expect(createTestClient).toHaveBeenCalledTimes(1); + expect((await accounts.list())[0]!.state).toBe("ready"); + }); + it("does not report an empty upstream response as success", async () => { + const { app, accounts, chatCompletion } = setup(); + await accounts.add([{ name: "one", key: "sk-proj-first-test" }]); + const id = (await accounts.list())[0]!.id; + chatCompletion.mockResolvedValue({ choices: [], usage: { total_tokens: 4 } }); + const res = await app.request( + `/admin/api/api-key-pools/openai/accounts/${id}/test`, + body({ model: "m" }), + ); + expect(await res.json()).toMatchObject({ ok: false, error: "empty_response" }); + }); +}); diff --git a/apps/gateway/src/routes/admin/api-key-pools.ts b/apps/gateway/src/routes/admin/api-key-pools.ts index ffcf49f4..58a44c51 100644 --- a/apps/gateway/src/routes/admin/api-key-pools.ts +++ b/apps/gateway/src/routes/admin/api-key-pools.ts @@ -1,3 +1,4 @@ +import { UpstreamError } from "@helm/core"; import { ApiKeyAccountInputSchema, ApiKeyAccountPatchSchema } from "@helm/shared"; import type { Context, Hono } from "hono"; import { z } from "zod"; @@ -5,6 +6,7 @@ import type { AppEnv } from "../../app.js"; import type { AdminApiDeps } from "./deps.js"; export function registerApiKeyPoolRoutes(app: Hono, deps: AdminApiDeps): void { + const runningTests = new Set(); app.get("/admin/api/api-key-pools", async (c) => { const usage = (await deps.oauthUsage?.queryRange(Date.now() - 86_400_000, Date.now())) ?? []; const pools = await Promise.all( @@ -100,6 +102,113 @@ export function registerApiKeyPoolRoutes(app: Hono, deps: AdminApiDeps): deps.apiKeyPools!.get(c.req.param("pool"))!.accounts!.remove(c.req.param("id")), ); }); + // Diagnostics deliberately bypass pool selection and do not alter account lifecycle. + app.post("/admin/api/api-key-pools/:pool/accounts/:id/test", async (c) => { + c.header("cache-control", "no-store"); + const parsed = z + .object({ model: z.string().min(1).max(200) }) + .strict() + .safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: "Invalid test model" }, 400); + const poolId = c.req.param("pool"); + const id = c.req.param("id"); + const pool = deps.apiKeyPools!.get(poolId)!; + const model = parsed.data.model; + await pool.accounts!.refresh(); + const account = pool.accounts!.entries().find((entry) => entry.id === id); + if (!account) return c.json({ error: "API key account not found" }, 404); + if ( + !pool.models.includes(model) || + (account.models !== null && !account.models.includes(model)) || + /image|realtime|audio|live|tts|whisper|transcri/i.test(model) + ) + return c.json({ error: "Select a configured text model allowed for this account" }, 400); + const lock = `${poolId}/${id}`; + if (runningTests.has(lock)) return c.json({ error: "Account test already running" }, 409); + const client = pool.createTestClient?.(account.key, model); + if (!client) return c.json({ error: "Account tester unavailable" }, 503); + runningTests.add(lock); + const started = Date.now(); + const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(30_000)]); + try { + const raw = await client.chatCompletion( + { + model, + messages: [{ role: "user", content: "Reply with exactly OK." }], + stream: false, + max_completion_tokens: 256, + ...(model === "gpt-6-luna" ? { reasoning_effort: "minimal" } : {}), + }, + { signal }, + ); + const completion = z + .object({ + choices: z.array(z.object({ message: z.object({ content: z.string().nullable() }) })), + usage: z.object({ total_tokens: z.number().nonnegative() }).optional(), + }) + .safeParse(raw); + const response = completion.success + ? completion.data.choices[0]?.message.content?.trim() + : null; + const nowMs = Date.now(); + // A completed request consumes quota even if its output is empty. + await deps.oauthUsage + ?.record({ + providerId: poolId, + account: `${account.id}@${account.generation}`, + bucketMs: nowMs - (nowMs % 3_600_000), + tokens: completion.success ? (completion.data.usage?.total_tokens ?? 0) : 0, + costUsd: null, + nowMs, + }) + .catch(() => {}); + if (!response) + return c.json({ + ok: false, + model, + durationMs: Date.now() - started, + error: "empty_response", + }); + // Never relay an echoed credential, even from a misconfigured upstream. + return c.json({ + ok: true, + model, + durationMs: Date.now() - started, + response: response.replaceAll(account.key, "[redacted]").slice(0, 2000), + }); + } catch (error) { + const upstream = error instanceof UpstreamError ? error : null; + const detail = z + .object({ error: z.object({ code: z.string().optional(), type: z.string().optional() }) }) + .safeParse(upstream?.providerRaw); + const code = detail.success ? (detail.data.error.code ?? detail.data.error.type) : undefined; + const known = new Set([ + "insufficient_quota", + "credit_balance_exhausted", + "organization_spend_limit_exceeded", + "project_spend_limit_exceeded", + "invalid_api_key", + "account_deactivated", + "model_not_found", + "model_not_allowed", + "rate_limit_exceeded", + ]); + return c.json({ + ok: false, + model, + durationMs: Date.now() - started, + upstreamStatus: upstream?.upstreamStatus ?? null, + error: + signal.aborted || upstream?.errorClass === "timeout" + ? "timeout" + : code && known.has(code) + ? code + : "upstream_error", + }); + } finally { + runningTests.delete(lock); + } + }); app.post("/admin/api/api-key-pools/:pool/accounts/:id/retry", async (c) => { return apply(c, () => deps.apiKeyPools!.get(c.req.param("pool"))!.accounts!.retry(c.req.param("id")), diff --git a/apps/gateway/src/routes/admin/deps.ts b/apps/gateway/src/routes/admin/deps.ts index 610c4251..d55edeb2 100644 --- a/apps/gateway/src/routes/admin/deps.ts +++ b/apps/gateway/src/routes/admin/deps.ts @@ -10,6 +10,7 @@ import type { OAuthSelectionStrategy, OAuthUsageStore, PoliciesConfig, + ProviderClient, ResponseWorkAdmission, RouteOptions, StoredCleanupReport, @@ -475,6 +476,7 @@ export interface AdminApiDeps { timezone: string; strategy: "round_robin" | "manual_priority"; models: string[]; + createTestClient?: (key: string, model: string) => ProviderClient | null; } >; rules: RuleStore; diff --git a/apps/gateway/src/server.ts b/apps/gateway/src/server.ts index 0df15b78..3055835d 100644 --- a/apps/gateway/src/server.ts +++ b/apps/gateway/src/server.ts @@ -3252,6 +3252,7 @@ export async function buildServer( timezone: string; strategy: "round_robin" | "manual_priority"; models: string[]; + createTestClient?: (key: string, model: string) => ProviderClient | null; } >(); for (const p of config.providers) { @@ -3281,6 +3282,24 @@ export async function buildServer( timezone: poolConfig.reset_timezone, strategy: poolConfig.strategy, models: [], + createTestClient: (apiKey, model) => { + const provider = config.providers.find( + (candidate) => + candidate.api_key_pool?.id === poolConfig.id && + candidate.models.some((entry) => entry.provider_model === model), + ); + return provider + ? createProviderClient( + provider, + { + baseUrl: baseUrlOverride || provider.base_url || fallbackBaseUrl, + timeoutMs: 30_000, + }, + { apiKey }, + resolveProviderProxy(provider, accountSettings), + ) + : null; + }, }; apiKeyPools.set(poolConfig.id, pool); } diff --git a/docs/assets/screenshots/provider-key-pool-desktop.png b/docs/assets/screenshots/provider-key-pool-desktop.png new file mode 100644 index 00000000..2eb96d96 Binary files /dev/null and b/docs/assets/screenshots/provider-key-pool-desktop.png differ diff --git a/docs/assets/screenshots/provider-key-pool-mobile.png b/docs/assets/screenshots/provider-key-pool-mobile.png new file mode 100644 index 00000000..fa91abc2 Binary files /dev/null and b/docs/assets/screenshots/provider-key-pool-mobile.png differ diff --git a/implementation-notes.md b/implementation-notes.md index 9892a203..2ac22533 100644 --- a/implementation-notes.md +++ b/implementation-notes.md @@ -13,7 +13,9 @@ - **存储决定**:复用 `config_kv` 单行与 AES-GCM,加上 SQLite/Postgres 原子比较更新;凭证及状态整包加密,不新增表或迁移。配置未初始化时兼容环境变量 key;一旦管理账号,删除最后一个账号也不回退到旧 key。 - **恢复规则**:按配置时区(默认北京时间)每月 1 日允许一次真实业务请求验证。跨实例通过持久化租约避免重复验证;验证失败至少等待 24 小时。月初不是余额已恢复的证明,不发付费定时探测、不购买或消耗订阅重置额度。 - **身份边界**:管理 ID 稳定,路由 ID 包含凭证代次;换 key 后旧续接拒绝发送,旧请求不能覆盖新凭证状态。手动暂停和失效凭证不在月初自动恢复。 -- **发布准备**:v0.30.60 已执行 `pnpm release:prepare 0.30.60`,官方稳定快照仍为 Grok 1.0.70、Claude Code 2.1.312、Codex 0.165.0 及同 tag 模型目录,无快照差异;补齐其余五种管理界面语言,修复完整 CI 的词条一致性失败。 +- **界面修正(2026-10-30)**:按 Opus 的布局建议复用现有响应式账号表、状态标签与操作菜单,移除重复卡片和未定义颜色变量;页面标题覆盖订阅与 API key。保留账号池在上方便于管理,使用既有金额精度格式,避免小额费用显示为零。桌面与窄屏验收使用合成账号,不传输生产截图或凭证给设计模型。 +- **逐 Key 测试(2026-10-30)**:管理界面可选择已配置且符合账号允许列表的文本模型,以当前 Key 发起最长 30 秒、最多 256 输出 token 的固定短请求。直连对应 provider,不参与轮换或失败转移;暂停、额度及失效状态不因诊断而改变。记录实际用量,错误仅返回安全代码,取消弹窗中断请求;测试会消耗上游额度,不能证明剩余余额。 +- **发布准备**:v0.30.61 已执行 `pnpm release:prepare 0.30.61`,官方稳定快照仍为 Grok 1.0.70、Claude Code 2.1.312、Codex 0.165.0 及同 tag 模型目录,无快照差异;补齐其余五种管理界面语言,修复完整 CI 的词条一致性失败。 - **限制**:轮询次数在单个 gateway 的协议池内均衡,不保证多副本全局严格均匀;用量仅覆盖 Helm 观察到的流量,金额为估算。策略可在后台覆盖 YAML 默认值,时区仍由配置控制。 diff --git a/package.json b/package.json index a5242c72..b442954d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "helm-api", - "version": "0.30.60", + "version": "0.30.61", "private": true, "type": "module", "engines": { diff --git a/packages/shared/src/config/api-key-pool.ts b/packages/shared/src/config/api-key-pool.ts index 9f10a91f..c0efd009 100644 --- a/packages/shared/src/config/api-key-pool.ts +++ b/packages/shared/src/config/api-key-pool.ts @@ -79,3 +79,13 @@ export const ApiKeyPoolsViewSchema = z.object({ ), }); export type ApiKeyPoolView = z.infer["pools"][number]; + +export const ApiKeyTestResultSchema = z.object({ + ok: z.boolean(), + model: z.string(), + durationMs: z.number().nonnegative(), + response: z.string().optional(), + error: z.string().optional(), + upstreamStatus: z.number().nullable().optional(), +}); +export type ApiKeyTestResult = z.infer;