diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..42b3538b610 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -382,7 +382,7 @@ Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked me ### Cockpit Tools Antigravity import -For v1, OpenCodex imports only a **Cockpit Tools Antigravity** JSON export for the `google-antigravity` provider. In the Providers dashboard, choose the local JSON file from that provider's Accounts tab. The dashboard does not show the file contents or credential values; it reports only imported, updated, failed, and unsupported counts. Other Cockpit providers are rejected in v1. +For v1, OpenCodex imports only a **Cockpit Tools Antigravity** JSON export for the `google-antigravity` provider. In the Providers dashboard, open that provider's Accounts tab and choose **Log in** or **Add account**: the dialog offers browser login or a Cockpit Tools import from a local JSON file, the clipboard, or pasted text. The dashboard does not show the file contents or credential values; it reports only imported, updated, failed, and unsupported counts. Other Cockpit providers are rejected in v1. The CLI accepts the export from a file or standard input only — never paste it into a command argument: diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 403cea49705..f14d01db783 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -117,7 +117,7 @@ is visible) and never forces an upstream refresh. | **Startup safety** | Show whether injected Codex routing survives a restart, with separate service and launcher-shim health plus exact repair commands. | | **Windows tray** | Install a per-user login tray for one-click proxy start, stop, restart, dashboard access, and status. The tray is a controller, not a proxy restart service. | | **Codex autostart** | Allow an already-installed Codex launcher shim to run `ocx ensure`. This toggle does not install a shim or background service. | -| **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Other OAuth providers with a logged-in account (Google Antigravity, xAI, Kiro, Cursor and the rest) show an **Account pool** card. Its switch controls the pre-dispatch account preference; a 429 still fails over either way. The strategy is marked **Saved, not live** and cannot be changed there until `pool.kernel` is on, and a strategy set only from the CLI, such as Kiro's `least-loaded`, is shown by name and kept when you toggle the pool. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | +| **Providers** | Add, edit, set the default (enabled providers only), enable/disable, and remove providers; manage OAuth account pools and API-key pools where supported. Removing the current default switches to the first remaining enabled provider when one exists; otherwise deletion is refused and the current default is kept. Provider Settings can disable live model discovery for endpoints with missing, slow, or oversized `/models` catalogs. OAuth account lists show each account as a card (or a compact row) with search, quota filters, sorting, a login/hidden/alias title switch, and one cached quota refresh per tab. Accounts without quota readings display as unknown and do not count as available; estimated remaining tokens are omitted. Logout remains next to the signed-in status; removing an account asks for confirmation in a dialog. The list rereads cached quota every 30 seconds while the dashboard is visible. For Claude (Anthropic) OAuth pools, each logged-in account shows its own 5-hour and weekly rate-limit bars (usage is per credential); a failed probe keeps the last-known bars and marks them unavailable until the next successful refresh. Other OAuth providers with a logged-in account (Google Antigravity, xAI, Kiro, Cursor and the rest) show an **Account pool** card. Its switch controls the pre-dispatch account preference; a 429 still fails over either way. The strategy is marked **Saved, not live** and cannot be changed there until `pool.kernel` is on, and a strategy set only from the CLI, such as Kiro's `least-loaded`, is shown by name and kept when you toggle the pool. The Provider Overview shown when no provider is selected carries a **Refresh all quotas** control that forces one server-side re-read of every configured provider; a provider whose upstream probe fails keeps its last-good row, so the status line reports that the check completed rather than claiming every value is fresh, and each row's own age stays the per-provider freshness signal. | | **Add provider** | One search above the tabs reaches all four at once — Accounts, Free, Local, Paid. While a query is live the results are grouped by tab with a count each, and the selected tab stays put rather than jumping. Press ArrowDown in the search box to focus the first available result action, skipping disabled buttons; if no action is available, focus stays in the search box. Local runtimes (Ollama, vLLM, LM Studio, LiteLLM) have their own tab, and a long provider note clamps to two lines with the full text one click away. Claude Code CLI (subscription) sits under Paid with a Subscription CLI badge, and selecting it warns that it bills the signed-in Claude subscription through `claude -p` and never uses an API key (use `anthropic-apikey` for key billing). | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 19c55968fa8..e9edc722ca5 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -593,7 +593,7 @@ Labels are read on login/import or refresh, never inferred from quota percentage | `POST /api/oauth/login/cancel` | Cancel a public in-progress OAuth flow | 400 unknown provider | | `GET /api/oauth/status` | Poll one provider's OAuth flow | 400 unknown provider | | `POST /api/oauth/logout` | Remove the selected provider credential | 400 unknown provider; `oauth_mutation_busy` | -| `GET /api/oauth/accounts` | List masked accounts; Anthropic and generic OAuth account rows include their `paused` state. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in. | 400 invalid provider | +| `GET /api/oauth/accounts` | List masked accounts; Anthropic and generic OAuth account rows include their `paused` state. Kiro rows include `autoSelectable` and a closed `skipReason` when excluded from automatic selection; an active singleton may still send. Quota remains opt-in (`quota=1`); `refresh=1` forces upstream probes, and adding `accountId=` forces only that account while the rest are served from cache. An id that matches no account forces every account. | 400 invalid provider | | `DELETE /api/oauth/accounts` | Remove one account | 400 invalid provider/id; 404 account missing; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/active` | Select the active OAuth account | 400 invalid provider/account; 404 account missing; 409 account paused; `oauth_mutation_busy` | | `PUT /api/oauth/accounts/pause` | Pause or resume one Anthropic or generic OAuth account. Body `{ provider, accountId, paused }`; pausing the active account selects the next usable account when available. Pause is durable and independent of pool enablement; resume preserves health and credentials. | 400 unsupported provider or invalid body; 404 account missing; `oauth_mutation_busy` | diff --git a/gui/public/provider-icons/cockpit-tools.png b/gui/public/provider-icons/cockpit-tools.png new file mode 100644 index 00000000000..729eaaae866 Binary files /dev/null and b/gui/public/provider-icons/cockpit-tools.png differ diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 99fa327d6b0..1eb0164f20c 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -472,8 +472,10 @@ export default function App() { navigateToPage(group.pages[0]); setNavOpen(false); }} + title={t(tkey)} + aria-label={t(tkey)} aria-current={active ? "page" : undefined}> - {t(tkey)} + {t(tkey)} ); diff --git a/gui/src/components/AccountAuthChoiceModal.tsx b/gui/src/components/AccountAuthChoiceModal.tsx new file mode 100644 index 00000000000..3e1aea13ffd --- /dev/null +++ b/gui/src/components/AccountAuthChoiceModal.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef } from "react"; +import { IconX } from "../icons"; +import { useT } from "../i18n/shared"; +import { ProviderIcon } from "./provider-workspace/ProviderRail"; +import CockpitToolsCard from "./provider-workspace/CockpitToolsCard"; + +export interface AccountAuthChoiceModalProps { + provider: string; + providerLabel: string; + apiBase: string; + isOpen: boolean; + isBusy?: boolean; + onClose: () => void; + onContinueOAuth: () => void; + onImportSuccess?: () => void; +} + +export default function AccountAuthChoiceModal({ + provider, + providerLabel, + apiBase, + isOpen, + isBusy = false, + onClose, + onContinueOAuth, + onImportSuccess, +}: AccountAuthChoiceModalProps) { + const t = useT(); + const dialog = useRef(null); + const primary = useRef(null); + useEffect(() => { + if (!isOpen) return; + const previous = document.activeElement as HTMLElement | null; + primary.current?.focus(); + // Deferred: React is still removing the dialog during cleanup and resets focus to the + // body afterwards, which would discard a synchronous restoration. + return () => { requestAnimationFrame(() => { if (previous?.isConnected && typeof previous.focus === "function") previous.focus(); }); }; + }, [isOpen]); + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
{ + if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); } + if (event.key !== "Tab") return; + const focusables = [...(dialog.current?.querySelectorAll("button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])") ?? [])].filter(el => el.offsetParent !== null); + const buttons = focusables as HTMLButtonElement[]; + const first = buttons?.[0], last = buttons?.[buttons.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + }} + > +
e.stopPropagation()}> +
+
+

+ {t("pws.authChoiceModalTitle", { provider: providerLabel })} +

+

+ {t("pws.authChoiceModalSubtitle")} +

+
+ +
+ +
+ {/* Method 1: Browser OAuth */} +
+
+ +
+ {t("pws.antigravityOauthTitle")} + {t("pws.antigravityOauthDesc")} +
+
+ +
+ + {/* Method 2: Cockpit Tools Card */} + +
+
+
+ ); +} diff --git a/gui/src/components/AntigravityChoiceModal.tsx b/gui/src/components/AntigravityChoiceModal.tsx new file mode 100644 index 00000000000..f8ce7796a3b --- /dev/null +++ b/gui/src/components/AntigravityChoiceModal.tsx @@ -0,0 +1,18 @@ +import { useT } from "../i18n/shared"; +import AccountAuthChoiceModal, { type AccountAuthChoiceModalProps } from "./AccountAuthChoiceModal"; + +export type AntigravityChoiceModalProps = Omit & { + provider?: string; + providerLabel?: string; +}; + +export default function AntigravityChoiceModal(props: AntigravityChoiceModalProps) { + const t = useT(); + return ( + + ); +} diff --git a/gui/src/components/provider-workspace/CockpitToolsCard.tsx b/gui/src/components/provider-workspace/CockpitToolsCard.tsx new file mode 100644 index 00000000000..d28b825642f --- /dev/null +++ b/gui/src/components/provider-workspace/CockpitToolsCard.tsx @@ -0,0 +1,298 @@ +import { useRef, useState } from "react"; +import { useT } from "../../i18n/shared"; +import { IconClipboard } from "../../icons"; + +const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; + +const COCKPIT_RESULT_KEYS = new Set([ + "totalCount", + "importedCount", + "updatedCount", + "failedCount", + "unsupportedCount", + "results", +]); + +const COCKPIT_RESULT_STATUSES = new Set(["imported", "updated", "failed", "unsupported"]); +const COCKPIT_STATUS_CODES: Record> = { + imported: new Set(["imported"]), + updated: new Set(["updated"]), + failed: new Set(["invalid_record", "credential_rejected", "identity_mismatch", "missing_project", "persist_failed"]), + unsupported: new Set(["unsupported_provider", "unsupported_format"]), +}; + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +export type CockpitImportResult = { + totalCount: number; + importedCount: number; + updatedCount: number; + failedCount: number; + unsupportedCount: number; + results: Array<{ index: number; status: string; code: string }>; +}; + +function safeCockpitImportResult(value: unknown): CockpitImportResult | null { + if (!isPlainObject(value) || Object.keys(value).some(key => !COCKPIT_RESULT_KEYS.has(key))) return null; + const { totalCount, importedCount, updatedCount, failedCount, unsupportedCount, results } = value; + if ( + typeof totalCount !== "number" || typeof importedCount !== "number" + || typeof updatedCount !== "number" || typeof failedCount !== "number" + || typeof unsupportedCount !== "number" || !Array.isArray(results) + ) return null; + for (const [index, result] of results.entries()) { + if (!isPlainObject(result) || Object.keys(result).some(key => !["index", "status", "code"].includes(key))) return null; + const status = String(result.status); + const code = String(result.code); + if (result.index !== index || !COCKPIT_RESULT_STATUSES.has(status)) return null; + const allowedCodes = COCKPIT_STATUS_CODES[status]; + if (!allowedCodes || !allowedCodes.has(code)) return null; + } + return { + totalCount, importedCount, updatedCount, failedCount, unsupportedCount, + results: results as Array<{ index: number; status: string; code: string }>, + }; +} + +export interface CockpitToolsCardProps { + apiBase: string; + onImportSuccess?: () => void | Promise; + className?: string; +} + +export default function CockpitToolsCard({ + apiBase, + onImportSuccess, + className = "", +}: CockpitToolsCardProps) { + const t = useT(); + const [importBusy, setImportBusy] = useState(false); + const [importStatus, setImportStatus] = useState<"idle" | "invalid" | "failed" | "complete">("idle"); + const [importResult, setImportResult] = useState(null); + const [showManualPaste, setShowManualPaste] = useState(false); + const [manualPasteText, setManualPasteText] = useState(""); + const importFileRef = useRef(null); + + const importCockpitJson = async (rawText: string) => { + const text = rawText.trim(); + if (!text || importBusy) return; + setImportBusy(true); + setImportStatus("idle"); + setImportResult(null); + try { + if (new TextEncoder().encode(text).byteLength > COCKPIT_IMPORT_MAX_BYTES) { + setImportStatus("invalid"); + return; + } + let document: unknown; + try { + document = JSON.parse(text) as unknown; + } catch { + // No lenient fallback: a context-free trailing-comma strip also rewrites + // text inside JSON strings and can submit corrupted account data. + setImportStatus("invalid"); + return; + } + if (isPlainObject(document)) { + document = [document]; + } + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 25000); + let response: Response; + try { + response = await fetch(`${apiBase}/api/oauth/accounts/import`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "google-antigravity", format: "cockpit-tools", document }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + if (!response.ok) { + setImportStatus("failed"); + return; + } + const result = safeCockpitImportResult(await response.json().catch(() => null)); + if (!result) { + setImportStatus("failed"); + return; + } + setImportResult(result); + setImportStatus("complete"); + setShowManualPaste(false); + setManualPasteText(""); + try { + await onImportSuccess?.(); + } catch { + /* Preserve the completed import state */ + } + } catch { + setImportStatus("failed"); + } finally { + if (importFileRef.current) importFileRef.current.value = ""; + setImportBusy(false); + } + }; + + const importCockpitFile = async (file: File | undefined) => { + if (!file || importBusy) return; + if (!file.name.toLowerCase().endsWith(".json") || file.size > COCKPIT_IMPORT_MAX_BYTES) { + setImportStatus("invalid"); + setImportResult(null); + if (importFileRef.current) importFileRef.current.value = ""; + return; + } + try { + const text = await file.text(); + await importCockpitJson(text); + } catch { + setImportStatus("failed"); + setImportResult(null); + } finally { + if (importFileRef.current) importFileRef.current.value = ""; + } + }; + + const importCockpitClipboard = async () => { + if (importBusy) return; + setImportStatus("idle"); + setImportResult(null); + try { + if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.readText === "function") { + const text = await navigator.clipboard.readText(); + if (text && text.trim()) { + await importCockpitJson(text); + return; + } + } + } catch { + setShowManualPaste(true); + return; + } + setShowManualPaste(true); + }; + + return ( +
+
+
+ +
+ {t("pws.cockpitCardTitle")} + {t("pws.cockpitCardSubtitle")} +
+
+
+ + { void importCockpitFile(event.currentTarget.files?.[0]); }} + /> + + + +
+
+ {showManualPaste && ( +
+ +