feat(proxy): add proxy pool and per-connection binding + strictProxy support

- Centralize proxy management with reusable proxy pools
- Per-connection proxy binding with legacy fallback
- Add strictProxy option: fail hard instead of silently falling back to direct
- Resolve alicode-intl conflict: keep alicode-intl support + proxy support

Made-with: Cursor
This commit is contained in:
decolua committed 2026-03-09 15:46:06 +07:00
1 parent 4c469291a1
commit 880f4eca91
22 files changed
+1811 -146

No files matched your search

@@ -17,11 +17,13 @@ export default function ProviderDetailPage() {
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [providerNode, setProviderNode] = useState(null);
const [proxyPools, setProxyPools] = useState([]);
const [showOAuthModal, setShowOAuthModal] = useState(false);
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showEditNodeModal, setShowEditNodeModal] = useState(false);
const [showBulkProxyModal, setShowBulkProxyModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [modelAliases, setModelAliases] = useState({});
const [headerImgError, setHeaderImgError] = useState(false);
@@ -29,6 +31,9 @@ export default function ProviderDetailPage() {
const [modelsTestError, setModelsTestError] = useState("");
const [testingModelId, setTestingModelId] = useState(null);
const [showAddCustomModel, setShowAddCustomModel] = useState(false);
const [selectedConnectionIds, setSelectedConnectionIds] = useState([]);
const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__");
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
const { copied, copy } = useCopyToClipboard();
const providerInfo = providerNode
@@ -70,16 +75,21 @@ export default function ProviderDetailPage() {
const fetchConnections = useCallback(async () => {
try {
const [connectionsRes, nodesRes] = await Promise.all([
const [connectionsRes, nodesRes, proxyPoolsRes] = await Promise.all([
fetch("/api/providers", { cache: "no-store" }),
fetch("/api/provider-nodes", { cache: "no-store" }),
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" }),
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
const proxyPoolsData = await proxyPoolsRes.json();
if (connectionsRes.ok) {
const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId);
setConnections(filtered);
}
if (proxyPoolsRes.ok) {
setProxyPools(proxyPoolsData.proxyPools || []);
}
if (nodesRes.ok) {
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
@@ -266,6 +276,201 @@ export default function ProviderDetailPage() {
}
};
const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
const allSelected = connections.length > 0 && selectedConnectionIds.length === connections.length;
const toggleSelectConnection = (connectionId) => {
setSelectedConnectionIds((prev) => (
prev.includes(connectionId)
? prev.filter((id) => id !== connectionId)
: [...prev, connectionId]
));
};
const toggleSelectAllConnections = () => {
if (allSelected) {
setSelectedConnectionIds([]);
return;
}
setSelectedConnectionIds(connections.map((conn) => conn.id));
};
const clearSelection = () => {
setSelectedConnectionIds([]);
setBulkProxyPoolId("__none__");
};
useEffect(() => {
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
}, [connections]);
const selectedProxySummary = (() => {
if (selectedConnections.length === 0) return "";
const poolIds = new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"));
if (poolIds.size === 1) {
const onlyId = [...poolIds][0];
if (onlyId === "__none__") return "All selected currently unbound";
const pool = proxyPools.find((p) => p.id === onlyId);
return `All selected currently bound to ${pool?.name || onlyId}`;
}
return "Selected connections have mixed proxy bindings";
})();
const openBulkProxyModal = () => {
if (selectedConnections.length === 0) return;
const uniquePoolIds = [...new Set(selectedConnections.map((conn) => conn.providerSpecificData?.proxyPoolId || "__none__"))];
setBulkProxyPoolId(uniquePoolIds.length === 1 ? uniquePoolIds[0] : "__none__");
setShowBulkProxyModal(true);
};
const closeBulkProxyModal = () => {
if (bulkUpdatingProxy) return;
setShowBulkProxyModal(false);
};
const handleBulkApplyProxyPool = async () => {
if (selectedConnectionIds.length === 0) return;
const proxyPoolId = bulkProxyPoolId === "__none__" ? null : bulkProxyPoolId;
setBulkUpdatingProxy(true);
try {
const results = await Promise.all(
selectedConnectionIds.map(async (connectionId) => {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxyPoolId }),
});
return res.ok;
})
);
const failedCount = results.filter((ok) => !ok).length;
if (failedCount > 0) {
alert(`Updated with ${failedCount} failed request(s).`);
}
await fetchConnections();
clearSelection();
setShowBulkProxyModal(false);
} catch (error) {
console.log("Error applying bulk proxy pool:", error);
} finally {
setBulkUpdatingProxy(false);
}
};
const selectionToolbar = connections.length > 0 ? (
<div className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-3 mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleSelectAllConnections}
className="text-xs px-2 py-1 rounded bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-text-muted"
>
{allSelected ? "Unselect all" : "Select all"}
</button>
{selectedConnectionIds.length > 0 && (
<button
type="button"
onClick={clearSelection}
className="text-xs px-2 py-1 rounded bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-text-muted"
>
Clear
</button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">{selectedConnectionIds.length} selected</span>
<Button size="sm" variant="secondary" onClick={openBulkProxyModal} disabled={selectedConnectionIds.length === 0}>
Proxy Action
</Button>
</div>
</div>
) : null;
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
const connectionsList = (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<div key={conn.id} className="flex items-stretch">
<div className="pr-2 flex items-center">
<input
type="checkbox"
checked={isSelected(conn.id)}
onChange={() => toggleSelectConnection(conn.id)}
className="size-4 rounded border-border bg-transparent"
title="Select connection"
aria-label={`Select ${conn.name || conn.email || conn.id}`}
/>
</div>
<div className="flex-1 min-w-0">
<ConnectionRow
connection={conn}
proxyPools={proxyPools}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
/>
</div>
</div>
))}
</div>
);
const bulkProxyOptions = [
{ value: "__none__", label: "None" },
...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
];
const bulkHint = selectedConnectionIds.length === 0
? "Select one or more connections, then click Proxy Action."
: selectedProxySummary;
const canApplyBulkProxy = selectedConnectionIds.length > 0 && !bulkUpdatingProxy;
const bulkActionModal = (
<Modal
isOpen={showBulkProxyModal}
onClose={closeBulkProxyModal}
title={`Proxy Action (${selectedConnectionIds.length} selected)`}
>
<div className="flex flex-col gap-4">
<Select
label="Proxy Pool"
value={bulkProxyPoolId}
onChange={(e) => setBulkProxyPoolId(e.target.value)}
options={bulkProxyOptions}
placeholder="None"
/>
<p className="text-xs text-text-muted">{bulkHint}</p>
<p className="text-xs text-text-muted">Selecting None will unbind selected connections from proxy pool.</p>
<div className="flex gap-2">
<Button onClick={handleBulkApplyProxyPool} fullWidth disabled={!canApplyBulkProxy}>
{bulkUpdatingProxy ? "Applying..." : "Apply"}
</Button>
<Button onClick={closeBulkProxyModal} variant="ghost" fullWidth disabled={bulkUpdatingProxy}>
Cancel
</Button>
</div>
</div>
</Modal>
);
const handleTestModel = async (modelId) => {
if (testingModelId) return;
setTestingModelId(modelId);
@@ -560,27 +765,10 @@ export default function ProviderDetailPage() {
)}
</div>
) : (
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
/>
))}
</div>
<>
{selectionToolbar}
{connectionsList}
</>
)}
</Card>
@@ -597,6 +785,8 @@ export default function ProviderDetailPage() {
{renderModelsSection()}
</Card>
{bulkActionModal}
{/* Modals */}
{providerId === "kiro" ? (
<KiroOAuthWrapper
@@ -633,12 +823,14 @@ export default function ProviderDetailPage() {
providerName={providerInfo.name}
isCompatible={isCompatible}
isAnthropic={isAnthropicCompatible}
proxyPools={proxyPools}
onSave={handleSaveApiKey}
onClose={() => setShowAddApiKeyModal(false)}
/>
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
proxyPools={proxyPools}
onSave={handleUpdateConnection}
onClose={() => setShowEditModal(false)}
/>
@@ -1126,7 +1318,40 @@ CooldownTimer.propTypes = {
until: PropTypes.string.isRequired,
};
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl;
const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy;
const proxyDisplayText = boundProxyPool
? `Pool: ${boundProxyPool.name}`
: boundProxyPoolId
? `Pool: ${boundProxyPoolId} (inactive/missing)`
: hasLegacyProxy
? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`
: "";
let proxyBadgeVariant = "default";
if (boundProxyPool?.isActive === true) {
proxyBadgeVariant = "success";
} else if (boundProxyPoolId || hasLegacyProxy) {
proxyBadgeVariant = "error";
}
let maskedProxyUrl = "";
if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) {
const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl;
try {
const parsed = new URL(rawProxyUrl);
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
} catch {
maskedProxyUrl = rawProxyUrl;
}
}
const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || "";
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || "OAuth Account"
: connection.name;
@@ -1199,6 +1424,11 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
<Badge variant={getStatusVariant()} size="sm" dot>
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
</Badge>
{hasAnyProxy && (
<Badge variant={proxyBadgeVariant} size="sm">
Proxy
</Badge>
)}
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
{connection.lastError && connection.isActive !== false && (
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
@@ -1210,6 +1440,23 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
)}
</div>
{hasAnyProxy && (
<div className="mt-1 flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-text-muted truncate max-w-[420px]" title={proxyDisplayText}>
{proxyDisplayText}
</span>
{maskedProxyUrl && (
<code className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded text-text-muted">
{maskedProxyUrl}
</code>
)}
{noProxyText && (
<span className="text-[11px] text-text-muted truncate max-w-[320px]" title={noProxyText}>
no_proxy: {noProxyText}
</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
@@ -1245,6 +1492,13 @@ ConnectionRow.propTypes = {
priority: PropTypes.number,
globalPriority: PropTypes.number,
}).isRequired,
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
proxyUrl: PropTypes.string,
noProxy: PropTypes.string,
isActive: PropTypes.bool,
})),
isOAuth: PropTypes.bool.isRequired,
isFirst: PropTypes.bool.isRequired,
isLast: PropTypes.bool.isRequired,
@@ -1255,11 +1509,14 @@ ConnectionRow.propTypes = {
onDelete: PropTypes.func.isRequired,
};
function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, onSave, onClose }) {
function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, proxyPools, onSave, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const [formData, setFormData] = useState({
name: "",
apiKey: "",
priority: 1,
proxyPoolId: NONE_PROXY_POOL_VALUE,
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
@@ -1309,6 +1566,7 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
name: formData.name,
apiKey: formData.apiKey,
priority: formData.priority,
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
testStatus: isValid ? "active" : "unknown",
});
} finally {
@@ -1360,6 +1618,28 @@ function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthro
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
/>
<Select
label="Proxy Pool"
value={formData.proxyPoolId}
onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None" },
...(proxyPools || []).map((pool) => ({ value: pool.id, label: pool.name })),
]}
placeholder="None"
/>
{(proxyPools || []).length === 0 && (
<p className="text-xs text-text-muted">
No active proxy pools available. Create one in Proxy Pools page first.
</p>
)}
<p className="text-xs text-text-muted">
Legacy manual proxy fields are still accepted by API for backward compatibility.
</p>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey || saving}>
{saving ? "Saving..." : "Save"}
@@ -1379,15 +1659,22 @@ AddApiKeyModal.propTypes = {
providerName: PropTypes.string,
isCompatible: PropTypes.bool,
isAnthropic: PropTypes.bool,
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
})),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const [formData, setFormData] = useState({
name: "",
priority: 1,
apiKey: "",
proxyPoolId: NONE_PROXY_POOL_VALUE,
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
@@ -1401,6 +1688,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
name: connection.name || "",
priority: connection.priority || 1,
apiKey: "",
proxyPoolId: connection.providerSpecificData?.proxyPoolId || NONE_PROXY_POOL_VALUE,
});
setTestResult(null);
setValidationResult(null);
@@ -1444,7 +1732,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const handleSubmit = async () => {
setSaving(true);
try {
const updates = { name: formData.name, priority: formData.priority };
const updates = {
name: formData.name,
priority: formData.priority,
proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId,
};
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let isValid = validationResult === "success";
@@ -1504,6 +1796,59 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
/>
<Select
label="Proxy Pool"
value={formData.proxyPoolId}
onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
options={[
{ value: NONE_PROXY_POOL_VALUE, label: "None" },
...(proxyPools || []).map((pool) => ({ value: pool.id, label: pool.name })),
]}
placeholder="None"
/>
{(proxyPools || []).length === 0 && (
<p className="text-xs text-text-muted">
No active proxy pools available. Create one in Proxy Pools page first.
</p>
)}
<p className="text-xs text-text-muted">
Runtime prefers proxy pool settings. Legacy proxy fields are still supported as fallback.
</p>
{connection.providerSpecificData?.connectionProxyEnabled === true && !connection.providerSpecificData?.proxyPoolId && (
<p className="text-xs text-amber-500">
This connection is still using legacy manual proxy settings until you bind a proxy pool.
</p>
)}
{connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId === NONE_PROXY_POOL_VALUE && (
<p className="text-xs text-amber-500">
Saving with None will unbind this connection from proxy pool and fallback to legacy/global proxy behavior.
</p>
)}
{connection.providerSpecificData?.proxyPoolId && formData.proxyPoolId !== NONE_PROXY_POOL_VALUE && connection.providerSpecificData?.proxyPoolId !== formData.proxyPoolId && (
<p className="text-xs text-amber-500">
You changed proxy pool binding. Use Test Connection to verify connectivity.
</p>
)}
{connection.providerSpecificData?.proxyPoolId && !(proxyPools || []).some((pool) => pool.id === connection.providerSpecificData.proxyPoolId) && (
<p className="text-xs text-red-500">
Current bound proxy pool is inactive or missing. Runtime will fallback to legacy proxy if available.
</p>
)}
{!connection.providerSpecificData?.proxyPoolId && (connection.providerSpecificData?.connectionProxyUrl || connection.providerSpecificData?.connectionNoProxy) && (
<p className="text-xs text-text-muted">
Legacy proxy: {connection.providerSpecificData?.connectionProxyUrl || "(empty)"}
{connection.providerSpecificData?.connectionNoProxy ? ` · no_proxy: ${connection.providerSpecificData.connectionNoProxy}` : ""}
</p>
)}
{!isOAuth && (
<>
<div className="flex gap-2">
@@ -1562,7 +1907,12 @@ EditConnectionModal.propTypes = {
priority: PropTypes.number,
authType: PropTypes.string,
provider: PropTypes.string,
providerSpecificData: PropTypes.object,
}),
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
})),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};