Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
442 changes: 326 additions & 116 deletions apps/admin/src/lib/components/ApiKeyPools.svelte

Large diffs are not rendered by default.

115 changes: 111 additions & 4 deletions apps/admin/src/lib/components/ApiKeyPools.test.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Expand Down Expand Up @@ -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' }) }),
);
},
);
10 changes: 9 additions & 1 deletion apps/admin/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 9 additions & 1 deletion apps/admin/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 9 additions & 1 deletion apps/admin/src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.": "テストを開始できませんでした。更新して再試行してください。"
}
10 changes: 9 additions & 1 deletion apps/admin/src/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.": "테스트를 시작할 수 없습니다. 새로고침 후 다시 시도하세요."
}
10 changes: 9 additions & 1 deletion apps/admin/src/locales/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
14 changes: 11 additions & 3 deletions apps/admin/src/locales/zh-hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.": "无法启动密钥测试,请刷新后重试。"
}
10 changes: 9 additions & 1 deletion apps/admin/src/locales/zh-hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.": "無法啟動金鑰測試,請重新整理後再試。"
}
11 changes: 4 additions & 7 deletions apps/admin/src/routes/providers/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -915,13 +915,8 @@
<section class="page">
<header class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="page-title">{$t('Subscription Providers')}</h1>
<p class="section-desc">
{$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.')}
</p>
<h1 class="page-title">{$t('Providers')}</h1>
<p class="section-desc">{$t('Manage subscriptions and API key accounts in one place.')}</p>
</div>
<div class="flex w-full shrink-0 gap-2 sm:w-auto">
<div class="flex-1 sm:flex-none">
Expand All @@ -941,6 +936,8 @@

<ApiKeyPools />

<h2 class="section-header mt-3">{$t('Subscription Providers')}</h2>

{#if refreshStatusLabel(overview.refresh)}
<p
data-testid="provider-refresh-status"
Expand Down
Loading