Skip to content

Commit c23de7a

Browse files
feat(providers): generalize account UI and per-provider fallback
Collapse Codex-only auth nav into Providers, add configurable per-provider fallback hops, and keep runtime failover on the existing combo engine so sofie patches stay reproducible. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Codesmith <codesmith-bot@users.noreply.github.com>
1 parent 7c3c507 commit c23de7a

26 files changed

Lines changed: 823 additions & 56 deletions

gui/src/App.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ import Subagents from "./pages/Subagents";
88
import Logs from "./pages/Logs";
99
import Usage from "./pages/Usage";
1010
import Storage from "./pages/Storage";
11-
import CodexAuth from "./pages/CodexAuth";
1211
import ApiKeys from "./pages/ApiKeys";
1312
import Claude from "./pages/Claude";
1413
import Grok from "./pages/Grok";
1514
import Startup from "./pages/Startup";
1615
import ErrorBoundary from "./components/ErrorBoundary";
17-
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons";
16+
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons";
1817
import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n/shared";
1918
import { Select, Switch } from "./ui";
2019
import { installApiAuthFetch } from "./api";
@@ -37,7 +36,6 @@ const PAGE_TKEY: Record<Page, TKey> = {
3736
logs: "nav.logs",
3837
usage: "nav.usage",
3938
storage: "nav.storage",
40-
"codex-auth": "nav.codexAuth",
4139
api: "nav.api",
4240
claude: "nav.claude",
4341
grok: "nav.grok",
@@ -48,7 +46,6 @@ const THEME_KEY = "ocx-theme";
4846

4947
const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [
5048
{ id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid },
51-
{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey },
5249
{ id: "providers", tkey: "nav.providers", Icon: IconServer },
5350
{ id: "models", tkey: "nav.models", Icon: IconBoxes },
5451
{ id: "subagents", tkey: "nav.subagents", Icon: IconBot },
@@ -305,7 +302,6 @@ export default function App() {
305302
{page === "logs" && <Logs apiBase={API_BASE} />}
306303
{page === "usage" && <Usage apiBase={API_BASE} />}
307304
{page === "storage" && <Storage apiBase={API_BASE} />}
308-
{page === "codex-auth" && <CodexAuth apiBase={API_BASE} />}
309305
{page === "api" && <ApiKeys apiBase={API_BASE} />}
310306
{page === "claude" && <Claude apiBase={API_BASE} />}
311307
{page === "grok" && <Grok apiBase={API_BASE} />}

gui/src/app-routing.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ export type Page =
1212
| "logs"
1313
| "usage"
1414
| "storage"
15-
| "codex-auth"
1615
| "api"
1716
| "claude"
1817
| "grok";
@@ -27,7 +26,6 @@ export const VALID_PAGES = new Set<Page>([
2726
"logs",
2827
"usage",
2928
"storage",
30-
"codex-auth",
3129
"api",
3230
"claude",
3331
"grok",
@@ -83,6 +81,12 @@ export function resolveAppHashChange(rawHash: string): AppHashChangeAction {
8381
return { page: "providers", replaceTo: "providers" };
8482
}
8583

84+
// Account management used to be a Codex-only destination. Providers now owns
85+
// OAuth accounts, API-key pools and the OpenAI/Codex pool in one place.
86+
if (rawHash === "codex-auth" || rawHash.startsWith("codex-auth/")) {
87+
return { page: "providers", replaceTo: "providers" };
88+
}
89+
8690
// An unrecognised sub-hash is normalised away rather than left in the URL.
8791
if (!hashBelongsToPage(rawHash, nextPage)) {
8892
return { page: nextPage, replaceTo: nextPage };

gui/src/components/provider-workspace/ProviderDetails.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export default function ProviderDetails({
3030
modelUsage,
3131
quotaReport,
3232
availableModels,
33+
peerProviders,
3334
hasLiveModels,
3435
selectedModels,
3536
modelsLoading,
@@ -58,6 +59,7 @@ export default function ProviderDetails({
5859
modelUsage?: ProviderModelUsageRow[];
5960
quotaReport?: ProviderQuotaReportView;
6061
availableModels: string[];
62+
peerProviders?: import("./ProviderSettings").ProviderPeerOption[];
6163
/** Server-reported live-catalog provenance; see filterModels(). */
6264
hasLiveModels: boolean;
6365
selectedModels: string[];
@@ -291,6 +293,7 @@ export default function ProviderDetails({
291293
item={item}
292294
apiBase={apiBase}
293295
availableModels={availableModels}
296+
peerProviders={peerProviders}
294297
onUpdateProvider={onUpdateProvider}
295298
onDirtyChange={setSettingsDirty}
296299
onRegisterSave={registerSettingsSave}

gui/src/components/provider-workspace/ProviderSettings.tsx

Lines changed: 142 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,56 @@ import { useEffect, useMemo, useRef, useState } from "react";
1111
import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../../base-url-choice";
1212
import { readJsonIfOk } from "../../fetch-json";
1313
import { useT } from "../../i18n/shared";
14-
import { IconLock } from "../../icons";
14+
import { IconLock, IconPlus, IconTrash } from "../../icons";
1515
import { isCatalogProviderId } from "../../provider-icons";
1616
import type { CatalogPreset } from "../provider-catalog/provider-presets";
1717
import { authModeLabel } from "./ProviderRail";
18-
import type { WorkspaceItem, ProviderUpdatePatch } from "./types";
18+
import type { ProviderFallbackTarget, WorkspaceItem, ProviderUpdatePatch } from "./types";
1919

2020
const ADAPTERS = ["openai-responses", "openai-chat", "anthropic", "google", "azure-openai", "cursor"] as const;
2121
const EMPTY_MODELS: string[] = [];
22+
const EMPTY_PEERS: ProviderPeerOption[] = [];
23+
24+
export type ProviderPeerOption = {
25+
name: string;
26+
disabled?: boolean;
27+
models?: string[];
28+
defaultModel?: string;
29+
};
2230

2331
type ChoicesStatus = "idle" | "loading" | "ready" | "error";
2432

33+
function normalizeFallback(raw: ProviderFallbackTarget[] | undefined): ProviderFallbackTarget[] {
34+
if (!Array.isArray(raw)) return [];
35+
return raw
36+
.map(row => ({
37+
provider: typeof row.provider === "string" ? row.provider.trim() : "",
38+
model: typeof row.model === "string" ? row.model.trim() : "",
39+
}))
40+
.filter(row => row.provider && row.model);
41+
}
42+
43+
function fallbackFingerprint(rows: ProviderFallbackTarget[] | undefined): string {
44+
return JSON.stringify(normalizeFallback(rows));
45+
}
46+
47+
function modelsForPeer(peer: ProviderPeerOption | undefined, currentModel: string): string[] {
48+
const set = new Set<string>();
49+
for (const id of peer?.models ?? []) {
50+
if (id.trim()) set.add(id.trim());
51+
}
52+
if (peer?.defaultModel?.trim()) set.add(peer.defaultModel.trim());
53+
if (currentModel.trim()) set.add(currentModel.trim());
54+
return [...set].sort((a, b) => a.localeCompare(b));
55+
}
56+
2557
export default function ProviderSettings({
26-
item, availableModels = EMPTY_MODELS, apiBase, onUpdateProvider, onDirtyChange, onRegisterSave,
58+
item, availableModels = EMPTY_MODELS, peerProviders = EMPTY_PEERS, apiBase, onUpdateProvider, onDirtyChange, onRegisterSave,
2759
}: {
2860
item: WorkspaceItem;
2961
availableModels?: string[];
62+
/** Other configured providers (and their known models) for the fallback picker. */
63+
peerProviders?: ProviderPeerOption[];
3064
/** When set, load endpoint choices for catalog providers that expose baseUrlChoices. */
3165
apiBase?: string;
3266
onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>;
@@ -44,6 +78,7 @@ export default function ProviderSettings({
4478
const [note, setNote] = useState(item.note ?? "");
4579
const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false);
4680
const [liveModels, setLiveModels] = useState(item.liveModels !== false);
81+
const [fallback, setFallback] = useState<ProviderFallbackTarget[]>(() => normalizeFallback(item.fallback));
4782
const [saving, setSaving] = useState(false);
4883
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
4984
const [baseUrlChoices, setBaseUrlChoices] = useState<CatalogPreset["baseUrlChoices"]>();
@@ -60,9 +95,10 @@ export default function ProviderSettings({
6095
setNote(item.note ?? "");
6196
setAllowPrivateNetwork(item.allowPrivateNetwork ?? false);
6297
setLiveModels(item.liveModels !== false);
98+
setFallback(normalizeFallback(item.fallback));
6399
setMsg(null);
64100
queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)));
65-
}, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]);
101+
}, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, item.fallback, baseUrlChoices]);
66102
/* eslint-enable react-hooks/set-state-in-effect */
67103

68104
useEffect(() => {
@@ -102,7 +138,8 @@ export default function ProviderSettings({
102138
|| (adapter.trim() === "anthropic" && authMode === "key" && apiKeyTransport !== (item.apiKeyTransport ?? "x-api-key"))
103139
|| note.trim() !== (item.note ?? "")
104140
|| allowPrivateNetwork !== (item.allowPrivateNetwork ?? false)
105-
|| liveModels !== (item.liveModels !== false);
141+
|| liveModels !== (item.liveModels !== false)
142+
|| fallbackFingerprint(fallback) !== fallbackFingerprint(item.fallback);
106143

107144
useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]);
108145

@@ -119,6 +156,11 @@ export default function ProviderSettings({
119156
return list;
120157
}, [adapter]);
121158

159+
const fallbackPeers = useMemo(
160+
() => peerProviders.filter(p => p.name !== item.name),
161+
[peerProviders, item.name],
162+
);
163+
122164
const isPreset = isCatalogProviderId(item.name);
123165
const hasEndpointPicker = choicesStatus === "ready" && !!(baseUrlChoices && baseUrlChoices.length > 0);
124166
const supportsApiKeyTransport = adapter.trim() === "anthropic" && authMode === "key";
@@ -132,10 +174,24 @@ export default function ProviderSettings({
132174
? resolvedBaseUrlForChoice(baseUrlChoices, endpointChoice, baseUrl)
133175
: baseUrl.trim();
134176
if (!adapter.trim() || !nextBaseUrl) { setMsg({ ok: false, text: t("pws.adapterBaseRequired") }); return false; }
177+
const nextFallback = normalizeFallback(fallback);
178+
if (fallback.some(row => (row.provider.trim() && !row.model.trim()) || (!row.provider.trim() && row.model.trim()))) {
179+
setMsg({ ok: false, text: t("pws.fallbackIncomplete") });
180+
return false;
181+
}
135182
setSaving(true);
136183
setMsg(null);
137184
try {
138-
const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels };
185+
const patch: ProviderUpdatePatch = {
186+
adapter: adapter.trim(),
187+
baseUrl: nextBaseUrl,
188+
defaultModel: defaultModel.trim(),
189+
authMode,
190+
note: note.trim(),
191+
allowPrivateNetwork,
192+
liveModels,
193+
fallback: nextFallback,
194+
};
139195
if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport;
140196
else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = "";
141197
const res = await onUpdateProvider(item.name, patch);
@@ -160,19 +216,25 @@ export default function ProviderSettings({
160216
setAdapter(item.adapter); setBaseUrl(item.baseUrl);
161217
setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth);
162218
setApiKeyTransport(item.apiKeyTransport ?? "x-api-key");
163-
setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); setMsg(null);
219+
setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false);
220+
setFallback(normalizeFallback(item.fallback));
221+
setMsg(null);
164222
setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl));
165223
};
166224

167-
const endpointLabel = (id: string, fallback: string) => {
225+
const endpointLabel = (id: string, fallbackLabel: string) => {
168226
switch (id) {
169227
case "token-plan": return t("modal.endpoint.tokenPlan");
170228
case "payg": return t("modal.endpoint.payAsYouGo");
171229
case "custom": return t("modal.endpoint.custom");
172-
default: return fallback;
230+
default: return fallbackLabel;
173231
}
174232
};
175233

234+
const updateFallbackRow = (index: number, patch: Partial<ProviderFallbackTarget>) => {
235+
setFallback(rows => rows.map((row, i) => i === index ? { ...row, ...patch } : row));
236+
};
237+
176238
return (
177239
<div className="pwi-settings-form">
178240
<label className="pwi-settings-field">
@@ -264,6 +326,77 @@ export default function ProviderSettings({
264326
<span className="muted text-label" style={{ display: "block", marginTop: 2 }}>{t("pws.liveModelsDesc")}</span>
265327
</span>
266328
</label>
329+
330+
<div className="pwi-settings-field">
331+
<span className="pwi-settings-label">{t("pws.fallback")}</span>
332+
<span className="pwi-settings-hint">{t("pws.fallbackDesc")}</span>
333+
<div className="pwi-fallback-list">
334+
{fallback.map((row, index) => {
335+
const peer = fallbackPeers.find(p => p.name === row.provider);
336+
const modelIds = modelsForPeer(peer, row.model);
337+
return (
338+
<div key={index} className="pwi-fallback-row">
339+
<select
340+
className="input"
341+
value={row.provider}
342+
aria-label={t("pws.fallback.provider")}
343+
onChange={e => {
344+
const provider = e.target.value;
345+
const first = modelsForPeer(fallbackPeers.find(p => p.name === provider), "")[0] ?? "";
346+
updateFallbackRow(index, { provider, model: first });
347+
}}
348+
>
349+
<option value="">{t("pws.fallback.pickProvider")}</option>
350+
{fallbackPeers.map(p => (
351+
<option key={p.name} value={p.name}>
352+
{p.disabled ? t("pws.fallback.disabled", { name: p.name }) : p.name}
353+
</option>
354+
))}
355+
</select>
356+
{modelIds.length > 0 ? (
357+
<select
358+
className="input"
359+
value={row.model}
360+
disabled={!row.provider}
361+
aria-label={t("pws.fallback.model")}
362+
onChange={e => updateFallbackRow(index, { model: e.target.value })}
363+
>
364+
<option value="">{t("pws.fallback.pickModel")}</option>
365+
{modelIds.map(id => <option key={id} value={id}>{id}</option>)}
366+
</select>
367+
) : (
368+
<input
369+
className="input"
370+
value={row.model}
371+
disabled={!row.provider}
372+
placeholder={t("pws.fallback.modelPlaceholder")}
373+
aria-label={t("pws.fallback.model")}
374+
onChange={e => updateFallbackRow(index, { model: e.target.value })}
375+
/>
376+
)}
377+
<button
378+
type="button"
379+
className="btn btn-ghost btn-sm"
380+
aria-label={t("common.remove")}
381+
onClick={() => setFallback(rows => rows.filter((_, i) => i !== index))}
382+
>
383+
<IconTrash width={14} height={14} />
384+
</button>
385+
</div>
386+
);
387+
})}
388+
<button
389+
type="button"
390+
className="btn btn-ghost btn-sm"
391+
style={{ alignSelf: "flex-start" }}
392+
disabled={fallbackPeers.length === 0}
393+
onClick={() => setFallback(rows => [...rows, { provider: "", model: "" }])}
394+
>
395+
<IconPlus width={14} height={14} /> {t("pws.fallback.add")}
396+
</button>
397+
</div>
398+
</div>
399+
267400
{dirty && (
268401
<div className="pwi-settings-sticky-bar">
269402
<span className="muted">{t("pws.settingsUnsavedBar")}</span>

gui/src/components/provider-workspace/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export interface ProviderAuthHandlers {
8585
onEditAlias: (provider: string, type: "oauth" | "api-key", id: string, current?: string) => void | Promise<void>;
8686
}
8787

88+
export type ProviderFallbackTarget = {
89+
provider: string;
90+
model: string;
91+
};
92+
8893
export type ProviderUpdatePatch = {
8994
adapter?: string;
9095
baseUrl?: string;
@@ -96,4 +101,6 @@ export type ProviderUpdatePatch = {
96101
disabled?: boolean;
97102
allowPrivateNetwork?: boolean;
98103
liveModels?: boolean;
104+
/** Ordered failover chain; `[]` clears. */
105+
fallback?: ProviderFallbackTarget[];
99106
};

gui/src/i18n/de.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { TKey } from "./en";
44
export const de: Record<TKey, string> = {
55
"nav.dashboard": "Übersicht",
66
"nav.startup": "Startsicherheit",
7-
"nav.providers": "Anbieter",
7+
"nav.providers": "Anbieter & Konten",
88
"nav.models": "Modelle",
99
"nav.combos": "Combos",
1010
"nav.subagents": "Sub-Agenten",
@@ -1262,6 +1262,16 @@ export const de: Record<TKey, string> = {
12621262
"pws.allowPrivateNetwork": "Lokales/privates Netzwerk erlauben",
12631263
"pws.liveModels": "Modelle beim Anbieter erkennen",
12641264
"pws.liveModelsDesc": "Lädt den Live-Modellkatalog des Anbieters. Ausschalten, um nur konfigurierte statische Modelle zu verwenden.",
1265+
"pws.fallback": "Fallback-Anbieter",
1266+
"pws.fallbackDesc": "Bei wiederholbaren Fehlern (429, 5xx, Stream-Abbruch) werden diese Ziele der Reihe nach versucht. Leer lassen, um den Fehler an den Client zurückzugeben.",
1267+
"pws.fallback.add": "Fallback hinzufügen",
1268+
"pws.fallback.provider": "Fallback-Anbieter",
1269+
"pws.fallback.model": "Fallback-Modell",
1270+
"pws.fallback.pickProvider": "Anbieter wählen",
1271+
"pws.fallback.pickModel": "Modell wählen",
1272+
"pws.fallback.modelPlaceholder": "Modell-ID",
1273+
"pws.fallback.disabled": "{name} (deaktiviert)",
1274+
"pws.fallbackIncomplete": "Jede Fallback-Zeile braucht Anbieter und Modell.",
12651275
"pws.optionalPlaceholder": "Optional",
12661276
"pws.providerId": "Anbieter-ID",
12671277
"pws.reauth": "Erneute Anmeldung erforderlich",

gui/src/i18n/en.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export const en = {
44
// sidebar / nav / common
55
"nav.dashboard": "Dashboard",
66
"nav.startup": "Startup",
7-
"nav.providers": "Providers",
7+
"nav.providers": "Providers & accounts",
88
"nav.models": "Models",
99
"nav.combos": "Combos",
1010
"nav.subagents": "Subagents",
@@ -982,6 +982,16 @@ export const en = {
982982
"pws.allowPrivateNetwork": "Allow local/private network",
983983
"pws.liveModels": "Discover models from provider",
984984
"pws.liveModelsDesc": "Fetch the provider's live model catalog. Turn this off to use only configured/static models.",
985+
"pws.fallback": "Fallback providers",
986+
"pws.fallbackDesc": "On retryable failures (429, 5xx, stream drop), hop to these targets in order. Leave empty to return the error to the client.",
987+
"pws.fallback.add": "Add fallback",
988+
"pws.fallback.provider": "Fallback provider",
989+
"pws.fallback.model": "Fallback model",
990+
"pws.fallback.pickProvider": "Pick provider",
991+
"pws.fallback.pickModel": "Pick model",
992+
"pws.fallback.modelPlaceholder": "model id",
993+
"pws.fallback.disabled": "{name} (disabled)",
994+
"pws.fallbackIncomplete": "Each fallback row needs both a provider and a model.",
985995
"pws.optionalPlaceholder": "Optional",
986996
"pws.providerId": "Provider ID",
987997
"pws.reauth": "Needs re-auth",

0 commit comments

Comments
 (0)