// 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 }
Reset
Save
);
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
{/* 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
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
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 }
Reset to defaults
Save configuration
);
}
/* ─────────────────────────────────────────────────────────────────
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 (
}
/>
{/* Toolbar */}
{/* Table */}
{loading ? (
) : (
{CODEX_MODEL_OPTIONS.map(m => )}
{AGY_MODEL_OPTIONS.map(m => )}
{CLAUDE_MODEL_OPTIONS.map(m => )}
{filtered.length === 0 ? (
) : (
Public alias
Upstream model
Forwarded to
Context
Status
Notes
{filtered.map(m => {
const invalid = invalidIds.has(m.id);
return (
→
updateModel(m.id, "real", e.target.value)}
placeholder={m.forward_to === "agy" ? "Gemini 3.1 Pro (High)" : "gpt-5.5"}
/>
updateModel(m.id, "forward_to", e.target.value)}
options={FORWARD_OPTIONS}
className="models-forward-select"
/>
updateModel(m.id, "context", e.target.value)}
options={[
{ value: "200k", label: "200k" },
{ value: "1m", label: "1M" },
]}
className="models-context-select"
/>
{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={
setDeleteConfirm(null)}>Cancel
Delete
}
/>
);
}
/* ─────────────────────────────────────────────────────────────────
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.
Cancel
)}
{!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"
/>
{isRenewing ? "Renew provider key" : "Save provider key"}
{/* Create client key */}
setClientForm(f => ({ ...f, label: e.target.value }))}
placeholder="Claude Code"
/>
setClientForm(f => ({ ...f, schema: e.target.value }))}
options={[
{ value: "both", label: "Both schemas" },
{ value: "anthropic", label: "Anthropic only" },
{ value: "openai", label: "OpenAI-compatible only" },
]}
/>
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" && (
setClientForm(f => ({ ...f, provider_key_id: e.target.value }))}
options={[
{ value: "", label: "Use first enabled " + clientForm.provider + " key" },
...selectedProviderKeys.map(p => ({ value: p.id, label: p.label + " · " + (p.key_preview || "") })),
]}
/>
)}
Create client key
{/* Provider keys table */}
{loading ? (
) : (keysData.providers || []).length === 0 ? (
) : (
Provider
Label
Base URL
Key
Status
Enabled
{(keysData.providers || []).map(p => (
{p.provider}
{p.label}
{p.base_url}
{p.key_preview}
{p.enabled ? "Enabled" : "Disabled"}
toggleKey("provider", p)}
/>
renewProvider(p)}>
Renew
))}
)}
{/* Client keys table */}
{loading ? (
) : (keysData.clients || []).length === 0 ? (
) : (
Label
Key
Schema
Route
Status
Enabled
{(keysData.clients || []).map(k => (
{k.label}
{k.key_preview}
{k.schema || "both"}
{k.provider
? k.provider + (k.provider_label ? " · " + k.provider_label : "")
: "default"}
{k.enabled ? "Enabled" : "Disabled"}
toggleKey("client", k)}
/>
))}
)}
{/* Created key modal */}
setCreatedKey("")}
title="Save your new client key"
description="This key will not be shown again. Copy it now and store it somewhere safe."
width={520}
footer={
setCreatedKey("")}>Done
}
>
);
}
/* ─────────────────────────────────────────────────────────────────
VIRUSTOTAL
───────────────────────────────────────────────────────────────── */
function VirusTotal({ 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 [enabled, setEnabled] = useState(true);
const [keysText, setKeysText] = useState("");
const [statuses, setStatuses] = useState([]);
const [meta, setMeta] = useState({ threshold: 1, upload: true, fail_closed: false });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const load = useCallback(() => {
setLoading(true);
api.get("/ui/api/virustotal").then(res => {
setEnabled(!!res.enabled);
setKeysText(res.keys_raw || "");
setStatuses(res.statuses || []);
setMeta({ threshold: res.threshold, upload: res.upload, fail_closed: res.fail_closed });
setDirty(false);
}).catch(e => notify("Failed to load VirusTotal settings: " + (e.message || e), "error"))
.finally(() => setLoading(false));
}, [notify]);
useEffect(() => { load(); }, []);
const keyCount = keysText.split(/[\n,;]+/).map(s => s.trim()).filter(Boolean).length;
const save = useCallback(async () => {
setSaving(true);
try {
const res = await api.post("/ui/api/virustotal", { enabled, keys: keysText });
setStatuses(res.statuses || []);
setDirty(false);
notify(res.message || "VirusTotal settings saved", "success");
} catch (e) {
notify("Save failed: " + (e.message || e), "error");
} finally { setSaving(false); }
}, [enabled, keysText, notify]);
if (loading) {
return (
);
}
return (
{dirty && Unsaved }
Refresh
Save
}
/>
Enable scanning
PROXY_VIRUSTOTAL_ENABLED
{ setEnabled(v); setDirty(true); }} />
Every attached file is checked against VirusTotal before agy reads it. A file flagged
malicious is deleted and the request is rejected — agy is never run on it. Verdicts are
cached locally by file hash, so re-uploading the same file skips VirusTotal.
{statuses.length === 0 ? (
) : (
Key Status Available again
{statuses.map((s, i) => (
{s.masked}
{s.status === "active" ? "Active" : "Cooling down"}
{s.cooling_until ? new Date(s.cooling_until).toLocaleString() : "—"}
))}
)}
);
}
/* ─────────────────────────────────────────────────────────────────
Exports
───────────────────────────────────────────────────────────────── */
window.Configuration = Configuration;
window.Models = Models;
window.VirusTotal = VirusTotal;
window.ApiKeys = ApiKeys;
})();