Skip to content
Open
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
24 changes: 20 additions & 4 deletions gui/src/components/provider-workspace/ProviderAuthPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -720,25 +720,41 @@ export default function ProviderAuthPanel({
{keys.length > 0 && (
<ul className="pwi-auth-list">
{keys.map(entry => (
<li key={entry.id} className={`pwi-auth-acct${entry.active ? " pwi-auth-acct--active" : ""}`}>
<div className={`pwi-auth-row${entry.active ? " pwi-auth-row--active" : ""}`}>
<li key={entry.id} className={`pwi-auth-acct${entry.active && !entry.disabled ? " pwi-auth-acct--active" : ""}`}>
<div className={`pwi-auth-row${entry.active && !entry.disabled ? " pwi-auth-row--active" : ""}`}>
<button type="button" className="pwi-auth-row-main"
onClick={() => void authHandlers.onSwitchApiKey(item.name, entry)}
disabled={entry.active && !entry.disabled} aria-label={entry.disabled ? `${t("codexAuth.resume")} — ${entry.label ?? entry.masked}` : undefined}>
<span className={`pwi-auth-dot ${entry.active ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" />
aria-current={entry.active && !entry.disabled ? "true" : undefined}
disabled={(entry.active && !entry.disabled) || busy || Boolean(switchingAccountId) || Boolean(pausingAccountId)} aria-label={entry.disabled ? `${t("pws.resumeAndActivateKey")} — ${entry.label ?? entry.masked}` : undefined}>
<span className={`pwi-auth-dot ${entry.active && !entry.disabled ? "pwi-auth-dot--ok" : "pwi-auth-dot--off"}`} aria-hidden="true" />
<span className="pwi-auth-row-copy">
<span className="pwi-auth-row-label">{entry.label ?? entry.masked}</span>
{entry.label && <code className="pwi-auth-row-secondary">{entry.masked} · {t("prov.accountId")}: {entry.id}</code>}
{entry.disabled && (
<span className="pwi-auth-row-secondary faint">{t("pws.accountPausedHint")}</span>
)}
</span>
{entry.disabled && <span className="badge badge-muted" title={entry.pauseDetails ? formatAccountPauseTooltip(t, locale, entry.pauseDetails) : undefined}>{t("codexAuth.paused")} · {t("codexAuth.resume")}</span>}
{entry.active && !entry.disabled && <span className="badge badge-primary">{t("prov.accountActive")}</span>}
</button>
{authHandlers.onPauseApiKey && (
<button type="button" className="btn btn-ghost btn-sm"
aria-label={`${t(entry.disabled ? "codexAuth.resume" : "codexAuth.pause")} — ${entry.label ?? entry.masked}`}
title={entry.disabled ? t("pws.accountPausedHint") : undefined}
aria-busy={pausingAccountId === entry.id}
disabled={busy || Boolean(switchingAccountId) || Boolean(pausingAccountId)}
onClick={() => void authHandlers.onPauseApiKey!(item.name, entry, !entry.disabled)}>
{t(entry.disabled ? "codexAuth.resume" : "codexAuth.pause")}
</button>
)}
<button type="button" className="btn btn-ghost btn-sm"
disabled={busy || Boolean(switchingAccountId) || Boolean(pausingAccountId)}
onClick={() => void authHandlers.onEditAlias(item.name, "api-key", entry.id, entry.label)}>
{t("prov.editAlias")}
</button>
<button type="button" className="btn btn-ghost btn-sm pwi-auth-row-remove"
aria-label={`${t("common.remove")} — ${entry.label ?? entry.masked}`}
disabled={busy || Boolean(switchingAccountId) || Boolean(pausingAccountId)}
title={`${t("common.remove")} — ${entry.label ?? entry.masked}`}
onClick={() => void authHandlers.onRemoveApiKey(item.name, entry)}>
<IconTrash style={{ width: 13, height: 13 }} aria-hidden="true" />
Expand Down
1 change: 1 addition & 0 deletions gui/src/components/provider-workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export interface ProviderAuthHandlers {
onRetryAccounts?: (provider: string) => void | Promise<void>;
onAddApiKey: (provider: string, key: string) => Promise<boolean>;
onSwitchApiKey: (provider: string, entry: ApiKeyRow) => void | Promise<void>;
onPauseApiKey?: (provider: string, entry: ApiKeyRow, paused: boolean) => void | Promise<void>;
onRemoveApiKey: (provider: string, entry: ApiKeyRow) => void | Promise<void>;
onEditAlias: (provider: string, type: "oauth" | "api-key", id: string, current?: string) => void | Promise<void>;
/**
Expand Down
83 changes: 81 additions & 2 deletions gui/src/hooks/useProviderAccountPools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,10 @@ export function useProviderAccountPools(deps: {
};

const switchApiKey = async (provider: string, entry: ApiKeyEntry) => {
if (entry.active && !entry.disabled || selectionMutationsRef.current.has(`key:${provider}`)) return;
if (entry.active && !entry.disabled || switchingAccountRef.current.has(provider) || pausingAccountRef.current.has(provider) || selectionMutationsRef.current.has(`key:${provider}`)) return;
const target = { provider, accountId: entry.id };
switchingAccountRef.current.set(provider, target);
setSwitchingAccounts(current => ({ ...current, [provider]: target }));
const key = invalidateSelectionReads(provider, "api-key");
const mutation = Symbol();
selectionMutationsRef.current.set(key, mutation);
Expand Down Expand Up @@ -580,6 +583,10 @@ export function useProviderAccountPools(deps: {
} catch {
if (currentMutation()) notify(t(resumed ? "prov.keyResumedSwitchFailed" : "prov.keySwitchFail"), false);
} finally {
if (switchingAccountRef.current.get(provider) === target) {
switchingAccountRef.current.delete(provider);
if (aliveRef.current) setSwitchingAccounts(current => { const next = { ...current }; delete next[provider]; return next; });
}
if (currentMutation()) {
invalidateSelectionReads(provider, "api-key");
selectionMutationsRef.current.delete(key);
Expand All @@ -588,6 +595,78 @@ export function useProviderAccountPools(deps: {
}
};

const pauseApiKey = async (provider: string, entry: ApiKeyEntry, paused: boolean) => {
if (switchingAccountRef.current.has(provider) || pausingAccountRef.current.has(provider) || selectionMutationsRef.current.has(`key:${provider}`)) return;
const target = { provider, accountId: entry.id };
pausingAccountRef.current.set(provider, target);
setPausingAccounts(current => ({ ...current, [provider]: { ...target, paused } }));
const key = invalidateSelectionReads(provider, "api-key");
const mutation = Symbol();
selectionMutationsRef.current.set(key, mutation);
const currentMutation = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase
&& selectionMutationsRef.current.get(key) === mutation;
const label = entry.label ?? entry.masked;
let result: { activeId?: string | null; activeAccountId?: string | null; activeAccountChanged?: boolean };
try {
const res = await fetch(`${apiBase}/api/providers/keys/pause`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: provider, id: entry.id, paused }),
});
if (!currentMutation()) return;
if (!res.ok) {
notify(t(paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { email: label }), false);
return;
}
result = await res.json().catch(() => ({})) as { activeId?: string | null; activeAccountId?: string | null; activeAccountChanged?: boolean };
if (!currentMutation()) return;
invalidateSelectionReads(provider, "api-key");
setKeyPools(current => {
const existing = current[provider];
if (!existing) return current;
const rows = existing.map(row => row.id === entry.id
? { ...row, disabled: paused, ...(paused ? {} : { pauseDetails: undefined }) }
: row);
const idx = rows.findIndex(row => row.id === entry.id);
const ordered = idx >= 0 ? [...rows.slice(idx + 1), ...rows.slice(0, idx)] : rows;
const fallbackSelected = paused && entry.active
? ordered.find(row => !row.disabled)?.id ?? null
: !paused && !rows.some(row => row.active && !row.disabled)
? entry.id
: rows.find(row => row.active)?.id ?? null;
const selected = result.activeId !== undefined
? result.activeId
: result.activeAccountId !== undefined
? result.activeAccountId
: fallbackSelected;
return { ...current, [provider]: selectionRows(rows, selected) };
});
selectionMutationsRef.current.delete(key);
} catch {
if (currentMutation()) notify(t(paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { email: label }), false);
return;
} finally {
if (currentMutation()) {
invalidateSelectionReads(provider, "api-key");
selectionMutationsRef.current.delete(key);
void refreshAccountRosters({ provider, kind: "api-key" });
}
if (pausingAccountRef.current.get(provider) === target) {
pausingAccountRef.current.delete(provider);
if (aliveRef.current) setPausingAccounts(current => { const next = { ...current }; delete next[provider]; return next; });
}
}
if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase) return;
notify(t(paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded", { email: label }), true);
try {
const refreshed = await refreshAccountRosters({ provider, kind: "api-key" });
if (result.activeAccountChanged ?? true) await Promise.all([fetchConfig(), fetchProviderQuotas(true)]);
if (!refreshed) notify(t("pws.accountsLoadFailed"), false);
} catch {
if (aliveRef.current && mountedRef.current) notify(t("pws.accountsLoadFailed"), false);
}
};

const removeApiKey = async (provider: string, entry: ApiKeyEntry) => {
const consented = await confirmAction({
message: t("prov.keyRemoveConfirm", { key: entry.label ?? entry.masked }),
Expand Down Expand Up @@ -712,7 +791,7 @@ export function useProviderAccountPools(deps: {
return {
accountSets, accountLoadStates, switchingAccount, pausingAccount, switchingAccounts, pausingAccounts, openAccounts, keyPools, addingKeyFor, newKeyValue,
setAccountSets, setAccountLoadStates, setSwitchingAccount, setOpenAccounts, setKeyPools, setAddingKeyFor, setNewKeyValue,
fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount,
fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, pauseApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount,
oauthCardProviders, keyCardProviders, activeAccountNeedsReauth,
};
}
1 change: 1 addition & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2977,6 +2977,7 @@ export const de: Record<TKey, string> = {
"pws.accountSwitching": "Wechsel läuft…",
"pws.accountCurrent": "Aktuelles Konto",
"pws.accountPausedHint": "Bis du das Konto fortsetzt, ist es von der automatischen Auswahl, von Wiederholungen, von der Cooldown-Wiederherstellung, von der manuellen Auswahl und von der proaktiven Token-Erneuerung ausgeschlossen.",
"pws.resumeAndActivateKey": "Fortsetzen und verwenden",
"pws.defaultModelNone": "Keins (Standard des Anbieters verwenden)",
"pws.discardSettings": "Verwerfen",
"pws.jsonEditorDesc": "Bearbeiten Sie die JSON-Konfiguration des Anbieters. Änderungen werden sofort gespeichert.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1853,6 +1853,7 @@ export const en = {
"pws.accountSwitching": "Switching…",
"pws.accountCurrent": "Current account",
"pws.accountPausedHint": "Excluded from automatic selection, retries, cooldown recovery, manual selection, and proactive token refresh until resumed.",
"pws.resumeAndActivateKey": "Resume and use",
"pws.defaultModelNone": "None (use provider default)",
"pws.discardSettings": "Discard",
"pws.jsonEditorDesc": "Edit the raw provider JSON config. Changes are saved immediately.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,6 +1826,7 @@ export const fr: Record<TKey, string> = {
"pws.accountSwitching": "Changement…",
"pws.accountCurrent": "Compte actuel",
"pws.accountPausedHint": "Exclu du choix automatique, des nouvelles tentatives, de la récupération après délai, de la sélection manuelle et du renouvellement proactif du jeton jusqu’à sa réactivation.",
"pws.resumeAndActivateKey": "Reprendre et utiliser",
"pws.defaultModelNone": "Aucun (utiliser la valeur par défaut du fournisseur)",
"pws.discardSettings": "Abandonner les modifications",
"pws.jsonEditorDesc": "Modifiez la configuration JSON brute du fournisseur. Les modifications sont enregistrées immédiatement.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1721,6 +1721,7 @@ export const ja: Record<TKey, string> = {
"pws.accountSwitching": "切り替え中…",
"pws.accountCurrent": "現在のアカウント",
"pws.accountPausedHint": "再開するまで、自動選択、再試行、クールダウン復旧、手動選択、トークンの事前更新の対象外です。",
"pws.resumeAndActivateKey": "再開して使用",
"pws.defaultModelNone": "なし(プロバイダーのデフォルトを使用)",
"pws.discardSettings": "破棄",
"pws.jsonEditorDesc": "生のプロバイダー JSON 設定を編集します。変更はすぐに保存されます。",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3024,6 +3024,7 @@ export const ko: Record<TKey, string> = {
"pws.accountSwitching": "전환 중…",
"pws.accountCurrent": "현재 계정",
"pws.accountPausedHint": "재개할 때까지 자동 선택, 재시도, 쿨다운 복구, 수동 선택 및 사전 토큰 갱신에서 제외됩니다.",
"pws.resumeAndActivateKey": "재개 및 사용",
"pws.defaultModelNone": "없음 (프로바이더 기본값 사용)",
"pws.discardSettings": "되돌리기",
"pws.jsonEditorDesc": "프로바이더 JSON 설정을 직접 편집합니다. 저장 즉시 반영됩니다.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,7 @@ export const pt: Record<TKey, string> = {
"pws.accountSwitching": "Alternando…",
"pws.accountCurrent": "Conta atual",
"pws.accountPausedHint": "Excluída da seleção automática, das novas tentativas, da recuperação de cooldown, da seleção manual e da renovação proativa de token até ser retomada.",
"pws.resumeAndActivateKey": "Retomar e usar",
"pws.defaultModelNone": "Nenhum (usar o padrão do provedor)",
"pws.discardSettings": "Descartar",
"pws.jsonEditorDesc": "Edite a configuração JSON bruta do provedor. As alterações são salvas imediatamente.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1812,6 +1812,7 @@ export const ru: Record<TKey, string> = {
"pws.accountSwitching": "Переключение…",
"pws.accountCurrent": "Текущий аккаунт",
"pws.accountPausedHint": "До возобновления исключён из автоматического выбора, повторов, восстановления после задержки, ручного выбора и проактивного обновления токена.",
"pws.resumeAndActivateKey": "Возобновить и использовать",
"pws.defaultModelNone": "Нет (использовать значение провайдера)",
"pws.discardSettings": "Не сохранять",
"pws.jsonEditorDesc": "Редактируйте исходную JSON-конфигурацию провайдера. Изменения сохраняются сразу.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1839,6 +1839,7 @@ export const tr: Record<TKey, string> = {
"pws.accountSwitching": "Değiştiriliyor…",
"pws.accountCurrent": "Mevcut hesap",
"pws.accountPausedHint": "Devam ettirilene kadar otomatik seçim, yeniden deneme, bekleme süresinden kurtarma, manuel seçim ve proaktif belirteç yenilemesinden hariç tutulur.",
"pws.resumeAndActivateKey": "Devam ettir ve kullan",
"pws.defaultModelNone": "Yok (sağlayıcı varsayılanını kullan)",
"pws.discardSettings": "Vazgeç",
"pws.jsonEditorDesc": "Ham sağlayıcı JSON konfigürasyonunu düzenleyin.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1823,6 +1823,7 @@ export const vi: Record<TKey, string> = {
"pws.accountSwitching": "Đang chuyển…",
"pws.accountCurrent": "Tài khoản hiện tại",
"pws.accountPausedHint": "Được loại trừ khỏi chọn tài khoản tự động, thử lại, hồi phục sau thời gian chờ, lựa chọn thủ công và làm mới token chủ động cho đến khi tiếp tục.",
"pws.resumeAndActivateKey": "Tiếp tục và sử dụng",
"pws.defaultModelNone": "Không có (sử dụng mặc định của provider)",
"pws.discardSettings": "Huỷ bỏ",
"pws.jsonEditorDesc": "Chỉnh sửa config dạng JSON thô của provider. Các thay đổi sẽ được lưu ngay lập tức.",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1516,6 +1516,7 @@ export const zhTW: Record<TKey, string> = {
"pws.accountSwitching": "切換中…",
"pws.accountCurrent": "當前帳號",
"pws.accountPausedHint": "恢復前不會參與自動選取、重試、冷卻恢復、手動選擇或主動 Token 刷新。",
"pws.resumeAndActivateKey": "恢復並使用",
"pws.defaultModelNone": "無(使用供應商預設值)",
"pws.discardSettings": "放棄",
"pws.jsonEditorDesc": "直接編輯供應商 JSON 配置。更改將立即儲存。",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2997,6 +2997,7 @@ export const zh: Record<TKey, string> = {
"pws.accountSwitching": "切换中…",
"pws.accountCurrent": "当前账户",
"pws.accountPausedHint": "恢复前不会参与自动切换、重试、冷却恢复、手动选择或主动令牌刷新。",
"pws.resumeAndActivateKey": "恢复并使用",
"pws.defaultModelNone": "无(使用提供商默认值)",
"pws.discardSettings": "放弃",
"pws.jsonEditorDesc": "直接编辑提供商 JSON 配置。更改将立即保存。",
Expand Down
3 changes: 2 additions & 1 deletion gui/src/pages/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
const {
accountSets, setAccountSets, accountLoadStates, switchingAccounts, pausingAccounts, keyPools, fetchAccountSets, fetchKeyPools,
refreshAccountRosters, oauthCardProviders, keyCardProviders,
switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias,
switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, pauseApiKey, removeApiKey, addApiKeyValue, editCredentialAlias,
removeAccount, activeAccountNeedsReauth,
} = pools;
const refreshSelection = useCallback((target?: AccountSelectionTarget) => {
Expand Down Expand Up @@ -593,6 +593,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
onRetryAccounts: async provider => { await fetchAccountSets([provider]); },
onAddApiKey: addApiKeyValue,
onSwitchApiKey: switchApiKey,
onPauseApiKey: pauseApiKey,
onRemoveApiKey: removeApiKey,
onEditAlias: editCredentialAlias,
onRefreshQuota: refreshProviderQuota,
Expand Down
Loading
Loading