// screens-config.jsx — Worker B: Configuration + Models + ApiKeys. // Owns: window.Configuration, window.Models, window.ApiKeys. // Phase-2 primitives only; CSS in screens-config.css (.cfg- .models- .keys- prefixes). // Props: { liveStatus, statusError, statusStale, refreshStatus, onAction, navigate, pushToast } (() => { const { useState, useEffect, useRef, useMemo, useCallback } = React; const { Icon, api, useLive, useToast, Button, IconButton, Card, StatCard, PageHeader, Field, Input, Textarea, Select, Switch, Checkbox, SegmentedControl, Badge, Tabs, Modal, EmptyState, Skeleton, Spinner, CopyButton, SecretField, KeyValue, CodeBlock, ErrorBoundary, } = window; /* ───────────────────────────────────────────────────────────────── Helpers ───────────────────────────────────────────────────────────────── */ const cx = (...parts) => parts.filter(Boolean).join(" "); const CODEX_MODEL_OPTIONS = [ "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-4o", "gpt-4.1", "gpt-4.1-mini", "o3", "o4-mini", ]; // Antigravity models served by the agy CLI (from `agy models`). Used as the // upstream-model options when a row's "Forwarded to" = Agy. agy accepts these // display names verbatim via --model. First entry is the default on switch. const AGY_MODEL_OPTIONS = [ "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)", "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)", "Claude Sonnet 4.6 (Thinking)", "Claude Opus 4.6 (Thinking)", "GPT-OSS 120B (Medium)", ]; // Models the Claude Code CLI accepts via --model when a row's "Forwarded to" // = Claude: short aliases or full claude-* ids. First entry is the default on // switch. The "[1m]" alias suffix (if any) is stripped server-side. const CLAUDE_MODEL_OPTIONS = [ "sonnet", "opus", "haiku", "claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5", ]; function modelStatusFor(real) { if (!real) return "unsupported"; return (CODEX_MODEL_OPTIONS.includes(real) || AGY_MODEL_OPTIONS.includes(real) || CLAUDE_MODEL_OPTIONS.includes(real)) ? "available" : "untested"; } // Where each alias forwards to. Codex, Agy and Claude route today (Agy and // Claude are text-only: no caller tool calls). Alphabetical. const FORWARD_OPTIONS = [ { value: "agy", label: "Agy" }, { value: "claude", label: "Claude" }, { value: "codex", label: "Codex" }, ]; const FORWARD_VALUES = FORWARD_OPTIONS.map(o => o.value); function normalizeForward(v) { const s = (v == null ? "" : String(v)).trim().toLowerCase(); return FORWARD_VALUES.includes(s) ? s : "codex"; } function normalizeModelDraft(m, idx) { const alias = (m.alias || "").trim(); const real = (m.real || "").trim(); const status = m.status || modelStatusFor(real); return { id: m.id || alias || ("model-" + (idx || 0)), alias, real, status, context: ((m.context || "200k").toLowerCase() === "1m" ? "1m" : "200k"), forward_to: normalizeForward(m.forward_to), default: !!m.default, recommended: !!m.recommended, }; } const STATUS_TONE = { available: "success", ok: "success", untested: "warning", warn: "warning", unsupported: "danger", }; const STATUS_LABEL = { available: "Available", ok: "Available", untested: "Untested", warn: "Untested", unsupported: "Unsupported", }; /* ───────────────────────────────────────────────────────────────── CONFIGURATION ───────────────────────────────────────────────────────────────── */ function Configuration({ liveStatus, pushToast }) { const toast = useToast(); const notify = useCallback((msg, tone) => { if (toast) { tone === "error" ? toast.error(msg) : tone === "success" ? toast.success(msg) : toast.info(msg); } else if (pushToast) pushToast(msg, tone || "info"); }, [toast, pushToast]); const DEFAULT_CFG = { UPSTREAM: "codex", CODEX_BASE_URL: "https://chatgpt.com", CODEX_AUTH_FILE: "~/.codex/auth.json", PROXY_PUBLIC_URL: "", PROXY_PORT: "4000", PROXY_API_KEY: "", REQUEST_TIMEOUT_MS: "120000", LOG_LEVEL: "info", }; const [cfg, setCfg] = useState(DEFAULT_CFG); const [secrets, setSecrets] = useState({}); const [dirty, setDirty] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); // Use liveStatus for codex_auth if already available const authMeta = (liveStatus && liveStatus.codex_auth) || null; useEffect(() => { setLoading(true); api.get("/ui/api/config").then(res => { setCfg(c => ({ ...c, ...(res.config || {}) })); setSecrets(res.secrets || {}); setDirty(false); }).catch(e => notify("Failed to load config: " + (e.message || e), "error")) .finally(() => setLoading(false)); }, []); const update = useCallback((k, v) => { setCfg(c => ({ ...c, [k]: v })); setDirty(true); }, []); const portValid = /^\d{2,5}$/.test(cfg.PROXY_PORT) && Number(cfg.PROXY_PORT) >= 1024; const urlValid = cfg.CODEX_BASE_URL ? /^https?:\/\//.test(cfg.CODEX_BASE_URL) : true; const canSave = dirty && portValid && urlValid && !saving; const save = useCallback(async () => { if (!canSave) return; setSaving(true); try { const res = await api.post("/ui/api/config", { config: cfg, }); setDirty(false); notify(res.message || "Configuration saved — restart proxy to apply", "success"); } catch (e) { notify("Save failed: " + (e.message || e), "error"); } finally { setSaving(false); } }, [canSave, cfg, notify]); const reset = useCallback(() => { setCfg(DEFAULT_CFG); setDirty(true); }, []); const upstreamHint = { codex: "Uses your Codex ChatGPT subscription via the auth file below.", openai: "Uses a raw OpenAI API key. Codex-only models will be unavailable.", }; const headerActions = (
{dirty && Unsaved changes}
); if (loading) { return (
); } return (
{/* Upstream */}
Upstream UPSTREAM
update("UPSTREAM", v)} />

{upstreamHint[cfg.UPSTREAM] || upstreamHint.codex}

Codex base URL CODEX_BASE_URL
update("CODEX_BASE_URL", e.target.value)} invalid={!urlValid} placeholder="https://chatgpt.com" /> {!urlValid && (

Must start with http:// or https://

)}
Codex auth file CODEX_AUTH_FILE
update("CODEX_AUTH_FILE", e.target.value)} placeholder="~/.codex/auth.json" />

{authMeta ? authMeta.exists ? "File exists — mode: " + (authMeta.mode || "unknown") : "File missing or unavailable" : "Path to the local Codex auth cache."}

{/* Upstream services — enable/disable each backend completely */}
{[ { key: "PROXY_CODEX_ENABLED", name: "Codex (ChatGPT)", hint: "OpenAI Codex via your ChatGPT subscription." }, { key: "PROXY_CLAUDE_ENABLED", name: "Claude Code", hint: "Anthropic Claude via the local Claude Code CLI." }, { key: "PROXY_AGY_ENABLED", name: "Antigravity (Gemini)", hint: "Google Antigravity via the local agy CLI." }, ].map(svc => { const on = (cfg[svc.key] || "1") !== "0"; return (
{svc.name} {svc.key}
update(svc.key, v ? "1" : "0")} label={on ? "Enabled" : "Disabled"} />

{svc.hint}{on ? "" : " Requests routed here return a clear “disabled” error."}

); })}

Enabling/disabling a service applies immediately on Save — no restart needed.

{/* Server */}
Public URL PROXY_PUBLIC_URL
update("PROXY_PUBLIC_URL", e.target.value)} placeholder="https://ai-api1.example.com" />

Public HTTPS URL shown to clients. Leave empty for local-only installs.

Port PROXY_PORT
update("PROXY_PORT", e.target.value)} invalid={!portValid} placeholder="4000" /> {portValid ?

Binds to 127.0.0.1:{cfg.PROXY_PORT}

:

Use a numeric port ≥ 1024

}
API key PROXY_API_KEY

Token Claude Code presents to this proxy. Never sent upstream.

Request timeout REQUEST_TIMEOUT_MS
update("REQUEST_TIMEOUT_MS", e.target.value)} placeholder="120000" />

Per-request timeout against the upstream (milliseconds).

Log level LOG_LEVEL
update("LOG_LEVEL", v)} />
{/* Model aliases are managed on the dedicated Models page (single source). */}
Model aliases moved. Manage what each public model maps to — upstream model, context, forwarded-to backend, default and recommended — on the{" "} Models page.
{dirty && Unsaved changes}
); } /* ───────────────────────────────────────────────────────────────── MODELS ───────────────────────────────────────────────────────────────── */ function Models({ pushToast }) { const toast = useToast(); const notify = useCallback((msg, tone) => { if (toast) { tone === "error" ? toast.error(msg) : tone === "success" ? toast.success(msg) : toast.info(msg); } else if (pushToast) pushToast(msg, tone || "info"); }, [toast, pushToast]); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); // id to delete const load = useCallback(() => { setLoading(true); api.get("/ui/api/models").then(res => { setModels((res.models || []).map((m, i) => normalizeModelDraft(m, i))); setDirty(false); }).catch(e => notify("Failed to load models: " + (e.message || e), "error")) .finally(() => setLoading(false)); }, [notify]); useEffect(() => { load(); }, []); const aliasCounts = useMemo(() => models.reduce((acc, m) => { const k = m.alias.toLowerCase(); if (k) acc[k] = (acc[k] || 0) + 1; return acc; }, {}), [models]); const invalidIds = useMemo(() => new Set(models.filter(m => { const a = m.alias.trim(), r = m.real.trim(); return !a || !r || aliasCounts[a.toLowerCase()] > 1 || /\s/.test(a); }).map(m => m.id)), [models, aliasCounts]); const counts = useMemo(() => { const c = { all: models.length, available: 0, untested: 0, unsupported: 0 }; models.forEach(m => { const s = m.status; if (s === "available" || s === "ok") c.available++; else if (s === "untested" || s === "warn") c.untested++; else c.unsupported++; }); return c; }, [models]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return models.filter(m => { const statusMatch = filter === "all" || (filter === "available" && (m.status === "available" || m.status === "ok")) || (filter === "untested" && (m.status === "untested" || m.status === "warn")) || (filter === "unsupported" && m.status === "unsupported"); const queryMatch = !q || m.alias.toLowerCase().includes(q) || m.real.toLowerCase().includes(q); return statusMatch && queryMatch; }); }, [models, filter, query]); const updateModel = useCallback((id, key, val) => { setModels(rows => rows.map(row => { if (row.id !== id) return row; const next = { ...row, [key]: val }; // Switching a row to Agy: if its upstream model isn't an Antigravity // model yet, prefill a sensible default so it routes to a real agy model. if (key === "forward_to" && val === "agy" && !AGY_MODEL_OPTIONS.includes((next.real || "").trim())) { next.real = AGY_MODEL_OPTIONS[0]; } // Switching a row to Claude: if its upstream model isn't a known Claude // name, prefill a sensible default so the CLI gets a valid --model. if (key === "forward_to" && val === "claude" && !CLAUDE_MODEL_OPTIONS.includes((next.real || "").trim())) { next.real = CLAUDE_MODEL_OPTIONS[0]; } if (key === "real" || key === "forward_to") next.status = modelStatusFor((next.real || "").trim()); return next; })); setDirty(true); }, []); // default / recommended are single-choice: turning one on clears the others. const toggleFlag = useCallback((id, key) => { setModels(rows => { const turningOn = !rows.find(r => r.id === id)?.[key]; return rows.map(row => { if (row.id === id) return { ...row, [key]: turningOn }; return turningOn && row[key] ? { ...row, [key]: false } : row; }); }); setDirty(true); }, []); const addAlias = useCallback(() => { const id = "new-" + Date.now(); setModels(rows => [...rows, normalizeModelDraft({ id, alias: "", real: CODEX_MODEL_OPTIONS[0], context: "200k" }, rows.length)]); setFilter("all"); setDirty(true); }, []); const confirmDelete = useCallback((id) => setDeleteConfirm(id), []); const doDelete = useCallback(() => { if (!deleteConfirm) return; setModels(rows => rows.filter(r => r.id !== deleteConfirm)); setDeleteConfirm(null); setDirty(true); }, [deleteConfirm]); const canSave = dirty && !saving && invalidIds.size === 0; const save = useCallback(async () => { if (!canSave) { if (invalidIds.size > 0) notify("Fix blank or duplicate aliases before saving", "error"); return; } setSaving(true); try { const res = await api.post("/ui/api/models", { models: models.map(m => ({ alias: m.alias.trim(), real: m.real.trim(), context: m.context, forward_to: m.forward_to, default: !!m.default, recommended: !!m.recommended })), }); setModels((res.models || []).map((m, i) => normalizeModelDraft(m, i))); setDirty(false); notify(res.message || "Model aliases saved", "success"); } catch (e) { notify("Save failed: " + (e.message || e), "error"); } finally { setSaving(false); } }, [canSave, models, invalidIds, notify]); // ── Import / Export ────────────────────────────────────────────── const fileInputRef = useRef(null); const exportModels = useCallback(() => { const payload = { version: 1, exported_at: new Date().toISOString(), models: models.map(m => ({ alias: m.alias.trim(), real: m.real.trim(), context: m.context, forward_to: m.forward_to, default: !!m.default, recommended: !!m.recommended, })), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "connect-ai-proxy-models.json"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); notify("Exported " + payload.models.length + " model" + (payload.models.length === 1 ? "" : "s"), "success"); }, [models, notify]); const importModels = useCallback((file) => { if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(String(reader.result)); const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.models) ? parsed.models : null); if (!list) throw new Error("expected a JSON array or an object with a \"models\" array"); const rows = list.map((m, i) => normalizeModelDraft({ id: "imp-" + Date.now() + "-" + i, alias: m.alias || m.from || "", real: m.real || m.to || "", context: m.context || "200k", forward_to: m.forward_to, default: !!m.default, recommended: !!m.recommended, }, i)); if (rows.length === 0) throw new Error("no models found in file"); setModels(rows); setFilter("all"); setQuery(""); setDirty(true); notify("Imported " + rows.length + " model" + (rows.length === 1 ? "" : "s") + " — review and Save", "success"); } catch (e) { notify("Import failed: " + (e.message || e), "error"); } }; reader.onerror = () => notify("Could not read file", "error"); reader.readAsText(file); }, [notify]); const onImportFile = useCallback((e) => { const file = e.target.files && e.target.files[0]; importModels(file); e.target.value = ""; // allow re-importing the same file }, [importModels]); const filterTabs = [ { id: "all", label: "All", badge: counts.all }, { id: "available", label: "Available", badge: counts.available }, { id: "untested", label: "Untested", badge: counts.untested }, { id: "unsupported", label: "Unsupported", badge: counts.unsupported }, ]; return (
{dirty && ( {invalidIds.size ? "Fix " + invalidIds.size + " row" + (invalidIds.size > 1 ? "s" : "") : "Unsaved"} )}
} /> {/* Toolbar */}
setQuery(e.target.value)} /> {query && ( setQuery("")} /> )}
{/* Table */} {loading ? ( ) : ( {CODEX_MODEL_OPTIONS.map(m => {AGY_MODEL_OPTIONS.map(m => {CLAUDE_MODEL_OPTIONS.map(m => {filtered.length === 0 ? ( ) : (
{filtered.map(m => { const invalid = invalidIds.has(m.id); return ( ); })}
Public alias Upstream model Forwarded to Context Status Notes
updateModel(m.id, "alias", e.target.value)} placeholder="claude-sonnet-4-5" />
updateModel(m.id, "real", e.target.value)} placeholder={m.forward_to === "agy" ? "Gemini 3.1 Pro (High)" : "gpt-5.5"} /> {STATUS_LABEL[m.status] || m.status} {invalid ? "Alias and model required; aliases must be unique and have no spaces." : m.alias ? (m.alias + " → " + (m.real || "no model")) : "New alias — fill in both fields."}
confirmDelete(m.id)} />
)}
)} {/* Info callout */}
How aliasing works. Claude Code requests Anthropic-style model names like{" "} claude-sonnet-4-5. This proxy rewrites the{" "} model field and forwards it to the upstream model. Context window size is advertised per alias. Forwarded to selects the backend:{" "} Codex and Agy route today —{" "} Agy (Google Antigravity) is text-only, so it serves plain completions but can't drive Claude Code's tool loop. Claude is reserved and takes effect once its backend is enabled.
{/* Delete confirm modal */} setDeleteConfirm(null)} title="Delete alias" description={"Remove this model alias? This cannot be undone without saving a new alias."} footer={
} /> ); } /* ───────────────────────────────────────────────────────────────── API KEYS ───────────────────────────────────────────────────────────────── */ function ApiKeys({ liveStatus, pushToast }) { const toast = useToast(); const notify = useCallback((msg, tone) => { if (toast) { tone === "error" ? toast.error(msg) : tone === "success" ? toast.success(msg) : toast.info(msg); } else if (pushToast) pushToast(msg, tone || "info"); }, [toast, pushToast]); const [keysData, setKeysData] = useState({ providers: [], clients: [], defaults: {} }); const [loading, setLoading] = useState(true); // Provider form const [providerForm, setProviderForm] = useState({ id: "", provider: "openai", label: "OpenAI", base_url: "", api_key: "", }); const [providerSaving, setProviderSaving] = useState(false); const isRenewing = !!providerForm.id; // Client form const [clientForm, setClientForm] = useState({ label: "Claude Code", schema: "both", provider: "default", provider_key_id: "", }); const [clientSaving, setClientSaving] = useState(false); // Created key modal const [createdKey, setCreatedKey] = useState(""); const rootUrl = (liveStatus && (liveStatus.display_url || liveStatus.public_url || liveStatus.local_url)) || "http://127.0.0.1:4000"; const defaults = keysData.defaults || {}; const anthropicUrl = (liveStatus && liveStatus.anthropic_url) || defaults.anthropic_base || (rootUrl + "/anthropic"); const openaiUrl = (liveStatus && liveStatus.openai_url) || defaults.openai_local_url || (rootUrl + "/openai/v1"); const load = useCallback(() => { setLoading(true); api.get("/ui/api/keys") .then(res => setKeysData(res || { providers: [], clients: [], defaults: {} })) .catch(e => notify("Failed to load keys: " + (e.message || e), "error")) .finally(() => setLoading(false)); }, [notify]); useEffect(() => { load(); }, []); const providerBaseDefault = providerForm.provider === "openai" ? (defaults.openai_base_url || "https://api.openai.com/v1") : (defaults.gemini_base_url || "https://generativelanguage.googleapis.com/v1beta/openai"); const selectedProviderKeys = (keysData.providers || []).filter( p => p.provider === clientForm.provider && p.enabled ); const saveProvider = useCallback(async () => { setProviderSaving(true); try { const res = await api.post("/ui/api/keys/provider", { ...providerForm, base_url: providerForm.base_url || providerBaseDefault, }); setKeysData(d => ({ ...d, providers: res.providers || d.providers, clients: res.clients || d.clients, })); setProviderForm(f => ({ ...f, id: "", api_key: "" })); notify(res.message || "Provider key saved", "success"); } catch (e) { notify("Save failed: " + (e.message || e), "error"); } finally { setProviderSaving(false); } }, [providerForm, providerBaseDefault, notify]); const renewProvider = useCallback((p) => { setProviderForm({ id: p.id, provider: p.provider, label: p.label, base_url: p.base_url || "", api_key: "", }); notify("Renewing " + p.label + " — paste the new API key", "info"); }, [notify]); const cancelRenew = useCallback(() => { setProviderForm({ id: "", provider: "openai", label: "OpenAI", base_url: "", api_key: "" }); }, []); const createClient = useCallback(async () => { setClientSaving(true); try { const res = await api.post("/ui/api/keys/client", clientForm); setKeysData(d => ({ ...d, providers: res.providers || d.providers, clients: res.clients || d.clients, })); setCreatedKey(res.api_key || ""); notify(res.message || "Client key created", "success"); } catch (e) { notify("Create failed: " + (e.message || e), "error"); } finally { setClientSaving(false); } }, [clientForm, notify]); const toggleKey = useCallback(async (kind, row) => { try { const res = await api.post("/ui/api/keys/toggle", { kind, id: row.id, enabled: !row.enabled }); setKeysData(d => ({ ...d, providers: res.providers || d.providers, clients: res.clients || d.clients, })); } catch (e) { notify("Update failed: " + (e.message || e), "error"); } }, [notify]); const providerCount = (keysData.providers || []).length; const clientCount = (keysData.clients || []).length; return (
Refresh } /> {/* Stat cards */}
{anthropicUrl}} sub={} /> {openaiUrl}} sub={} /> : String(providerCount)} sub="OpenAI · Google AI Studio" /> : String(clientCount)} sub="Local proxy authentication" />
{/* Add/create forms */}
{/* Add provider key */} {isRenewing && (
Renewing {providerForm.label} — paste the new API key below.
)}
{!isRenewing && ( setProviderForm(f => ({ ...f, id: "", provider: v, label: v === "openai" ? "OpenAI" : "Google AI Studio", base_url: "" }))} /> )} setProviderForm(f => ({ ...f, label: e.target.value }))} placeholder="My OpenAI key" /> setProviderForm(f => ({ ...f, base_url: e.target.value }))} placeholder={providerBaseDefault} /> setProviderForm(f => ({ ...f, api_key: e.target.value }))} placeholder="sk-…" autoComplete="off" />
{/* Create client key */}
setClientForm(f => ({ ...f, label: e.target.value }))} placeholder="Claude Code" /> setClientForm(f => ({ ...f, provider: e.target.value, provider_key_id: "" }))} options={[ { value: "default", label: "Default proxy route" }, { value: "openai", label: "OpenAI provider key" }, { value: "gemini", label: "Google AI Studio key" }, ]} /> {clientForm.provider !== "default" && (