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
81 changes: 79 additions & 2 deletions gui/src/components/provider-workspace/ProviderSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { readJsonIfOk } from "../../fetch-json";
import { useT } from "../../i18n/shared";
import { IconLock } from "../../icons";
import { isCatalogProviderId } from "../../provider-icons";
import { openAiAccountProviderState } from "../../provider-payload";
import type { CatalogPreset } from "../provider-catalog/provider-presets";
import { authModeLabel } from "./ProviderRail";
import type { WorkspaceItem, ProviderUpdatePatch } from "./types";
Expand Down Expand Up @@ -46,6 +47,9 @@ export default function ProviderSettings({
const [liveModels, setLiveModels] = useState(item.liveModels !== false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [accountMode, setAccountMode] = useState<"pool" | "direct">(item.codexAccountMode ?? "pool");
const [modeSaving, setModeSaving] = useState(false);
const [modeMsg, setModeMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [baseUrlChoices, setBaseUrlChoices] = useState<CatalogPreset["baseUrlChoices"]>();
const [choicesStatus, setChoicesStatus] = useState<ChoicesStatus>(apiBase ? "loading" : "idle");
const [endpointChoice, setEndpointChoice] = useState(() => "custom");
Expand All @@ -61,10 +65,19 @@ export default function ProviderSettings({
setAllowPrivateNetwork(item.allowPrivateNetwork ?? false);
setLiveModels(item.liveModels !== false);
setMsg(null);
setModeMsg(null);
queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)));
}, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]);
/* eslint-enable react-hooks/set-state-in-effect */

// Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress
// draft, so it is deliberately kept out of the form-reset effect above.
/* eslint-disable react-hooks/set-state-in-effect -- intentional split from the form reset */
useEffect(() => {
setAccountMode(item.codexAccountMode ?? "pool");
}, [item.codexAccountMode]);
/* eslint-enable react-hooks/set-state-in-effect */

useEffect(() => {
if (!apiBase) return;
let cancelled = false;
Expand Down Expand Up @@ -122,12 +135,15 @@ export default function ProviderSettings({
const isPreset = isCatalogProviderId(item.name);
const hasEndpointPicker = choicesStatus === "ready" && !!(baseUrlChoices && baseUrlChoices.length > 0);
const supportsApiKeyTransport = adapter.trim() === "anthropic" && authMode === "key";
const openAiState = item.name === "openai" ? openAiAccountProviderState(item) : "invalid";
const isCanonicalOpenAi = openAiState === "ready" || openAiState === "disabled";
// Lock plain baseUrl for presets while loading or when there is no picker.
// On fetch error, keep it editable so allowBaseUrlOverride providers are not trapped.
const plainBaseUrlLocked = isPreset && choicesStatus !== "error";

const save = async (): Promise<boolean> => {
if (!onUpdateProvider) { setMsg({ ok: false, text: t("pws.updatesUnavailable") }); return false; }
if (modeSaving) return false;
const nextBaseUrl = hasEndpointPicker
? resolvedBaseUrlForChoice(baseUrlChoices, endpointChoice, baseUrl)
: baseUrl.trim();
Expand Down Expand Up @@ -159,6 +175,26 @@ export default function ProviderSettings({
return () => onRegisterSave(null);
}, [onRegisterSave]);

const applyAccountMode = async (next: "pool" | "direct") => {
if (modeSaving || saving || next === accountMode) return;
if (!onUpdateProvider) { setModeMsg({ ok: false, text: t("pws.updatesUnavailable") }); return; }
setModeSaving(true);
setModeMsg(null);
try {
const res = await onUpdateProvider("openai", { codexAccountMode: next });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh quotas after changing account mode

A successful mode switch only goes through useProvidersCrud.updateProvider, which refreshes /api/config but never bumps fetchProviderQuotas(true). This matters because fetchChatGptForwardQuota reports the active pooled account in Pool mode but the main account in Direct mode, while ProviderWorkspaceShell re-fetches quotas only when its explicit quota revision changes. Consequently, after switching modes, the Overview, Usage, and dashboard quota bars continue showing the previous account's quota until another quota-invalidating action or a page remount; route this mutation through the existing forced quota refresh path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[GD] Fixed in f26edc8e: after a successful codexAccountMode PATCH, updateProvider now awaits fetchProviderQuotas(true) and codexPool.load(true) (the shared Codex account controller) before reporting success. Tests assert both calls and that non-mode patches skip them.

if (res.ok) {
setAccountMode(next);
setModeMsg({ ok: true, text: t("pws.accountModeSaved") });
} else {
setModeMsg({ ok: false, text: res.error || t("pws.accountModeFailed") });
}
} catch {
setModeMsg({ ok: false, text: t("pws.accountModeFailed") });
} finally {
setModeSaving(false);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const discard = () => {
setAdapter(item.adapter); setBaseUrl(item.baseUrl);
setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth);
Expand Down Expand Up @@ -243,6 +279,43 @@ export default function ProviderSettings({
</select>
)}
</label>
{isCanonicalOpenAi && (
<label className="pwi-settings-field">
<span className="pwi-settings-label">{t("codexAuth.accountModeTitle")}</span>
<select
className="input"
value={accountMode}
disabled={modeSaving || saving}
onChange={e => {
const next = e.target.value as "pool" | "direct";
if (next === accountMode) return;
// Flipping modes rebinds running threads and changes quota accounting,
// so the PATCH only fires after an explicit confirmation.
if (!window.confirm(t("pws.accountModeConfirm"))) {
// Keep the visible choice aligned with the applied mode.
e.target.value = accountMode;
return;
}
void applyAccountMode(next);
}}
>
<option value="pool">{t("codexAuth.accountModePool")}</option>
<option value="direct">{t("codexAuth.accountModeDirect")}</option>
</select>
<span className="pwi-settings-hint">
{accountMode === "direct" ? t("codexAuth.accountModeDirectDesc") : t("codexAuth.accountModePoolDesc")}
</span>
{modeSaving && <span className="muted text-label">{t("pws.accountSwitching")}</span>}
{modeMsg && (
<span
role={modeMsg.ok ? "status" : "alert"}
className={modeMsg.ok ? "pwi-settings-mode-msg pwi-settings-mode-msg--ok" : "pwi-settings-mode-msg pwi-settings-mode-msg--err"}
>
{modeMsg.text}
</span>
)}
</label>
)}
{supportsApiKeyTransport && (
<label className="pwi-settings-field">
<span className="pwi-settings-label">{t("modal.apiKeyTransport")}</span>
Expand Down Expand Up @@ -272,11 +345,15 @@ export default function ProviderSettings({
<span className="muted">{t("pws.settingsUnsavedBar")}</span>
<div className="pwi-settings-sticky-bar-actions">
<button type="button" className="btn btn-ghost btn-sm" onClick={discard} disabled={saving}>{t("pws.discardSettings")}</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => void save()} disabled={saving}>{saving ? t("pws.saving") : t("pws.saveSettings")}</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => void save()} disabled={saving || modeSaving}>{saving ? t("pws.saving") : t("pws.saveSettings")}</button>
</div>
</div>
)}
{msg && <div className={msg.ok ? "pwi-settings-msg pwi-settings-msg--ok" : "pwi-settings-msg pwi-settings-msg--err"}>{msg.text}</div>}
{msg && (
<div role={msg.ok ? "status" : "alert"} className={msg.ok ? "pwi-settings-msg pwi-settings-msg--ok" : "pwi-settings-msg pwi-settings-msg--err"}>
{msg.text}
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions gui/src/components/provider-workspace/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,6 @@ export type ProviderUpdatePatch = {
disabled?: boolean;
allowPrivateNetwork?: boolean;
liveModels?: boolean;
/** Dedicated field: the API PATCHes it alone for the canonical `openai` provider. */
codexAccountMode?: "direct" | "pool";
};
3 changes: 3 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1464,6 +1464,9 @@ export const de: Record<TKey, string> = {
"pws.saveSettings": "Speichern",
"pws.saving": "Wird gespeichert…",
"pws.settingsSaved": "Einstellungen gespeichert.",
"pws.accountModeSaved": "Kontomodus gespeichert.",
"pws.accountModeFailed": "Kontomodus konnte nicht gewechselt werden.",
"pws.accountModeConfirm": "OpenAI-Kontomodus wechseln? Laufende Unterhaltungen werden dem anderen Kontosatz zugeordnet und die Quotennutzung wird unter dem neuen Modus erfasst.",
"pws.settingsUnsavedBar": "Es gibt ungespeicherte Änderungen.",
"pws.unsavedLeaveBody": "Es gibt ungespeicherte Änderungen. Vor dem Verlassen speichern?",
"pws.unsavedLeaveTitle": "Ungespeicherte Änderungen",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,9 @@ export const en = {
"pws.saveSettings": "Save",
"pws.saving": "Saving…",
"pws.settingsSaved": "Settings saved.",
"pws.accountModeSaved": "Account mode saved.",
"pws.accountModeFailed": "Could not switch the account mode.",
"pws.accountModeConfirm": "Switch the OpenAI account mode? Running conversations will be reassigned to the other mode's account set, and quota usage will be tracked against the new mode.",
"pws.settingsUnsavedBar": "You have unsaved changes.",
"pws.unsavedLeaveBody": "You have unsaved changes. Save them before leaving?",
"pws.unsavedLeaveTitle": "Unsaved changes",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,9 @@ export const ja: Record<TKey, string> = {
"pws.saveSettings": "保存",
"pws.saving": "保存中…",
"pws.settingsSaved": "設定を保存しました。",
"pws.accountModeSaved": "アカウントモードを保存しました。",
"pws.accountModeFailed": "アカウントモードを切り替えられませんでした。",
"pws.accountModeConfirm": "OpenAI のアカウントモードを切り替えますか?実行中の会話はもう一方のモードのアカウントセットに再割り当てされ、クォータ使用量は新しいモードで計上されます。",
"pws.settingsUnsavedBar": "未保存の変更があります。",
"pws.unsavedLeaveBody": "未保存の変更があります。保存してから移動しますか?",
"pws.unsavedLeaveTitle": "未保存の変更",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1491,6 +1491,9 @@ export const ko: Record<TKey, string> = {
"pws.saveSettings": "저장",
"pws.saving": "저장 중…",
"pws.settingsSaved": "설정이 저장되었습니다.",
"pws.accountModeSaved": "계정 모드가 저장되었습니다.",
"pws.accountModeFailed": "계정 모드를 전환할 수 없습니다.",
"pws.accountModeConfirm": "OpenAI 계정 모드를 전환할까요? 실행 중인 대화가 다른 모드의 계정 세트로 다시 연결되며, 할당량 사용량은 새 모드로 집계됩니다.",
"pws.settingsUnsavedBar": "저장하지 않은 변경사항이 있습니다.",
"pws.unsavedLeaveBody": "저장하지 않은 변경사항이 있습니다. 나가기 전에 저장하시겠습니까?",
"pws.unsavedLeaveTitle": "미저장 변경사항",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,9 @@ export const ru: Record<TKey, string> = {
"pws.saveSettings": "Сохранить",
"pws.saving": "Сохранение…",
"pws.settingsSaved": "Настройки сохранены.",
"pws.accountModeSaved": "Режим аккаунта сохранён.",
"pws.accountModeFailed": "Не удалось переключить режим аккаунта.",
"pws.accountModeConfirm": "Переключить режим аккаунта OpenAI? Текущие беседы будут перенаправлены на другой набор аккаунтов, а использование квоты будет учитываться в новом режиме.",
"pws.settingsUnsavedBar": "Есть несохранённые изменения.",
"pws.unsavedLeaveBody": "Есть несохранённые изменения. Сохранить их перед переходом?",
"pws.unsavedLeaveTitle": "Несохранённые изменения",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,9 @@ export const zh: Record<TKey, string> = {
"pws.saveSettings": "保存",
"pws.saving": "保存中…",
"pws.settingsSaved": "设置已保存。",
"pws.accountModeSaved": "账户模式已保存。",
"pws.accountModeFailed": "无法切换账户模式。",
"pws.accountModeConfirm": "切换 OpenAI 账户模式?正在进行的对话将重新分配到另一种模式的账户集合,配额用量将按新模式计入。",
"pws.settingsUnsavedBar": "有未保存的更改。",
"pws.unsavedLeaveBody": "有未保存的更改。离开前保存吗?",
"pws.unsavedLeaveTitle": "未保存的更改",
Expand Down
3 changes: 3 additions & 0 deletions gui/src/pages/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ export default function Providers({ apiBase }: { apiBase: string }) {
const { removeProvider, confirmRemoveProvider, setProviderDisabled, setDefaultProvider, updateProvider } = useProvidersCrud({
apiBase, t, removeBusyRef, workspaceSelected, setWorkspaceSelected, setRemoveConfirmName,
notify, fetchConfig, fetchOauth, fetchProviderQuotas,
// Mode PATCHes clear quota caches and thread affinity; the shared controller
// must re-read /active (with quota) so both tabs show the post-switch state.
refreshCodexAccount: () => codexPool.load(true),
});

const requestLoginOAuth = (provider: string, addAccount = false) => {
Expand Down
12 changes: 11 additions & 1 deletion gui/src/pages/use-providers-crud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function useProvidersCrud({
fetchConfig,
fetchOauth,
fetchProviderQuotas,
refreshCodexAccount,
}: {
apiBase: string;
t: TFn;
Expand All @@ -40,6 +41,8 @@ export function useProvidersCrud({
fetchConfig: () => Promise<void>;
fetchOauth: () => Promise<void>;
fetchProviderQuotas: (refresh?: boolean) => Promise<void>;
/** Shared Codex account controller refresh (Providers.tsx passes codexPool.load). */
refreshCodexAccount?: () => Promise<unknown> | unknown;
}) {
const removeProvider = useCallback(async (name: string) => {
setRemoveConfirmName(name);
Expand Down Expand Up @@ -103,11 +106,18 @@ export function useProvidersCrud({
// Await refresh so callers (e.g. notes editor) only leave edit mode once
// item.note reflects the saved value.
await fetchConfig();
// A codexAccountMode PATCH clears quota caches and thread affinity server-side,
// so both dependent surfaces must refresh before the action reports success.
if (Object.hasOwn(patch, "codexAccountMode")) {
const refreshes: Promise<unknown>[] = [fetchProviderQuotas(true)];
if (refreshCodexAccount) refreshes.push(Promise.resolve(refreshCodexAccount()));
await Promise.all(refreshes);
}
return { ok: true };
} catch {
return { ok: false, error: t("prov.networkError") };
}
}, [apiBase, fetchConfig, t]);
}, [apiBase, fetchConfig, fetchProviderQuotas, refreshCodexAccount, t]);

const setDefaultProvider = useCallback(async (name: string): Promise<boolean> => {
try {
Expand Down
2 changes: 2 additions & 0 deletions gui/src/provider-workspace/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface WorkspaceProvider {
disabled?: boolean;
note?: string;
allowPrivateNetwork?: boolean;
/** Codex account routing mode for the canonical `openai` forward provider. */
codexAccountMode?: "direct" | "pool";
}

/** Three-way pricing/ownership tier for a ready provider row. */
Expand Down
3 changes: 3 additions & 0 deletions gui/src/styles/provider-workspace-settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@
}
.pwi-settings-textarea:focus { border-color: var(--accent-ring); outline: none; }
.pwi-settings-hint { font-size: var(--text-caption); color: var(--muted); line-height: 1.4; }
.pwi-settings-mode-msg { font-size: var(--text-caption); line-height: 1.4; }
.pwi-settings-mode-msg--ok { color: var(--green); }
.pwi-settings-mode-msg--err { color: var(--red); }

.pwi-settings-sticky-bar {
position: sticky; bottom: 0; z-index: 2;
Expand Down
Loading
Loading