merge: pull mhiqrambg/9router-mibp-version into master
Merge the MIBP fork (v1.0.14, synced to decolua v0.5.81) into our master
(0.5.86) at merge-base a8c9d380. Keep HEAD's infra policy (untracked
lockfile, mirror-configurable Dockerfile, decolua GHCR/DockerHub, README)
while absorbing the fork's engine features:
- feat(providers): freebuff provider + executor + OAuth + usage tracking
- feat(providers): cline free-tier models, Freebuff catalog sync
- feat(proxy-pools): pool egress geo probe, proxy-pool fitness + retry
- fix(usage): hide noAuth providers (devin-cli, mimo-free) from usage list
- test(harness): DATA_DIR isolation so tests never write the real DB
- fix(codebuddy-intl): probe token in connection test, OAuth by identity
- chore(guards): durable markers so fixes aren't silently dropped
Resolutions:
- registry/index.js regenerated deterministically (122 providers, alpha
order). trae/windsurf/devin-cli stay hidden per HEAD security posture
(no tool-calling / local-agent shell access) — not re-enabled.
- nonStreamingHandler: drop the generic unconditional unwrapDataEnvelope
call; envelope unwrap stays scoped to clineEnvelope-quirk providers
(unwrapClineEnvelope), fixing a latent mibp bug where non-opted-in
providers ({success,data} bodies) were stripped.
- Drop fork-local Docker lockfile policy (package-lock.json, AGENTS.md,
.npmrc verify scripts): this repo keeps package-lock untracked (nix
build deploy). .npmrc (audit=false/fund=false) kept.
- Keep gitbook-pages workflow enabled (ours); mibp disabled it.
- Restore 13 upstream tests mibp deleted (they cover features we keep).
Verified: 2841 tests, 2687 pass, fail set byte-identical to HEAD (zero
new regressions); providers/alias/oauth baselines regenerated to merged
code and all green.
This commit is contained in:
@@ -10,6 +10,16 @@ import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config";
|
||||
import { LOCALE_FLAGS } from "@/shared/constants/locales";
|
||||
|
||||
function memoryLine(m) {
|
||||
if (!m) return "";
|
||||
const fb = m.inMemory?.freebuff || {};
|
||||
return [
|
||||
`DB: ${m.dbPath || "-"} (${m.dbSizeMB || "-"})`,
|
||||
`Data dir: ${m.dataDirSizeMB || "-"}`,
|
||||
`In-memory: fitness=${m.inMemory?.fitnessPools ?? 0} pool(s) · geo=${m.inMemory?.geoPools ?? 0} pool(s) · freebuff sessions=${fb.sessions ?? 0}, locks=${fb.modelLocks ?? 0}, pool-limits=${fb.poolLimits ?? 0}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function getLocaleFromCookie() {
|
||||
if (typeof document === "undefined") return "en";
|
||||
const cookie = document.cookie
|
||||
@@ -72,6 +82,8 @@ export default function ProfilePage() {
|
||||
const certFileRef = useRef(null);
|
||||
|
||||
const importFileRef = useRef(null);
|
||||
const [memoryInfo, setMemoryInfo] = useState(null);
|
||||
const [memoryLoading, setMemoryLoading] = useState(false);
|
||||
const [proxyForm, setProxyForm] = useState({
|
||||
outboundProxyEnabled: false,
|
||||
outboundProxyUrl: "",
|
||||
@@ -752,6 +764,19 @@ export default function ProfilePage() {
|
||||
setShutdownOpen(false);
|
||||
};
|
||||
|
||||
const handleMemoryCheck = async () => {
|
||||
setMemoryLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/system/memory", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setMemoryInfo(await res.json());
|
||||
} catch (err) {
|
||||
setMemoryInfo({ error: err.message });
|
||||
} finally {
|
||||
setMemoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/logout", { method: "POST" });
|
||||
@@ -825,6 +850,15 @@ export default function ProfilePage() {
|
||||
>
|
||||
Import Backup
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon="memory"
|
||||
onClick={handleMemoryCheck}
|
||||
loading={memoryLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Check Size Memory
|
||||
</Button>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
@@ -838,6 +872,13 @@ export default function ProfilePage() {
|
||||
{dbStatus.message}
|
||||
</p>
|
||||
)}
|
||||
{memoryInfo && (
|
||||
<pre className="text-[11px] font-mono text-text-muted whitespace-pre-wrap break-all">
|
||||
{memoryInfo.error
|
||||
? `Error: ${memoryInfo.error}`
|
||||
: memoryLine(memoryInfo)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1646,6 +1687,14 @@ export default function ProfilePage() {
|
||||
<div className="text-center text-xs sm:text-sm text-text-muted py-4">
|
||||
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>
|
||||
<p className="mt-1">{isRemoteHost ? "Remote Mode" : "Local Mode - All data stored on your machine"}</p>
|
||||
<a
|
||||
href="https://github.com/mhiqrambg/9router-mibp-version"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block mt-1 text-[11px] hover:text-primary transition-colors"
|
||||
>
|
||||
MIBP Edition
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,30 +6,76 @@ import PropTypes from "prop-types";
|
||||
import { Badge, Toggle, Tooltip } from "@/shared/components";
|
||||
import CooldownTimer from "./CooldownTimer";
|
||||
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null, modelAssignmentOptions = null, onModelAssignmentChange = null, strictModelAssignment = false }) {
|
||||
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||
const [selectedProxyIds, setSelectedProxyIds] = useState([]);
|
||||
const [rotationStrategy, setRotationStrategy] = useState("none");
|
||||
const proxyDropdownRef = useRef(null);
|
||||
|
||||
// Initialize proxy state from connection
|
||||
useEffect(() => {
|
||||
const proxyPoolIds = connection.providerSpecificData?.proxyPoolIds || [];
|
||||
const legacyProxyPoolId = connection.providerSpecificData?.proxyPoolId;
|
||||
|
||||
// Migrate legacy single proxy to array format
|
||||
queueMicrotask(() => {
|
||||
if (legacyProxyPoolId && proxyPoolIds.length === 0) {
|
||||
setSelectedProxyIds([legacyProxyPoolId]);
|
||||
} else {
|
||||
setSelectedProxyIds(proxyPoolIds);
|
||||
}
|
||||
setRotationStrategy(connection.providerSpecificData?.proxyRotationStrategy || "none");
|
||||
});
|
||||
}, [connection]);
|
||||
|
||||
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
|
||||
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
|
||||
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
|
||||
|
||||
// Display logic - support both new (multi-proxy) and legacy (single proxy) formats
|
||||
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}`
|
||||
: "";
|
||||
const hasAnyProxy = selectedProxyIds.length > 0 || hasLegacyProxy;
|
||||
|
||||
const getProxyDisplayText = () => {
|
||||
if (selectedProxyIds.length === 0 && !hasLegacyProxy) return "";
|
||||
|
||||
if (selectedProxyIds.length === 1) {
|
||||
const pool = proxyPoolMap.get(selectedProxyIds[0]);
|
||||
return pool ? `Pool: ${pool.name}` : `Pool: ${selectedProxyIds[0]} (inactive/missing)`;
|
||||
}
|
||||
|
||||
if (selectedProxyIds.length > 1) {
|
||||
const strategyLabel = rotationStrategy === "random" ? "Random" : rotationStrategy === "round-robin" ? "Round Robin" : rotationStrategy === "failover" ? "Failover" : rotationStrategy === "smart" ? "Smart" : "Multiple";
|
||||
return `${selectedProxyIds.length} pools (${strategyLabel})`;
|
||||
}
|
||||
|
||||
if (hasLegacyProxy) {
|
||||
return `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const proxyDisplayText = getProxyDisplayText();
|
||||
const autoPingTooltip = autoPing?.provider === "codex"
|
||||
? "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota."
|
||||
: "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away.";
|
||||
|
||||
let maskedProxyUrl = "";
|
||||
if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) {
|
||||
const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl;
|
||||
if (selectedProxyIds.length > 0) {
|
||||
const selectedPools = selectedProxyIds.map(id => proxyPoolMap.get(id)).filter(Boolean);
|
||||
if (selectedPools.length > 0) {
|
||||
try {
|
||||
const parsed = new URL(selectedPools[0].proxyUrl);
|
||||
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
|
||||
if (selectedPools.length > 1) {
|
||||
maskedProxyUrl += ` (+${selectedPools.length - 1} more)`;
|
||||
}
|
||||
} catch {
|
||||
maskedProxyUrl = selectedPools[0].proxyUrl;
|
||||
}
|
||||
}
|
||||
} else if (connection.providerSpecificData?.connectionProxyUrl) {
|
||||
const rawProxyUrl = connection.providerSpecificData?.connectionProxyUrl;
|
||||
try {
|
||||
const parsed = new URL(rawProxyUrl);
|
||||
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
|
||||
@@ -38,12 +84,15 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
}
|
||||
}
|
||||
|
||||
const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || "";
|
||||
const noProxyText = selectedProxyIds.length > 0
|
||||
? proxyPoolMap.get(selectedProxyIds[0])?.noProxy || ""
|
||||
: connection.providerSpecificData?.connectionNoProxy || "";
|
||||
|
||||
let proxyBadgeVariant = "default";
|
||||
if (boundProxyPool?.isActive === true) {
|
||||
proxyBadgeVariant = "success";
|
||||
} else if (boundProxyPoolId || hasLegacyProxy) {
|
||||
if (selectedProxyIds.length > 0) {
|
||||
const allActive = selectedProxyIds.every(id => proxyPoolMap.get(id)?.isActive === true);
|
||||
proxyBadgeVariant = allActive ? "success" : "error";
|
||||
} else if (hasLegacyProxy) {
|
||||
proxyBadgeVariant = "error";
|
||||
}
|
||||
|
||||
@@ -59,10 +108,54 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showProxyDropdown]);
|
||||
|
||||
const handleSelectProxy = async (poolId) => {
|
||||
const handleToggleProxySelection = (poolId) => {
|
||||
setSelectedProxyIds(prev => {
|
||||
if (prev.includes(poolId)) {
|
||||
return prev.filter(id => id !== poolId);
|
||||
} else {
|
||||
return [...prev, poolId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrategyChange = (strategy) => {
|
||||
setRotationStrategy(strategy);
|
||||
|
||||
// Auto-select all active proxy pools when switching to rotation strategies
|
||||
if (strategy !== "none" && selectedProxyIds.length === 0) {
|
||||
const activePoolIds = (proxyPools || [])
|
||||
.filter(pool => pool.isActive === true)
|
||||
.map(pool => pool.id);
|
||||
setSelectedProxyIds(activePoolIds);
|
||||
}
|
||||
|
||||
// Reset to single proxy when switching to "none"
|
||||
if (strategy === "none" && selectedProxyIds.length > 1) {
|
||||
setSelectedProxyIds(selectedProxyIds.slice(0, 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyProxyChanges = async () => {
|
||||
setUpdatingProxy(true);
|
||||
try {
|
||||
await onUpdateProxy(poolId === "__none__" ? null : poolId);
|
||||
await onUpdateProxy({
|
||||
proxyPoolIds: selectedProxyIds,
|
||||
proxyRotationStrategy: rotationStrategy,
|
||||
});
|
||||
setShowProxyDropdown(false);
|
||||
} finally {
|
||||
setUpdatingProxy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectProxy = async (poolId) => {
|
||||
// Legacy single-proxy mode (backwards compatibility)
|
||||
setUpdatingProxy(true);
|
||||
try {
|
||||
await onUpdateProxy({
|
||||
proxyPoolIds: poolId === "__none__" ? [] : [poolId],
|
||||
proxyRotationStrategy: "none",
|
||||
});
|
||||
} finally {
|
||||
setUpdatingProxy(false);
|
||||
setShowProxyDropdown(false);
|
||||
@@ -109,7 +202,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [modelLockUntil]);
|
||||
}, [connection, modelLockUntil]);
|
||||
|
||||
// Determine effective status (override unavailable if cooldown expired)
|
||||
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
|
||||
@@ -191,6 +284,20 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{modelAssignmentOptions && onModelAssignmentChange && (
|
||||
<select
|
||||
value={connection.providerSpecificData?.assignedModel || connection.providerSpecificData?.freebuffModel || ""}
|
||||
onChange={(e) => onModelAssignmentChange(e.target.value)}
|
||||
disabled={!strictModelAssignment}
|
||||
className="mt-2 max-w-full rounded-md border border-border bg-background px-2 py-1 text-[11px] text-text-main"
|
||||
title="Model assignment"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{modelAssignmentOptions.map((model) => (
|
||||
<option key={model.id} value={model.id}>{model.name || model.id}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{hasAnyProxy && (
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span className="max-w-full truncate text-[11px] text-text-muted sm:max-w-[420px]" title={proxyDisplayText}>
|
||||
@@ -226,22 +333,119 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
<span className="text-[10px] leading-tight">Proxy</span>
|
||||
</button>
|
||||
{showProxyDropdown && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 max-w-[78vw] min-w-[160px] rounded-lg border border-border bg-bg py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => handleSelectProxy("__none__")}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${!boundProxyPoolId ? "text-primary font-medium" : "text-text-main"}`}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
{(proxyPools || []).map((pool) => (
|
||||
<button
|
||||
key={pool.id}
|
||||
onClick={() => handleSelectProxy(pool.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${boundProxyPoolId === pool.id ? "text-primary font-medium" : "text-text-main"}`}
|
||||
<div className="absolute left-0 top-full z-50 mt-1 max-w-[calc(100vw-2rem)] min-w-[280px] rounded-lg border border-border bg-bg shadow-lg sm:left-auto sm:right-0">
|
||||
{/* Rotation Strategy Selector */}
|
||||
<div className="border-b border-border p-3">
|
||||
<label className="block text-xs font-medium text-text-muted mb-2">Rotation Strategy</label>
|
||||
<select
|
||||
value={rotationStrategy}
|
||||
onChange={(e) => handleStrategyChange(e.target.value)}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text-main focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pool.name}
|
||||
<option value="none">None (Single Proxy)</option>
|
||||
<option value="random">Random</option>
|
||||
<option value="round-robin">Round Robin</option>
|
||||
<option value="failover">Failover</option>
|
||||
<option value="smart">Smart</option>
|
||||
</select>
|
||||
{rotationStrategy !== "none" && (
|
||||
<p className="mt-1 text-[10px] text-text-muted">
|
||||
{rotationStrategy === "random" && "Randomly select proxy on each request"}
|
||||
{rotationStrategy === "round-robin" && "Rotate proxies in order across requests"}
|
||||
{rotationStrategy === "failover" && "Try next proxy on failure"}
|
||||
{rotationStrategy === "smart" && "Skip pools whose egress IP is blocked for this provider/model (e.g. Freebuff limited-IP). See Proxy Fitness to clear/block."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Proxy Pool Selection */}
|
||||
<div className="max-h-[200px] overflow-y-auto py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedProxyIds([]);
|
||||
setRotationStrategy("none");
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${selectedProxyIds.length === 0 ? "text-primary font-medium" : "text-text-main"}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{selectedProxyIds.length === 0 ? "check_box" : "check_box_outline_blank"}
|
||||
</span>
|
||||
<span>None</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Select All Button (only for rotation strategies) */}
|
||||
{rotationStrategy !== "none" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const activePoolIds = (proxyPools || [])
|
||||
.filter(pool => pool.isActive === true)
|
||||
.map(pool => pool.id);
|
||||
const allSelected = activePoolIds.length > 0 && activePoolIds.every(id => selectedProxyIds.includes(id));
|
||||
|
||||
if (allSelected) {
|
||||
setSelectedProxyIds([]);
|
||||
} else {
|
||||
setSelectedProxyIds(activePoolIds);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm border-b border-border hover:bg-black/5 dark:hover:bg-white/5 ${selectedProxyIds.length === (proxyPools || []).filter(p => p.isActive).length ? "bg-black/5 dark:bg-white/5" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{selectedProxyIds.length === (proxyPools || []).filter(p => p.isActive).length && selectedProxyIds.length > 0
|
||||
? "check_box"
|
||||
: "check_box_outline_blank"
|
||||
}
|
||||
</span>
|
||||
<span className="font-medium">Select All Active</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(proxyPools || []).map((pool) => {
|
||||
const isSelected = selectedProxyIds.includes(pool.id);
|
||||
const isActive = pool.isActive === true;
|
||||
return (
|
||||
<button
|
||||
key={pool.id}
|
||||
onClick={() => {
|
||||
if (rotationStrategy === "none") {
|
||||
setSelectedProxyIds([pool.id]);
|
||||
} else {
|
||||
handleToggleProxySelection(pool.id);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${isSelected ? "bg-black/5 dark:bg-white/5" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
{rotationStrategy === "none"
|
||||
? (isSelected ? "radio_button_checked" : "radio_button_unchecked")
|
||||
: (isSelected ? "check_box" : "check_box_outline_blank")
|
||||
}
|
||||
</span>
|
||||
<span className={isSelected ? "text-primary font-medium" : "text-text-main"}>{pool.name}</span>
|
||||
{!isActive && (
|
||||
<span className="ml-auto text-[10px] text-red-500">(inactive)</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Apply Button */}
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
onClick={handleApplyProxyChanges}
|
||||
disabled={updatingProxy}
|
||||
className="w-full rounded bg-primary px-3 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{updatingProxy ? "Applying..." : "Apply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -306,6 +510,9 @@ ConnectionRow.propTypes = {
|
||||
onUpdateProxy: PropTypes.func,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
modelAssignmentOptions: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string })),
|
||||
onModelAssignmentChange: PropTypes.func,
|
||||
strictModelAssignment: PropTypes.bool,
|
||||
oneByOneStatus: PropTypes.shape({
|
||||
state: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
|
||||
@@ -67,6 +67,7 @@ export default function ProviderDetailPage() {
|
||||
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
|
||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||
const [strictModelAssignment, setStrictModelAssignment] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
@@ -181,6 +182,21 @@ export default function ProviderDetailPage() {
|
||||
return levels && levels.includes(thinkingMode) ? thinkingMode : null;
|
||||
};
|
||||
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
||||
const assignmentModels = (() => {
|
||||
const byId = new Map();
|
||||
const add = (model) => {
|
||||
if (model?.id && !byId.has(model.id)) byId.set(model.id, model);
|
||||
};
|
||||
models.forEach(add);
|
||||
kiloFreeModels.forEach(add);
|
||||
customModels.forEach((model) => {
|
||||
if (model.providerAlias === providerStorageAlias && (model.kind || model.type || "llm") === "llm") {
|
||||
add(model);
|
||||
}
|
||||
});
|
||||
const disabled = new Set(disabledModelIds);
|
||||
return [...byId.values()].filter((model) => !disabled.has(model.id));
|
||||
})();
|
||||
// Union of levels across this provider's reasoning models — drives the level picker options.
|
||||
// Include custom models too (e.g. manually added gpt-5.6-sol → max).
|
||||
const providerThinkingLevels = (() => {
|
||||
@@ -324,6 +340,7 @@ export default function ProviderDetailPage() {
|
||||
const override = (settingsData.providerStrategies || {})[providerId] || {};
|
||||
setProviderStrategy(override.fallbackStrategy || null);
|
||||
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
|
||||
setStrictModelAssignment(override.strictModelAssignment === true);
|
||||
// Load per-provider thinking config
|
||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||
setThinkingMode(thinkingCfg.mode || "auto");
|
||||
@@ -379,9 +396,13 @@ export default function ProviderDetailPage() {
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
const current = settingsData.providerStrategies || {};
|
||||
|
||||
// Build override: null strategy means remove override, use global
|
||||
const override = {};
|
||||
// Preserve Freebuff-only settings while changing the shared strategy.
|
||||
const override = { ...(current[providerId] || {}) };
|
||||
if (strategy) override.fallbackStrategy = strategy;
|
||||
else {
|
||||
delete override.fallbackStrategy;
|
||||
delete override.stickyRoundRobinLimit;
|
||||
}
|
||||
if (strategy === "round-robin" && stickyLimit !== "") {
|
||||
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
|
||||
}
|
||||
@@ -403,6 +424,44 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStrictAssignmentToggle = async (enabled) => {
|
||||
setStrictModelAssignment(enabled);
|
||||
try {
|
||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
const current = settingsData.providerStrategies || {};
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerStrategies: {
|
||||
...current,
|
||||
[providerId]: { ...(current[providerId] || {}), strictModelAssignment: enabled },
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving Freebuff strict assignment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelAssignment = async (connectionId, assignedModel) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerSpecificData: { assignedModel: assignedModel || null } }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections((prev) => prev.map((c) => c.id === connectionId
|
||||
? { ...c, providerSpecificData: { ...(c.providerSpecificData || {}), assignedModel: assignedModel || null } }
|
||||
: c));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error saving Freebuff model assignment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoundRobinToggle = (enabled) => {
|
||||
const strategy = enabled ? "round-robin" : null;
|
||||
const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit;
|
||||
@@ -463,10 +522,12 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
fetchCustomModels();
|
||||
fetchDisabledModels();
|
||||
Promise.resolve().then(() => {
|
||||
fetchConnections();
|
||||
fetchAliases();
|
||||
fetchCustomModels();
|
||||
fetchDisabledModels();
|
||||
});
|
||||
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
|
||||
|
||||
// Live per-connection catalogs (cursor, zed): the static registry carries
|
||||
@@ -476,13 +537,13 @@ export default function ProviderDetailPage() {
|
||||
useEffect(() => {
|
||||
const isLiveCatalog = providerId === "cursor" || providerId === "zed";
|
||||
if (!isLiveCatalog) {
|
||||
setLiveModels([]);
|
||||
queueMicrotask(() => setLiveModels([]));
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = connections.find((item) => item.isActive !== false);
|
||||
if (!connection?.id) {
|
||||
setLiveModels([]);
|
||||
queueMicrotask(() => setLiveModels([]));
|
||||
if (providerId === "zed") setLiveModelsError(null);
|
||||
return;
|
||||
}
|
||||
@@ -942,7 +1003,9 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
|
||||
queueMicrotask(() => {
|
||||
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
|
||||
});
|
||||
}, [connections]);
|
||||
|
||||
const selectedProxySummary = (() => {
|
||||
@@ -1043,17 +1106,39 @@ export default function ProviderDetailPage() {
|
||||
onToggle: (on) => handleAutoPingConnection(conn.id, on),
|
||||
provider: providerId,
|
||||
} : null}
|
||||
onUpdateProxy={async (proxyPoolId) => {
|
||||
onUpdateProxy={async (proxyConfig) => {
|
||||
try {
|
||||
// Support both new format (object) and legacy format (string/null)
|
||||
const updatePayload = typeof proxyConfig === 'object' && proxyConfig !== null
|
||||
? {
|
||||
proxyPoolIds: proxyConfig.proxyPoolIds || [],
|
||||
proxyRotationStrategy: proxyConfig.proxyRotationStrategy || "none",
|
||||
}
|
||||
: {
|
||||
// Legacy single-proxy format
|
||||
proxyPoolId: proxyConfig || null,
|
||||
};
|
||||
|
||||
const res = await fetch(`/api/providers/${conn.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ proxyPoolId: proxyPoolId || null }),
|
||||
body: JSON.stringify(updatePayload),
|
||||
});
|
||||
if (res.ok) {
|
||||
setConnections(prev => prev.map(c =>
|
||||
c.id === conn.id
|
||||
? { ...c, providerSpecificData: { ...c.providerSpecificData, proxyPoolId: proxyPoolId || null } }
|
||||
? {
|
||||
...c,
|
||||
providerSpecificData: {
|
||||
...c.providerSpecificData,
|
||||
...(updatePayload.proxyPoolIds !== undefined ? {
|
||||
proxyPoolIds: updatePayload.proxyPoolIds,
|
||||
proxyRotationStrategy: updatePayload.proxyRotationStrategy,
|
||||
} : {
|
||||
proxyPoolId: updatePayload.proxyPoolId,
|
||||
})
|
||||
}
|
||||
}
|
||||
: c
|
||||
));
|
||||
}
|
||||
@@ -1067,6 +1152,9 @@ export default function ProviderDetailPage() {
|
||||
}}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
oneByOneStatus={oneByOneResults[conn.id] || null}
|
||||
modelAssignmentOptions={assignmentModels}
|
||||
onModelAssignmentChange={(model) => handleModelAssignment(conn.id, model)}
|
||||
strictModelAssignment={strictModelAssignment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1578,6 +1666,13 @@ export default function ProviderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-black/[0.03] pt-2 dark:border-white/[0.03]">
|
||||
<div>
|
||||
<span className="text-xs text-text-muted font-medium">Strict Model Assignment</span>
|
||||
<p className="text-[10px] text-text-muted">Only assigned accounts can serve each model for this provider.</p>
|
||||
</div>
|
||||
<Toggle checked={strictModelAssignment} onChange={handleStrictAssignmentToggle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Badge, Button, Card, CardSkeleton, Input, ConfirmModal, Toggle } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
function fmtTime(value) {
|
||||
if (!value) return "-";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? "-" : d.toLocaleTimeString();
|
||||
}
|
||||
|
||||
// "freebuff::openai/gpt-5.6-luna" -> { provider: "freebuff", model: "openai/gpt-5.6-luna" }
|
||||
// "freebuff::*" -> { provider: "freebuff", model: null }
|
||||
function parseScope(scope) {
|
||||
const sep = String(scope || "").indexOf("::");
|
||||
if (sep < 0) return { provider: String(scope || ""), model: null };
|
||||
const provider = scope.slice(0, sep);
|
||||
const model = scope.slice(sep + 2);
|
||||
return { provider, model: model === "*" || model === "" ? null : model };
|
||||
}
|
||||
|
||||
function maskProxyUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname || "";
|
||||
const port = parsed.port ? `:${parsed.port}` : "";
|
||||
return `${parsed.protocol}//${host}${port}`;
|
||||
} catch {
|
||||
return String(url || "");
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten fitness snapshot into one record per (pool, scope); drops expired marks.
|
||||
function buildRecords(fitness, pools, now = Date.now()) {
|
||||
const poolById = new Map((pools || []).map((p) => [p.id, p]));
|
||||
const records = [];
|
||||
for (const [poolId, byScope] of Object.entries(fitness || {})) {
|
||||
const pool = poolById.get(poolId);
|
||||
for (const [scope, info] of Object.entries(byScope || {})) {
|
||||
const until = info?.until ? new Date(info.until).getTime() : 0;
|
||||
if (!until || until <= now) continue;
|
||||
const { provider, model } = parseScope(scope);
|
||||
records.push({
|
||||
poolId,
|
||||
scope,
|
||||
provider,
|
||||
model,
|
||||
until,
|
||||
reason: info?.reason || "blocked",
|
||||
poolName: pool?.name || poolId.slice(0, 8),
|
||||
proxyUrl: pool?.proxyUrl || "",
|
||||
egress: pool?.egress || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
export default function ProxyFitnessPage() {
|
||||
const [pools, setPools] = useState([]);
|
||||
const [fitness, setFitness] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [providerFilter, setProviderFilter] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||
const [providerMenuDropUp, setProviderMenuDropUp] = useState(false);
|
||||
const [clearingScope, setClearingScope] = useState(null); // poolId::scope while clearing
|
||||
const [confirmClearAll, setConfirmClearAll] = useState(false);
|
||||
const [clearingAll, setClearingAll] = useState(false);
|
||||
const [geoEnabled, setGeoEnabled] = useState(true);
|
||||
const [geoUpdating, setGeoUpdating] = useState(false);
|
||||
const providerMenuRef = useRef(null);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (providerMenuRef.current && !providerMenuRef.current.contains(e.target)) {
|
||||
setProviderMenuOpen(false);
|
||||
}
|
||||
};
|
||||
if (providerMenuOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [providerMenuOpen]);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
try {
|
||||
const [poolRes, fitRes] = await Promise.all([
|
||||
fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" }),
|
||||
fetch("/api/proxy-pools/fitness", { cache: "no-store" }),
|
||||
]);
|
||||
const poolData = await poolRes.json().catch(() => ({ proxyPools: [] }));
|
||||
setPools(poolData.proxyPools || []);
|
||||
if (fitRes.ok) {
|
||||
const fitData = await fitRes.json().catch(() => ({}));
|
||||
setFitness(fitData.pools || fitData.fitness || {});
|
||||
} else {
|
||||
setFitness({});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching proxy fitness:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.then((s) => {
|
||||
if (typeof s.poolGeoProbeEnabled === "boolean") setGeoEnabled(s.poolGeoProbeEnabled);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleGeoToggle = async (next) => {
|
||||
setGeoUpdating(true);
|
||||
setGeoEnabled(next);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ poolGeoProbeEnabled: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setGeoEnabled(!next);
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
notify.success(next ? "Geo probe enabled" : "Geo probe disabled");
|
||||
} catch (err) {
|
||||
notify.error(`Update failed: ${err.message}`);
|
||||
} finally {
|
||||
setGeoUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const records = useMemo(() => buildRecords(fitness, pools), [fitness, pools]);
|
||||
|
||||
// Providers present in the fitness data (filter options)
|
||||
const providerOptions = useMemo(() => {
|
||||
const set = new Set();
|
||||
for (const rec of records) {
|
||||
if (rec.provider) set.add(rec.provider);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}, [records]);
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return records.filter((rec) => {
|
||||
if (providerFilter !== "all" && rec.provider !== providerFilter) return false;
|
||||
if (q) {
|
||||
const hay = [rec.proxyUrl, rec.egress?.ip, rec.egress?.country, rec.egress?.region, rec.poolName]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [records, providerFilter, search]);
|
||||
|
||||
const handleClear = async (rec) => {
|
||||
setClearingScope(rec.scope);
|
||||
try {
|
||||
const res = await fetch(`/api/proxy-pools/${rec.poolId}/fitness/clear`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scope: rec.scope }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
notify.success(`Cleared ${rec.scope}`);
|
||||
fetchAll();
|
||||
} catch (err) {
|
||||
notify.error(`Clear failed: ${err.message}`);
|
||||
} finally {
|
||||
setClearingScope(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
setClearingAll(true);
|
||||
try {
|
||||
const res = await fetch("/api/proxy-pools/fitness/clear-all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(providerFilter !== "all" ? { provider: providerFilter } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
notify.success(providerFilter !== "all" ? `Cleared all ${providerFilter} blocks` : "Cleared all blocks");
|
||||
setConfirmClearAll(false);
|
||||
fetchAll();
|
||||
} catch (err) {
|
||||
notify.error(`Clear all failed: ${err.message}`);
|
||||
} finally {
|
||||
setClearingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProviderLabel = providerFilter === "all" ? "All providers" : providerFilter;
|
||||
|
||||
// Open the provider menu as an overlay anchored to its container. Flips to
|
||||
// drop-up when there isn't enough viewport space below (menu max-h ~288px).
|
||||
const toggleProviderMenu = () => {
|
||||
if (providerMenuOpen) {
|
||||
setProviderMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
const el = providerMenuRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setProviderMenuDropUp(window.innerHeight - rect.bottom < 300);
|
||||
}
|
||||
setProviderMenuOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
||||
{loading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold text-text-main">Proxy Fitness</h1>
|
||||
<Badge variant={records.length > 0 ? "error" : "default"} size="sm">
|
||||
{records.length} active block{records.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{records.length > 0 && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="delete_sweep"
|
||||
onClick={() => setConfirmClearAll(true)}
|
||||
disabled={clearingAll}
|
||||
>
|
||||
Clear All{providerFilter !== "all" ? ` (${providerFilter})` : ""}
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={geoEnabled}
|
||||
onChange={handleGeoToggle}
|
||||
disabled={geoUpdating}
|
||||
label="Geo probe"
|
||||
description="Probe egress IP/country per pool every ~30 min (uses geo-API quota)"
|
||||
/>
|
||||
<Button variant="secondary" size="sm" icon="refresh" onClick={fetchAll}>Refresh</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-text-muted">
|
||||
Shows which provider is blocked on which proxy IP (from provider region gates, per-IP
|
||||
limits, or manual marks). Smart rotation skips these pools while the block is active.
|
||||
</p>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-border-subtle bg-surface p-3">
|
||||
<div className="relative flex w-64 max-w-full flex-col" ref={providerMenuRef}>
|
||||
<label className="mb-1 text-xs font-medium text-text-muted">Provider</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleProviderMenu}
|
||||
className="flex h-8 w-full items-center justify-between gap-1 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-main transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={providerMenuOpen}
|
||||
title="Filter by provider"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{providerFilter === "all" ? (
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">apps</span>
|
||||
) : (
|
||||
<ProviderIcon providerId={providerFilter} size={18} className="size-[18px] rounded object-contain" fallbackText={providerFilter.slice(0, 2).toUpperCase()} />
|
||||
)}
|
||||
<span className="truncate capitalize">{selectedProviderLabel}</span>
|
||||
</span>
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted">expand_more</span>
|
||||
</button>
|
||||
|
||||
{providerMenuOpen && (
|
||||
<div className={`absolute left-0 z-20 w-full max-h-72 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-1 shadow-lg ${providerMenuDropUp ? "bottom-full mb-1" : "top-full mt-1"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setProviderFilter("all"); setProviderMenuOpen(false); }}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === "all" ? "bg-primary/10 text-primary" : "text-text-main hover:bg-black/5 dark:hover:bg-white/10"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[22px]">apps</span>
|
||||
<span className="font-medium">All providers</span>
|
||||
{providerFilter === "all" && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
|
||||
</button>
|
||||
<div className="my-1 h-px bg-black/10 dark:bg-white/10" />
|
||||
{providerOptions.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => { setProviderFilter(provider); setProviderMenuOpen(false); }}
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === provider ? "bg-primary/10 text-primary" : "text-text-main hover:bg-black/5 dark:hover:bg-white/10"}`}
|
||||
>
|
||||
<ProviderIcon providerId={provider} size={24} className="size-6 rounded-md object-contain" fallbackText={provider.slice(0, 2).toUpperCase()} />
|
||||
<span className="font-medium capitalize">{provider}</span>
|
||||
{providerFilter === provider && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-48 flex-1 flex-col">
|
||||
<label className="mb-1 text-xs font-medium text-text-muted">IP / Proxy / Pool</label>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="e.g. 104.28, vercel-relay…"
|
||||
icon="search"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Records table */}
|
||||
<Card className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[700px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-subtle text-left text-xs uppercase tracking-wider text-text-muted">
|
||||
<th className="px-4 py-2 font-semibold">Provider</th>
|
||||
<th className="px-4 py-2 font-semibold">Model</th>
|
||||
<th className="px-4 py-2 font-semibold">IP / Proxy</th>
|
||||
<th className="px-4 py-2 font-semibold">Pool</th>
|
||||
<th className="px-4 py-2 font-semibold">Reason</th>
|
||||
<th className="px-4 py-2 font-semibold">Until</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRecords.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-text-muted">
|
||||
{records.length === 0
|
||||
? "No active blocks. Blocks appear here when a provider region-gates a proxy IP."
|
||||
: "No blocks match the current filters."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRecords.map((rec) => (
|
||||
<tr key={`${rec.poolId}::${rec.scope}`} className="border-b border-border-subtle last:border-0 align-top hover:bg-surface-2/40">
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-2 rounded bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400">
|
||||
<span className="material-symbols-outlined text-[14px]">block</span>
|
||||
<span className="capitalize">{rec.provider}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-56 px-4 py-3">
|
||||
<span className="truncate text-text-main">{rec.model || <em className="text-text-muted">all models</em>}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<code className="truncate font-mono text-xs text-text-main">{maskProxyUrl(rec.proxyUrl)}</code>
|
||||
{rec.egress?.ip && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-text-muted">
|
||||
<span className="truncate">egress {rec.egress.ip}{rec.egress.country ? ` · ${rec.egress.country}` : ""}</span>
|
||||
{rec.egress.isUnstable && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-0.5 rounded bg-amber-500/15 px-1 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||
title={rec.egress.ipCount >= 2 ? `Egress IP changed ${rec.egress.ipCount}× — relay egress is not stable` : "Egress is unstable"}
|
||||
>
|
||||
unstable
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-text-muted">{rec.poolName}</td>
|
||||
<td className="px-4 py-3 text-text-muted">{rec.reason}</td>
|
||||
<td className="px-4 py-3 text-text-muted">{fmtTime(rec.until)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="close"
|
||||
onClick={() => handleClear(rec)}
|
||||
disabled={clearingScope === rec.scope}
|
||||
title="Clear this block"
|
||||
>
|
||||
{clearingScope === rec.scope ? "Clearing..." : "Clear"}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={confirmClearAll}
|
||||
onClose={() => setConfirmClearAll(false)}
|
||||
onConfirm={handleClearAll}
|
||||
title="Clear all blocks"
|
||||
message={providerFilter !== "all"
|
||||
? `Clear all active blocks for provider "${providerFilter}"? This lets those pools be selected again immediately.`
|
||||
: "Clear all active proxy blocks? This lets all pools be selected again immediately."}
|
||||
confirmText={clearingAll ? "Clearing..." : "Clear All"}
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { formatResetTime, getRemainingPercentage } from "./utils";
|
||||
import { formatFreebucksPrice, formatResetTime, getRemainingPercentage } from "./utils";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -171,9 +171,20 @@ export default function QuotaTable({
|
||||
{/* Name */}
|
||||
<div className="flex w-36 min-w-0 items-center gap-1.5">
|
||||
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
||||
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
||||
{quota.name}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className={`${nameText} font-medium text-text-primary truncate`}>
|
||||
{quota.name}
|
||||
</div>
|
||||
{quota.price !== undefined && (
|
||||
<div
|
||||
className="text-[9px] leading-tight text-text-muted truncate"
|
||||
title={quota.priceNote || ""}
|
||||
>
|
||||
{formatFreebucksPrice(quota.price)}
|
||||
{quota.priceNote ? ` · ${quota.priceNote}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress + used/total */}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseQuotaData,
|
||||
calculatePercentage,
|
||||
filterQuotasByVisibility,
|
||||
formatFreebucksHeader,
|
||||
getHiddenQuotaRows,
|
||||
getQuotaVisibilityKey,
|
||||
getConnectionLabel,
|
||||
@@ -1263,6 +1264,11 @@ export default function ProviderLimits() {
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1.5">
|
||||
{quota?.raw?.freebucks && !error && !isLoading && (
|
||||
<div className="mb-1.5 rounded-md bg-black/[0.03] px-2 py-1.5 text-[10px] leading-relaxed text-text-muted dark:bg-white/[0.03]">
|
||||
{formatFreebucksHeader(quota.raw.freebucks)}
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-5 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[28px] animate-spin">
|
||||
|
||||
@@ -258,6 +258,49 @@ export function formatResetTime(date) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Freebucks account header line — mirrors upstream freebucksHeaderLine:
|
||||
* "10/25 Freebucks daily · resets in 4h 12m · 20 in wallet · $6.50 monthly usage left".
|
||||
* Wallet shown only when non-empty; monthly only when the server sent it
|
||||
* (older servers omit it — showing $0 would read as "nothing left").
|
||||
* @param {{balance: number|null, daily: {limit:number,spent:number,remaining:number,resetAt:string|null}, wallet:{balance:number}, monthly?:{remainingUsd:number,limitUsd:number|null,resetAt:string|null}}} freebucks
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function formatFreebucksHeader(freebucks) {
|
||||
if (!freebucks?.daily) return null;
|
||||
const parts = [
|
||||
`${Math.max(0, Math.round(freebucks.daily.remaining))}/${Math.max(0, Math.round(freebucks.daily.limit))} Freebucks daily`,
|
||||
];
|
||||
const countdown = formatResetTime(freebucks.daily.resetAt);
|
||||
if (countdown !== "-") parts.push(`resets in ${countdown}`);
|
||||
if (Number(freebucks.wallet?.balance) > 0) {
|
||||
parts.push(`${Math.max(0, Math.round(freebucks.wallet.balance))} in wallet`);
|
||||
}
|
||||
if (freebucks.monthly && Number.isFinite(Number(freebucks.monthly.remainingUsd))) {
|
||||
parts.push(`${formatFreebucksUsd(freebucks.monthly.remainingUsd)} monthly usage left`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
* "$25", "$4.20", "$0" — whole dollars until the figure is small enough that
|
||||
* the cents are the story. Mirrors upstream formatAllowanceUsd.
|
||||
*/
|
||||
export function formatFreebucksUsd(usd) {
|
||||
const safe = Math.max(0, Number(usd));
|
||||
if (safe >= 10) return `$${Math.round(safe)}`;
|
||||
if (safe >= 1) return `$${safe.toFixed(1).replace(/\.0$/, "")}`;
|
||||
return `$${safe.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "15 Freebucks/hr" — a bare number would read as dollars; the unit is the
|
||||
* hour, not the message.
|
||||
*/
|
||||
export function formatFreebucksPrice(price) {
|
||||
return `${Math.max(0, Math.round(Number(price) || 0))} Freebucks/hr`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tailwind color class based on percentage
|
||||
* @param {number} percentage - Remaining percentage (0-100)
|
||||
@@ -685,6 +728,27 @@ export function parseQuotaData(provider, data) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "freebuff":
|
||||
// Session quotas keyed by model id — label rows with the friendly
|
||||
// displayName (from the registry) and keep modelKey for ordering.
|
||||
// Metered rows carry the live Freebucks price (price) + promo tagline
|
||||
// (priceNote), both server-authoritative.
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([modelKey, quota]) => {
|
||||
normalizedQuotas.push({
|
||||
name: quota.displayName || modelKey,
|
||||
modelKey,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
recurring: quota.recurring !== false,
|
||||
price: quota.price,
|
||||
priceNote: quota.priceNote,
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "groq":
|
||||
// Requests/Tokens rate-limit windows from response headers — absolute
|
||||
// used/total (calculatePercentage derives the bar), like Codex/Kiro.
|
||||
|
||||
@@ -265,6 +265,7 @@ export async function GET(request, { params }) {
|
||||
"qoder",
|
||||
"qoder-cn",
|
||||
"grok-cli",
|
||||
"freebuff",
|
||||
];
|
||||
let deviceData;
|
||||
if (noPkceDeviceProviders.includes(provider)) {
|
||||
|
||||
@@ -471,7 +471,7 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
},
|
||||
"grok-cli": {
|
||||
customResolver: async (connection) => {
|
||||
const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||
const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}, connection.id);
|
||||
const result = await resolveGrokCliModels({
|
||||
...connection,
|
||||
connectionId: connection.id,
|
||||
|
||||
@@ -33,7 +33,31 @@ function normalizeProxyConfig(body = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeProxyPoolUpdate(proxyPoolIdInput) {
|
||||
async function normalizeProxyPoolUpdate(body) {
|
||||
// Handle new multi-proxy format
|
||||
if (body.proxyPoolIds !== undefined) {
|
||||
const proxyPoolIds = Array.isArray(body.proxyPoolIds) ? body.proxyPoolIds : [];
|
||||
const proxyRotationStrategy = body.proxyRotationStrategy || "none";
|
||||
|
||||
// Validate all proxy pool IDs exist
|
||||
for (const poolId of proxyPoolIds) {
|
||||
if (poolId) {
|
||||
const pool = await getProxyPoolById(poolId);
|
||||
if (!pool) {
|
||||
return { hasProxyPoolField: true, error: `Proxy pool ${poolId} not found` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasProxyPoolField: true,
|
||||
proxyPoolIds: proxyPoolIds.filter(Boolean),
|
||||
proxyRotationStrategy,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle legacy single proxy format
|
||||
const proxyPoolIdInput = body.proxyPoolId;
|
||||
if (proxyPoolIdInput === undefined) {
|
||||
return { hasProxyPoolField: false, proxyPoolId: null };
|
||||
}
|
||||
@@ -111,7 +135,7 @@ export async function PUT(request, { params }) {
|
||||
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxyPoolResult = await normalizeProxyPoolUpdate(body.proxyPoolId);
|
||||
const proxyPoolResult = await normalizeProxyPoolUpdate(body);
|
||||
if (proxyPoolResult.error) {
|
||||
return NextResponse.json({ error: proxyPoolResult.error }, { status: 400 });
|
||||
}
|
||||
@@ -147,10 +171,19 @@ export async function PUT(request, { params }) {
|
||||
}
|
||||
|
||||
if (proxyPoolResult.hasProxyPoolField) {
|
||||
if (proxyPoolResult.proxyPoolId === null) {
|
||||
// Handle new multi-proxy format
|
||||
if (proxyPoolResult.proxyPoolIds !== undefined) {
|
||||
updateData.providerSpecificData.proxyPoolIds = proxyPoolResult.proxyPoolIds;
|
||||
updateData.providerSpecificData.proxyRotationStrategy = proxyPoolResult.proxyRotationStrategy;
|
||||
// Clear legacy field
|
||||
delete updateData.providerSpecificData.proxyPoolId;
|
||||
} else {
|
||||
updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId;
|
||||
// Handle legacy single-proxy format
|
||||
if (proxyPoolResult.proxyPoolId === null) {
|
||||
delete updateData.providerSpecificData.proxyPoolId;
|
||||
} else {
|
||||
updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
KIMCHI_CONFIG,
|
||||
} from "@/lib/oauth/constants/oauth";
|
||||
import { buildClineHeaders } from "@/shared/utils/clineAuth";
|
||||
import { decodeJwtPayload } from "@/lib/oauth/providerHelpers";
|
||||
|
||||
// OAuth provider test endpoints
|
||||
const OAUTH_TEST_CONFIG = {
|
||||
@@ -100,6 +101,26 @@ const OAUTH_TEST_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
},
|
||||
"codebuddy-cn": { tokenExists: true },
|
||||
// GUARD — DO NOT REMOVE. See AGENTS.md §4. Without this entry, Test Connection
|
||||
// returns "Provider test not supported". codebuddy-intl access tokens are
|
||||
// Keycloak JWTs (iss .../auth/realms/copilot); probe the realm's userinfo
|
||||
// endpoint so a revoked/expired token is caught. Derive the realm URL from the
|
||||
// token's `iss` claim, falling back to the known copilot realm.
|
||||
// 200 = valid, 401 = invalid/revoked.
|
||||
// Covered by tests/unit/codebuddy-intl-connection.test.js.
|
||||
"codebuddy-intl": {
|
||||
buildUrl: (token) => {
|
||||
const iss = decodeJwtPayload(token)?.iss;
|
||||
const base = typeof iss === "string" && iss.startsWith("https://")
|
||||
? iss.replace(/\/$/, "")
|
||||
: "https://www.codebuddy.ai/auth/realms/copilot";
|
||||
return `${base}/protocol/openid-connect/userinfo`;
|
||||
},
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
refreshable: true,
|
||||
},
|
||||
kimchi: {
|
||||
url: KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers",
|
||||
method: "GET",
|
||||
@@ -111,9 +132,24 @@ const OAUTH_TEST_CONFIG = {
|
||||
},
|
||||
refreshable: false,
|
||||
},
|
||||
freebuff: {
|
||||
// The session endpoint doubles as the auth probe: GET never claims a
|
||||
// session (POST would burn 1.0 unit of the daily quota). Mirrors the usage
|
||||
// handler: 401 = bad token, 403 = region/account gate (token still valid),
|
||||
// 404 = no session row yet (pre-join, token valid). No refresh path —
|
||||
// when the authToken dies the user re-logs in.
|
||||
url: "https://www.codebuff.com/api/v1/freebuff/session",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: { Accept: "application/json", "User-Agent": "codebuff-cli/0.0.138" },
|
||||
acceptStatuses: [403, 404],
|
||||
softFailMessage: {
|
||||
403: "Connected, but Freebuff is gated (403) — country blocked or account banned.",
|
||||
},
|
||||
},
|
||||
// Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
|
||||
"grok-cli": {
|
||||
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
|
||||
"grok-cli": { url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
@@ -247,7 +283,7 @@ async function refreshOAuthToken(connection) {
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
|
||||
}
|
||||
|
||||
if (provider === "codex" || provider === "grok-cli" || provider === "xai") {
|
||||
if (provider === "codex" || provider === "grok-cli" || provider === "xai" || provider === "codebuddy-intl") {
|
||||
return await refreshProviderCredentials(provider, connection, console);
|
||||
}
|
||||
|
||||
@@ -849,7 +885,7 @@ export async function testSingleConnection(id) {
|
||||
const connection = await getProviderConnectionById(id);
|
||||
if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() };
|
||||
|
||||
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}, connection.id);
|
||||
|
||||
if (effectiveProxy.connectionProxyEnabled && effectiveProxy.connectionProxyUrl && !effectiveProxy.vercelRelayUrl) {
|
||||
const proxyResult = await testProxyUrl({ proxyUrl: effectiveProxy.connectionProxyUrl });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections } from "@/lib/localDb";
|
||||
import { backfillCodexEmails } from "@/lib/oauth/providers";
|
||||
import { backfillCodexEmails, backfillCodeBuddyIntlIdentity } from "@/lib/oauth/providers";
|
||||
import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
const SAFE_FIELDS = [
|
||||
@@ -77,6 +77,7 @@ function sortConnections(connections, sort) {
|
||||
export async function GET(request) {
|
||||
try {
|
||||
await backfillCodexEmails();
|
||||
await backfillCodeBuddyIntlIdentity();
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const provider = searchParams.get("provider") || "all";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { AI_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
|
||||
import { normalizeProviderId, normalizeProviderSpecificData } from "@/lib/providerNormalization";
|
||||
import { backfillCodeBuddyIntlIdentity } from "@/lib/oauth/providers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -49,6 +50,9 @@ async function normalizeProxyPoolId(proxyPoolId) {
|
||||
// GET /api/providers - List all connections
|
||||
export async function GET() {
|
||||
try {
|
||||
// Self-heal legacy CodeBuddy Intl OAuth rows that predate identity capture
|
||||
// (they show as "Account N" with no email). Runs once per process.
|
||||
await backfillCodeBuddyIntlIdentity();
|
||||
const connections = await getProviderConnections();
|
||||
|
||||
// Build nodeNameMap for compatible providers (id → name)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearPoolUnfit, ensurePoolFitnessHydrated } from "open-sse/services/proxyPoolFitness.js";
|
||||
|
||||
// POST /api/proxy-pools/[id]/fitness/clear
|
||||
// Body: { scope: "provider::model" } — clears the mark for this pool + scope.
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
await ensurePoolFitnessHydrated();
|
||||
const { id } = await params;
|
||||
let body = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const scope = typeof body?.scope === "string" ? body.scope.trim() : "";
|
||||
if (!id || !scope) {
|
||||
return NextResponse.json({ error: "pool id and scope are required" }, { status: 400 });
|
||||
}
|
||||
clearPoolUnfit(id, scope);
|
||||
return NextResponse.json({ ok: true, poolId: id, scope });
|
||||
} catch (error) {
|
||||
console.log("Error clearing pool fitness:", error);
|
||||
return NextResponse.json({ error: "Failed to clear pool fitness" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearAllPoolUnfit, ensurePoolFitnessHydrated } from "open-sse/services/proxyPoolFitness.js";
|
||||
|
||||
// POST /api/proxy-pools/fitness/clear-all
|
||||
// Body: { provider?: string } — clears every mark, or only marks scoped to the
|
||||
// given provider ("provider::*") when provided.
|
||||
export async function POST(request) {
|
||||
try {
|
||||
await ensurePoolFitnessHydrated();
|
||||
let body = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const provider = typeof body?.provider === "string" && body.provider.trim() ? body.provider.trim() : null;
|
||||
clearAllPoolUnfit(provider);
|
||||
return NextResponse.json({ ok: true, provider: provider || null });
|
||||
} catch (error) {
|
||||
console.log("Error clearing proxy fitness:", error);
|
||||
return NextResponse.json({ error: "Failed to clear proxy fitness" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ensurePoolFitnessHydrated, poolFitnessSnapshot } from "open-sse/services/proxyPoolFitness.js";
|
||||
|
||||
// GET /api/proxy-pools/fitness — in-memory snapshot of pool fitness marks.
|
||||
// Returns { pools: { [poolId]: { [scope]: { until, reason } } } }.
|
||||
export async function GET() {
|
||||
try {
|
||||
await ensurePoolFitnessHydrated();
|
||||
return NextResponse.json({ pools: poolFitnessSnapshot() });
|
||||
} catch (error) {
|
||||
console.log("Error reading proxy fitness:", error);
|
||||
return NextResponse.json({ error: "Failed to read proxy fitness" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createProxyPool, getProviderConnections, getProxyPools } from "@/models";
|
||||
import { getPoolGeo } from "open-sse/services/poolGeo.js";
|
||||
|
||||
function toBoolean(value) {
|
||||
if (value === "true") return true;
|
||||
@@ -65,6 +66,8 @@ export async function GET(request) {
|
||||
const enrichedProxyPools = proxyPools.map((pool) => ({
|
||||
...pool,
|
||||
boundConnectionCount: usageMap.get(pool.id) || 0,
|
||||
// Egress geo from the background probe cache (null until first probe).
|
||||
egress: getPoolGeo(pool.id) || null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ proxyPools: enrichedProxyPools });
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getDataDir } from "@/lib/dataDir";
|
||||
import { DATA_DIR, DATA_FILE } from "@/lib/db/paths";
|
||||
import { poolFitnessSnapshot } from "open-sse/services/proxyPoolFitness.js";
|
||||
import { poolGeoSnapshot } from "open-sse/services/poolGeo.js";
|
||||
|
||||
function formatMB(bytes) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function dirSizeMB(dir, maxDepth = 4) {
|
||||
let total = 0;
|
||||
const walk = (p, depth) => {
|
||||
if (depth > maxDepth) return;
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(p, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(p, e.name);
|
||||
try {
|
||||
if (e.isDirectory()) walk(full, depth + 1);
|
||||
else if (e.isFile()) total += fs.statSync(full).size;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
};
|
||||
walk(dir, 0);
|
||||
return total;
|
||||
}
|
||||
|
||||
// GET /api/system/memory — data + in-memory state sizes.
|
||||
export async function GET() {
|
||||
try {
|
||||
const dataDir = getDataDir();
|
||||
const dbPath = DATA_FILE;
|
||||
let dbFile = 0;
|
||||
try { dbFile = fs.statSync(dbPath).size; } catch { dbFile = 0; }
|
||||
|
||||
const fitness = poolFitnessSnapshot();
|
||||
const geo = poolGeoSnapshot();
|
||||
const { sessionStateSize } = await import("open-sse/executors/freebuff.js");
|
||||
|
||||
return NextResponse.json({
|
||||
dataDir,
|
||||
dbPath,
|
||||
dbSizeMB: formatMB(dbFile),
|
||||
dataDirSizeMB: formatMB(dirSizeMB(dataDir)),
|
||||
inMemory: {
|
||||
fitnessPools: Object.keys(fitness).length,
|
||||
geoPools: Object.keys(geo).length,
|
||||
freebuff: sessionStateSize(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error reading memory sizes:", error);
|
||||
return NextResponse.json({ error: "Failed to read memory sizes" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
|
||||
// Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure
|
||||
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
|
||||
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData, connection.id);
|
||||
const proxyOptions = {
|
||||
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
|
||||
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
|
||||
|
||||
@@ -8,6 +8,7 @@ const OPTIONAL_FIELDS = [
|
||||
"scope", "projectId", "apiKey", "testStatus",
|
||||
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode",
|
||||
"consecutiveUseCount", "idToken", "lastRefreshAt",
|
||||
"proxyRotationStrategy", "proxyPoolIds",
|
||||
];
|
||||
|
||||
const MODEL_LOCK_PREFIX = "modelLock_";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getProxyPoolById } from "@/models";
|
||||
import { ensurePoolFitnessHydrated, fitPoolIds } from "open-sse/services/proxyPoolFitness.js";
|
||||
|
||||
// Safely normalize any value into a trimmed string.
|
||||
function normalizeString(value) {
|
||||
@@ -13,24 +14,43 @@ const rotateState = new Map(); // providerId → { index }
|
||||
* Pick one proxy pool ID from a list based on strategy.
|
||||
* round-robin: cycle sequentially (in-memory, resets on restart)
|
||||
* random: uniform random pick
|
||||
* smart: region-aware — skip pools unfit for `scope`, round-robin on the fit subset
|
||||
* none/single: return first entry
|
||||
* @param {string[]} poolIds
|
||||
* @param {string} strategy
|
||||
* @param {string} providerId
|
||||
* @param {{ scope?: string, excludeIds?: string[] }} [opts]
|
||||
*/
|
||||
export function pickProxyPoolId(poolIds, strategy, providerId) {
|
||||
export function pickProxyPoolId(poolIds, strategy, providerId, opts = {}) {
|
||||
if (!poolIds || poolIds.length === 0) return null;
|
||||
if (poolIds.length === 1) return poolIds[0];
|
||||
const { scope = null, excludeIds = [] } = opts || {};
|
||||
|
||||
if (strategy === "round-robin") {
|
||||
let eligible = poolIds.filter((id) => !(excludeIds || []).includes(id));
|
||||
// Region-aware filtering is opt-in via the "smart" strategy.
|
||||
if (strategy === "smart" && scope) eligible = fitPoolIds(eligible, scope);
|
||||
|
||||
if (eligible.length === 0) {
|
||||
// Freebuff must never reuse a limited-IP egress. Other providers retain
|
||||
// the previous fail-open behavior when every smart candidate is marked
|
||||
// unfit; their executors may have their own pool fallback semantics.
|
||||
if (providerId === "freebuff" && strategy === "smart") return null;
|
||||
eligible = poolIds.filter((id) => !(excludeIds || []).includes(id));
|
||||
if (eligible.length === 0) return null;
|
||||
}
|
||||
if (eligible.length === 1) return eligible[0];
|
||||
|
||||
if (strategy === "round-robin" || strategy === "smart") {
|
||||
const state = rotateState.get(providerId) || { index: -1 };
|
||||
state.index = (state.index + 1) % poolIds.length;
|
||||
state.index = (state.index + 1) % eligible.length;
|
||||
rotateState.set(providerId, state);
|
||||
return poolIds[state.index];
|
||||
return eligible[state.index];
|
||||
}
|
||||
|
||||
if (strategy === "random") {
|
||||
return poolIds[Math.floor(Math.random() * poolIds.length)];
|
||||
return eligible[Math.floor(Math.random() * eligible.length)];
|
||||
}
|
||||
|
||||
return poolIds[0]; // "none" or unknown
|
||||
return eligible[0]; // "none" or unknown
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,75 +79,127 @@ function normalizeLegacyProxy(providerSpecificData = {}) {
|
||||
* Resolve final proxy configuration.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Proxy Pool
|
||||
* 2. Legacy Proxy
|
||||
* 3. No Proxy
|
||||
* 1. Multi-Proxy Pool (new format with rotation)
|
||||
* 2. Single Proxy Pool (legacy)
|
||||
* 3. Legacy Proxy
|
||||
* 4. No Proxy
|
||||
*/
|
||||
export async function resolveConnectionProxyConfig(
|
||||
providerSpecificData = {}
|
||||
providerSpecificData = {},
|
||||
connectionId = null,
|
||||
excludePoolIds = null
|
||||
) {
|
||||
try {
|
||||
const proxyPoolIdRaw = normalizeString(
|
||||
providerSpecificData?.proxyPoolId
|
||||
);
|
||||
|
||||
// "__none__" means explicitly disabled
|
||||
const proxyPoolId =
|
||||
proxyPoolIdRaw === "__none__" ? "" : proxyPoolIdRaw;
|
||||
await ensurePoolFitnessHydrated();
|
||||
// Handle new multi-proxy format
|
||||
const proxyPoolIds = providerSpecificData?.proxyPoolIds || [];
|
||||
const proxyRotationStrategy = providerSpecificData?.proxyRotationStrategy || "none";
|
||||
|
||||
// Handle legacy single-proxy format
|
||||
const legacyProxyPoolId = normalizeString(providerSpecificData?.proxyPoolId);
|
||||
const proxyPoolIdRaw = legacyProxyPoolId === "__none__" ? "" : legacyProxyPoolId;
|
||||
|
||||
const legacy = normalizeLegacyProxy(providerSpecificData);
|
||||
const multiPoolScope = providerSpecificData?.proxyPoolScope || null;
|
||||
let selectedPoolId = null;
|
||||
|
||||
/**
|
||||
* -----------------------------
|
||||
* Proxy Pool Resolution
|
||||
* Multi-Proxy Pool Resolution (NEW)
|
||||
* -----------------------------
|
||||
*/
|
||||
if (proxyPoolId) {
|
||||
const proxyPool = await getProxyPoolById(proxyPoolId);
|
||||
if (proxyPoolIds.length > 0) {
|
||||
selectedPoolId = pickProxyPoolId(proxyPoolIds, proxyRotationStrategy, connectionId, { scope: multiPoolScope, excludeIds: excludePoolIds });
|
||||
|
||||
if (selectedPoolId) {
|
||||
const proxyPool = await getProxyPoolById(selectedPoolId);
|
||||
const proxyUrl = normalizeString(proxyPool?.proxyUrl);
|
||||
const noProxy = normalizeString(proxyPool?.noProxy);
|
||||
|
||||
const isValidPool = proxyPool && proxyPool.isActive === true && proxyUrl;
|
||||
|
||||
if (isValidPool) {
|
||||
/**
|
||||
* Vercel/Cloudflare relay proxies use base URL rewriting
|
||||
* instead of HTTP_PROXY environment variables.
|
||||
*/
|
||||
if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno") {
|
||||
return {
|
||||
source: proxyPool.type,
|
||||
proxyPoolId: selectedPoolId,
|
||||
proxyPool,
|
||||
connectionProxyEnabled: false,
|
||||
connectionProxyUrl: "",
|
||||
connectionNoProxy: noProxy,
|
||||
strictProxy: proxyPool.strictProxy === true,
|
||||
vercelRelayUrl: proxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard proxy pool
|
||||
*/
|
||||
return {
|
||||
source: "pool",
|
||||
proxyPoolId: selectedPoolId,
|
||||
proxyPool,
|
||||
connectionProxyEnabled: true,
|
||||
connectionProxyUrl: proxyUrl,
|
||||
connectionNoProxy: noProxy,
|
||||
strictProxy: proxyPool.strictProxy === true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!selectedPoolId &&
|
||||
proxyRotationStrategy === "smart" &&
|
||||
multiPoolScope?.startsWith("freebuff::")
|
||||
) {
|
||||
return {
|
||||
source: "pool",
|
||||
proxyPoolId: null,
|
||||
proxyPool: null,
|
||||
noFitPool: true,
|
||||
connectionProxyEnabled: false,
|
||||
connectionProxyUrl: "",
|
||||
connectionNoProxy: "",
|
||||
strictProxy: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* -----------------------------
|
||||
* Single Proxy Pool Resolution (LEGACY)
|
||||
* -----------------------------
|
||||
*/
|
||||
if (proxyPoolIdRaw) {
|
||||
const proxyPool = await getProxyPoolById(proxyPoolIdRaw);
|
||||
const proxyUrl = normalizeString(proxyPool?.proxyUrl);
|
||||
const noProxy = normalizeString(proxyPool?.noProxy);
|
||||
|
||||
const isValidPool =
|
||||
proxyPool &&
|
||||
proxyPool.isActive === true &&
|
||||
proxyUrl;
|
||||
const isValidPool = proxyPool && proxyPool.isActive === true && proxyUrl;
|
||||
|
||||
if (isValidPool) {
|
||||
/**
|
||||
* Vercel/Cloudflare relay proxies use base URL rewriting
|
||||
* instead of HTTP_PROXY environment variables.
|
||||
*/
|
||||
if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno") {
|
||||
return {
|
||||
source: proxyPool.type,
|
||||
|
||||
proxyPoolId,
|
||||
proxyPoolId: proxyPoolIdRaw,
|
||||
proxyPool,
|
||||
|
||||
connectionProxyEnabled: false,
|
||||
connectionProxyUrl: "",
|
||||
connectionNoProxy: noProxy,
|
||||
|
||||
strictProxy: proxyPool.strictProxy === true,
|
||||
|
||||
vercelRelayUrl: proxyUrl, // Still mapped to vercelRelayUrl in the unified payload since they use the exact same header spec
|
||||
vercelRelayUrl: proxyUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard proxy pool
|
||||
*/
|
||||
return {
|
||||
source: "pool",
|
||||
|
||||
proxyPoolId,
|
||||
proxyPoolId: proxyPoolIdRaw,
|
||||
proxyPool,
|
||||
|
||||
connectionProxyEnabled: true,
|
||||
connectionProxyUrl: proxyUrl,
|
||||
connectionNoProxy: noProxy,
|
||||
|
||||
strictProxy: proxyPool.strictProxy === true,
|
||||
};
|
||||
}
|
||||
@@ -145,7 +217,7 @@ export async function resolveConnectionProxyConfig(
|
||||
return {
|
||||
source: "legacy",
|
||||
|
||||
proxyPoolId: proxyPoolId || null,
|
||||
proxyPoolId: proxyPoolIdRaw || null,
|
||||
proxyPool: null,
|
||||
|
||||
...legacy,
|
||||
@@ -160,7 +232,7 @@ export async function resolveConnectionProxyConfig(
|
||||
return {
|
||||
source: "none",
|
||||
|
||||
proxyPoolId: proxyPoolId || null,
|
||||
proxyPoolId: proxyPoolIdRaw || null,
|
||||
proxyPool: null,
|
||||
|
||||
...legacy,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Background pool egress geo probe — fills the poolGeo cache so the Proxy
|
||||
// Fitness / Proxy Pools UI can show each pool's egress IP + country, and
|
||||
// future provider region policies can pre-mark pools unfit.
|
||||
// Fail-open everywhere; never blocks startup or requests.
|
||||
//
|
||||
// Rate safety: each probe hits ipinfo.io through the pool; quotas are small,
|
||||
// so failing pools get a negative backoff instead of being re-probed every
|
||||
// pass, and per-pass output is a single aggregated summary line.
|
||||
|
||||
import { getProxyPools } from "@/models";
|
||||
import { probePoolGeo, setPoolGeo, getPoolGeo } from "open-sse/services/poolGeo.js";
|
||||
import { isNonServerRuntime } from "@/sse/services/backgroundTokenRefresh.js";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
|
||||
const PROBE_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const INITIAL_DELAY_MS = 15 * 1000;
|
||||
const CONCURRENCY = 3;
|
||||
// Re-probe a pool when its last sample is older than this — multiple samples
|
||||
// per pool are what let us flag flapping (changing egress) relays.
|
||||
const GEO_REPROBE_MS = 30 * 60 * 1000;
|
||||
|
||||
// Backoff windows per failure family: server/rate problems are likely to
|
||||
// persist (broken relay, exhausted quota), network blips recover sooner.
|
||||
const BACKOFF = {
|
||||
"rate-limit": 2 * 60 * 60 * 1000,
|
||||
server: 2 * 60 * 60 * 1000,
|
||||
network: 30 * 60 * 1000,
|
||||
timeout: 30 * 60 * 1000,
|
||||
"no-ip": 30 * 60 * 1000,
|
||||
};
|
||||
|
||||
let started = false;
|
||||
let intervalHandle = null;
|
||||
let initialTimeoutHandle = null;
|
||||
let probing = false;
|
||||
const probeBackoff = new Map(); // poolId -> retryAfter (ms epoch)
|
||||
|
||||
function isTruthyEnv(v) {
|
||||
if (v == null || v === "") return false;
|
||||
return ["1", "true", "yes", "on"].includes(String(v).trim().toLowerCase());
|
||||
}
|
||||
|
||||
// probe with bounded concurrency; skips pools in backoff or with fresh samples.
|
||||
async function probeAll() {
|
||||
if (probing) return;
|
||||
probing = true;
|
||||
try {
|
||||
// UI toggle (settings.poolGeoProbeEnabled) gates the whole feature; the
|
||||
// env var remains a hard override for headless setups.
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings?.poolGeoProbeEnabled === false) return;
|
||||
} catch { /* DB hiccup — keep probing (fail-open) */ }
|
||||
|
||||
const pools = await getProxyPools({ isActive: true });
|
||||
const now = Date.now();
|
||||
const active = (pools || []).filter((p) => !!p?.proxyUrl);
|
||||
const targets = active.filter((p) => {
|
||||
const until = probeBackoff.get(p.id);
|
||||
if (until && until > now) return false; // waiting out a failure
|
||||
const geo = getPoolGeo(p.id);
|
||||
return !geo || now - geo.ts >= GEO_REPROBE_MS;
|
||||
});
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const failTally = {};
|
||||
let next = 0;
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, Math.max(targets.length, 1)) }, async () => {
|
||||
while (next < targets.length) {
|
||||
const pool = targets[next++];
|
||||
const res = await probePoolGeo(pool);
|
||||
if (res.ok) {
|
||||
setPoolGeo(pool.id, res.geo);
|
||||
if (probeBackoff.has(pool.id)) probeBackoff.delete(pool.id);
|
||||
} else {
|
||||
failTally[res.error] = (failTally[res.error] || 0) + 1;
|
||||
probeBackoff.set(pool.id, now + (BACKOFF[res.error] || BACKOFF.network));
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(workers);
|
||||
|
||||
const filled = active.filter((p) => getPoolGeo(p.id)).length;
|
||||
const failSummary = Object.entries(failTally).map(([k, n]) => `${k}×${n}`).join(", ") || "none";
|
||||
console.log(`[PoolEgressProbe] geo ${filled}/${active.length} · fail: ${failSummary}`);
|
||||
} catch (e) {
|
||||
console.log(`[PoolEgressProbe] pass failed: ${e?.message || e}`);
|
||||
} finally {
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startPoolEgressProbe({ intervalMs } = {}) {
|
||||
if (started) return false;
|
||||
if (isNonServerRuntime()) return false;
|
||||
if (isTruthyEnv(process.env.POOL_GEO_PROBE_DISABLED)) return false;
|
||||
started = true;
|
||||
const period = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : PROBE_INTERVAL_MS;
|
||||
console.log("[PoolEgressProbe] Scheduler started", { intervalMs: period, initialDelayMs: INITIAL_DELAY_MS });
|
||||
initialTimeoutHandle = setTimeout(() => { probeAll().catch(() => {}); }, INITIAL_DELAY_MS);
|
||||
if (initialTimeoutHandle.unref) initialTimeoutHandle.unref();
|
||||
intervalHandle = setInterval(() => { probeAll().catch(() => {}); }, period);
|
||||
if (intervalHandle.unref) intervalHandle.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function stopPoolEgressProbe() {
|
||||
if (initialTimeoutHandle) clearTimeout(initialTimeoutHandle);
|
||||
if (intervalHandle) clearInterval(intervalHandle);
|
||||
initialTimeoutHandle = null;
|
||||
intervalHandle = null;
|
||||
started = false;
|
||||
}
|
||||
|
||||
export const __test__ = { probeAll };
|
||||
@@ -0,0 +1,47 @@
|
||||
// Periodic in-memory state sweeper — a cron-like job that prunes expired data
|
||||
// so here long-running servers never accumulate stale entries:
|
||||
// • pool fitness marks (proxyPoolFitness.pruneExpired)
|
||||
// • pool egress geo cache incl. ipHistory (poolGeo.pruneStaleGeo)
|
||||
// • freebuff session cache + cooldowns (freebuff.pruneSessionState)
|
||||
// Fail-open everywhere; never blocks startup or requests.
|
||||
|
||||
import { pruneExpired } from "open-sse/services/proxyPoolFitness.js";
|
||||
import { pruneStaleGeo } from "open-sse/services/poolGeo.js";
|
||||
import { isNonServerRuntime } from "@/sse/services/backgroundTokenRefresh.js";
|
||||
|
||||
const SWEEP_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
let started = false;
|
||||
let handle = null;
|
||||
|
||||
async function sweep() {
|
||||
try {
|
||||
const fitness = pruneExpired();
|
||||
const geo = pruneStaleGeo();
|
||||
const { pruneSessionState } = await import("open-sse/executors/freebuff.js");
|
||||
const sessions = pruneSessionState();
|
||||
if (fitness || geo || sessions) {
|
||||
console.log(`[StateSweeper] pruned ${fitness} fitness, ${geo} geo, ${sessions} session/cooldown entries`);
|
||||
}
|
||||
} catch {
|
||||
// fail-open: next tick retries
|
||||
}
|
||||
}
|
||||
|
||||
export function startStateSweeper({ intervalMs } = {}) {
|
||||
if (started) return false;
|
||||
if (isNonServerRuntime()) return false;
|
||||
started = true;
|
||||
const period = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : SWEEP_INTERVAL_MS;
|
||||
handle = setInterval(() => { sweep().catch(() => {}); }, period);
|
||||
if (handle.unref) handle.unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function stopStateSweeper() {
|
||||
if (handle) clearInterval(handle);
|
||||
handle = null;
|
||||
started = false;
|
||||
}
|
||||
|
||||
export const __test__ = { sweep };
|
||||
@@ -127,6 +127,9 @@ export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
|
||||
// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes
|
||||
export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
|
||||
|
||||
// Freebuff OAuth Configuration (Device Code Flow)
|
||||
export const FREEBUFF_CONFIG = { ...PROVIDER_OAUTH["freebuff"] };
|
||||
|
||||
// Trae (ByteDance marscode) OAuth — authorization_code flow with local callback.
|
||||
// 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost}
|
||||
// 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb}
|
||||
|
||||
@@ -50,6 +50,21 @@ function extractEmailFromAccessToken(accessToken) {
|
||||
return payload.email || payload.preferred_username || payload.sub || undefined;
|
||||
}
|
||||
|
||||
// Human display name from OIDC-style JWT claims.
|
||||
// Preference: full `name` → given+family → email local-part.
|
||||
function extractDisplayNameFromAccessToken(accessToken) {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
if (!payload) return undefined;
|
||||
const full = typeof payload.name === "string" ? payload.name.trim() : "";
|
||||
if (full) return full;
|
||||
const given = typeof payload.given_name === "string" ? payload.given_name.trim() : "";
|
||||
const family = typeof payload.family_name === "string" ? payload.family_name.trim() : "";
|
||||
const combined = [given, family].filter(Boolean).join(" ").trim();
|
||||
if (combined) return combined;
|
||||
const email = typeof payload.email === "string" ? payload.email.trim() : "";
|
||||
return email ? email.split("@")[0] : undefined;
|
||||
}
|
||||
|
||||
export async function fetchKiroProfileArn(accessToken) {
|
||||
if (!accessToken) return null;
|
||||
try {
|
||||
@@ -87,4 +102,5 @@ export {
|
||||
decodeXaiIdTokenEmail,
|
||||
decodeJwtPayload,
|
||||
extractEmailFromAccessToken,
|
||||
extractDisplayNameFromAccessToken,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CODEBUDDY_INTL_CONFIG } from "../constants/oauth.js";
|
||||
import { extractEmailFromAccessToken, extractDisplayNameFromAccessToken } from "../providerHelpers.js";
|
||||
|
||||
// CodeBuddy International — mirrors codebuddy-cn flow against the .ai domain.
|
||||
const codebuddyIntl = {
|
||||
@@ -67,6 +68,12 @@ const codebuddyIntl = {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in || 86400,
|
||||
// GUARD — DO NOT REMOVE. See AGENTS.md §4. The CodeBuddy access token is a
|
||||
// Keycloak JWT carrying email/name claims; surface them so a fresh OAuth
|
||||
// login is named by identity (and deduped on re-login) instead of falling
|
||||
// back to "Account N". Covered by tests/unit/codebuddy-intl-connection.test.js.
|
||||
email: extractEmailFromAccessToken(tokens.access_token) || null,
|
||||
displayName: extractDisplayNameFromAccessToken(tokens.access_token) || null,
|
||||
providerSpecificData: {},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import crypto from "node:crypto";
|
||||
import { FREEBUFF_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
/**
|
||||
* Freebuff / Codebuff CLI login (fingerprint device-flow — NOT OAuth2):
|
||||
* 1) POST {baseUrl}/api/auth/cli/code { fingerprintId } // baseUrl = https://freebuff.com
|
||||
* → { fingerprintId, fingerprintHash, loginUrl, expiresAt }
|
||||
* (The server echoes the request host into loginUrl, so calling
|
||||
* freebuff.com yields freebuff.com/login?auth_code=… — exactly the link
|
||||
* the official CLI shows. www.codebuff.com would return a wrong link.)
|
||||
* 2) User opens loginUrl in a browser and signs in / confirms the device
|
||||
* 3) GET {baseUrl}/api/auth/cli/status?fingerprintId=..&fingerprintHash=..&expiresAt=..
|
||||
* → { user: { id, email, name, authToken, fingerprintId, ... } } once authorized
|
||||
*
|
||||
* The resulting user.authToken is the Bearer token used against the
|
||||
* OpenAI-compatible endpoint https://www.codebuff.com/api/v1/chat/completions
|
||||
* (same backend, different host — freebuff.com does not serve /api/v1/*).
|
||||
*
|
||||
* Implemented on top of the generic "device_code" flow: the fingerprintId +
|
||||
* fingerprintHash + expiresAt triple rides in the `device_code` payload and is
|
||||
* decoded server-side on every poll.
|
||||
*/
|
||||
// Login-flow host. The CLI in freebuff mode logs in via freebuff.com, and the
|
||||
// server builds loginUrl from the host it was called on — so this must stay
|
||||
// https://freebuff.com (www.codebuff.com would yield the wrong login link).
|
||||
const LOGIN_HOST = "https://freebuff.com";
|
||||
|
||||
const freebuff = {
|
||||
config: FREEBUFF_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const fingerprintId = crypto.randomUUID();
|
||||
const baseUrl = (config.baseUrl || LOGIN_HOST).replace(/\/$/, "");
|
||||
const response = await fetch(
|
||||
`${baseUrl}${config.loginCodePath || "/api/auth/cli/code"}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "codebuff-cli/0.0.138",
|
||||
},
|
||||
body: JSON.stringify({ fingerprintId }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Freebuff login code request failed: ${error}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const loginUrl = typeof data.loginUrl === "string" ? data.loginUrl : "";
|
||||
const authCode = loginUrl.match(/auth_code=([^&]+)/)?.[1] || "";
|
||||
const expiresAt = Number(data.expiresAt) || 0;
|
||||
// Server codes live ~1h, but the official CLI stops polling after 5 minutes
|
||||
// (and OAuthModal derives its deadline from expires_in). Clamp to
|
||||
// oauthTimeoutMs so the modal doesn't hammer /api/auth/cli/status for an hour.
|
||||
// When the server omits expiresAt, fall back to the full oauthTimeoutMs —
|
||||
// never the 60s floor, or the user gets only a minute to finish login.
|
||||
const timeoutSec = Math.max(60, Math.floor((config.oauthTimeoutMs || 300000) / 1000));
|
||||
const serverMs =
|
||||
Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt - Date.now() : timeoutSec * 1000;
|
||||
const expiresIn = Math.max(60, Math.min(Math.floor(serverMs / 1000), timeoutSec));
|
||||
return {
|
||||
// fingerprintId + fingerprintHash + expiresAt travel inside device_code;
|
||||
// pollToken decodes them for /api/auth/cli/status.
|
||||
device_code: JSON.stringify({
|
||||
fingerprintId: data.fingerprintId || fingerprintId,
|
||||
fingerprintHash: data.fingerprintHash,
|
||||
expiresAt,
|
||||
}),
|
||||
user_code: authCode || "",
|
||||
verification_uri: loginUrl,
|
||||
verification_uri_complete: loginUrl,
|
||||
expires_in: expiresIn,
|
||||
interval: 5,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(deviceCode) || {};
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
const { fingerprintId, fingerprintHash, expiresAt } = parsed;
|
||||
if (!fingerprintId || !fingerprintHash || !expiresAt) {
|
||||
return { ok: true, data: { error: "authorization_pending" } };
|
||||
}
|
||||
// The status endpoint is a GET with query params; it returns 401
|
||||
// {"error":"Authentication failed"} while the device is still waiting for
|
||||
// the browser sign-in, and 200 { user } once authorized. Mirror the CLI:
|
||||
// keep polling on anything except a `user` payload (the modal enforces its
|
||||
// own 5-minute deadline).
|
||||
const baseUrl = (config.baseUrl || LOGIN_HOST).replace(/\/$/, "");
|
||||
const query = new URLSearchParams({ fingerprintId, fingerprintHash, expiresAt: String(expiresAt) });
|
||||
const response = await fetch(
|
||||
`${baseUrl}${config.loginStatusPath || "/api/auth/cli/status"}?${query.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "codebuff-cli/0.0.138",
|
||||
},
|
||||
},
|
||||
);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
|
||||
if (data?.user?.authToken) {
|
||||
return { ok: true, data: { access_token: data.user.authToken, ...data.user } };
|
||||
}
|
||||
return { ok: true, data: { error: "authorization_pending" } };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: null,
|
||||
email: tokens.email || undefined,
|
||||
displayName: tokens.name || undefined,
|
||||
providerSpecificData: {
|
||||
authMethod: "device_code",
|
||||
fingerprintId: tokens.fingerprintId || null,
|
||||
userId: tokens.id || null,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export default freebuff;
|
||||
@@ -2,7 +2,7 @@
|
||||
import "open-sse/index.js";
|
||||
|
||||
import { generatePKCE } from "../utils/pkce.js";
|
||||
import { extractCodexAccountInfo, fetchKiroProfileArn } from "../providerHelpers.js";
|
||||
import { extractCodexAccountInfo, fetchKiroProfileArn, extractEmailFromAccessToken, extractDisplayNameFromAccessToken } from "../providerHelpers.js";
|
||||
|
||||
import claude from "./claude.js";
|
||||
import codex from "./codex.js";
|
||||
@@ -16,6 +16,7 @@ import qoderCn from "./qoder-cn.js";
|
||||
import github from "./github.js";
|
||||
import kiro from "./kiro.js";
|
||||
import cursor from "./cursor.js";
|
||||
import freebuff from "./freebuff.js";
|
||||
import kimi from "./kimi.js";
|
||||
import kilocode from "./kilocode.js";
|
||||
import cline from "./cline.js";
|
||||
@@ -42,6 +43,7 @@ const PROVIDERS = {
|
||||
github,
|
||||
kiro,
|
||||
cursor,
|
||||
freebuff,
|
||||
kimi,
|
||||
kilocode,
|
||||
cline,
|
||||
@@ -209,6 +211,43 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
|
||||
|
||||
// Run-once guard across the process lifetime
|
||||
let codexBackfillDone = false;
|
||||
let codebuddyIntlBackfillDone = false;
|
||||
|
||||
// Backfill email + displayName for existing CodeBuddy Intl OAuth connections
|
||||
// created before mapTokens surfaced identity (they show up as "Account N").
|
||||
// The access token is a Keycloak JWT carrying email/name claims.
|
||||
export async function backfillCodeBuddyIntlIdentity() {
|
||||
if (codebuddyIntlBackfillDone) return;
|
||||
codebuddyIntlBackfillDone = true;
|
||||
try {
|
||||
const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb");
|
||||
const connections = await getProviderConnections();
|
||||
const targets = connections.filter((c) => {
|
||||
if (c.provider !== "codebuddy-intl" || c.authType !== "oauth" || !c.accessToken) return false;
|
||||
// Also re-heal rows whose name is still the generic "Account N" placeholder.
|
||||
const genericName = typeof c.name === "string" && /^Account \d+$/.test(c.name.trim());
|
||||
return !c.email || !c.displayName || genericName;
|
||||
});
|
||||
for (const conn of targets) {
|
||||
const patch = {};
|
||||
const email = conn.email || extractEmailFromAccessToken(conn.accessToken);
|
||||
const displayName = conn.displayName || extractDisplayNameFromAccessToken(conn.accessToken);
|
||||
if (!conn.email && email) patch.email = email;
|
||||
if (!conn.displayName && displayName) patch.displayName = displayName;
|
||||
// Rename the generic placeholder to the identity (email preferred, matching
|
||||
// deriveConnectionName's behavior for new logins).
|
||||
if (/^Account \d+$/.test((conn.name || "").trim()) && (email || displayName)) {
|
||||
patch.name = email || displayName;
|
||||
}
|
||||
if (Object.keys(patch).length) {
|
||||
await updateProviderConnection(conn.id, patch);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
codebuddyIntlBackfillDone = false;
|
||||
console.log("backfillCodeBuddyIntlIdentity failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill email + chatgpt account info for existing codex OAuth connections missing them
|
||||
export async function backfillCodexEmails() {
|
||||
|
||||
@@ -8,7 +8,6 @@ import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import HeaderMenu from "@/shared/components/HeaderMenu";
|
||||
import HeaderLanguage from "@/shared/components/HeaderLanguage";
|
||||
import ThemeToggle from "@/shared/components/ThemeToggle";
|
||||
import DonateModal from "@/shared/components/DonateModal";
|
||||
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
@@ -183,7 +182,6 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
const pathname = usePathname();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [loginMethod, setLoginMethod] = useState("");
|
||||
const [donateOpen, setDonateOpen] = useState(false);
|
||||
|
||||
// Memoize page info to prevent unnecessary recalculations
|
||||
const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]);
|
||||
@@ -316,19 +314,20 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
</div>
|
||||
)}
|
||||
<HeaderSearch />
|
||||
<button
|
||||
onClick={() => setDonateOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 h-8 rounded-lg border border-pink-500/30 bg-pink-500/10 text-pink-600 dark:text-pink-400 hover:bg-pink-500/20 transition-colors text-sm font-medium"
|
||||
aria-label="Donate"
|
||||
<a
|
||||
href="https://github.com/mhiqrambg/9router-mibp-version"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 h-8 rounded-lg border border-primary/30 bg-primary/10 text-primary hover:bg-primary/20 transition-colors text-sm font-medium"
|
||||
aria-label="MIBP Edition"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">volunteer_activism</span>
|
||||
<span className="hidden sm:inline">Donate</span>
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[18px]">code</span>
|
||||
<span className="hidden sm:inline">MIBP Edition</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<HeaderLanguage />
|
||||
<HeaderMenu onLogout={handleLogout} />
|
||||
</div>
|
||||
<DonateModal isOpen={donateOpen} onClose={() => setDonateOpen(false)} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const STRATEGIES = [
|
||||
{ value: "none", label: "None (single pool)" },
|
||||
{ value: "round-robin", label: "Round-robin" },
|
||||
{ value: "random", label: "Random" },
|
||||
{ value: "smart", label: "Smart" },
|
||||
];
|
||||
|
||||
export default function NoAuthProxyCard({ providerId }) {
|
||||
@@ -121,7 +122,9 @@ export default function NoAuthProxyCard({ providerId }) {
|
||||
: isRotation
|
||||
? rotateStrategy === "round-robin"
|
||||
? `Rotating through all ${proxyPools.length} active pools in order. State is in-memory (resets on restart).`
|
||||
: `Picking a random pool from ${proxyPools.length} active pools each request.`
|
||||
: rotateStrategy === "smart"
|
||||
? `Skip pools whose egress IP is blocked for this provider (e.g. per-IP limits). See Proxy Fitness to clear/block.`
|
||||
: `Picking a random pool from ${proxyPools.length} active pools each request.`
|
||||
: `Uses the selected pool above. Set to Round-robin or Random to rotate across all active pools.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -291,6 +291,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
"qoder",
|
||||
"qoder-cn",
|
||||
"grok-cli",
|
||||
"freebuff",
|
||||
];
|
||||
if (deviceCodeProviders.includes(provider)) {
|
||||
setIsDeviceCode(true);
|
||||
|
||||
@@ -25,6 +25,7 @@ const navItems = [
|
||||
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
||||
{ href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" },
|
||||
{ href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" },
|
||||
{ href: "/dashboard/proxy-fitness", label: "Proxy Fitness", icon: "network_check" },
|
||||
// { href: "/dashboard/pxpipe", label: "PXPIPE", icon: "image" },
|
||||
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
|
||||
];
|
||||
@@ -355,6 +356,17 @@ export default function Sidebar({ onClose }) {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border-subtle px-6 py-3 text-center">
|
||||
<a
|
||||
href="https://github.com/mhiqrambg/9router-mibp-version"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-text-muted/60 hover:text-text-muted transition-colors"
|
||||
>
|
||||
MIBP Edition · GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
{/* Remote Promo Modal */}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { buildUsageProviderList } from "@/shared/utils/usageProviders";
|
||||
|
||||
// Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds
|
||||
function isLLMProvider(id) {
|
||||
@@ -235,21 +236,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
||||
for (const node of (nodesData?.nodes || [])) {
|
||||
nodeNameMap[node.id] = node.name;
|
||||
}
|
||||
const seen = new Set();
|
||||
const unique = (d?.connections || []).filter((c) => {
|
||||
if (c.isActive === false) return false;
|
||||
if (!isLLMProvider(c.provider)) return false;
|
||||
if (seen.has(c.provider)) return false;
|
||||
seen.add(c.provider);
|
||||
return true;
|
||||
}).map((c) => ({
|
||||
...c,
|
||||
nodeName: nodeNameMap[c.provider] || null,
|
||||
setProviders(buildUsageProviderList({
|
||||
connections: d?.connections || [],
|
||||
freeProviders: FREE_PROVIDERS,
|
||||
nodeNameMap,
|
||||
isLLMProvider,
|
||||
}));
|
||||
const noAuthProviders = Object.values(FREE_PROVIDERS)
|
||||
.filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id))
|
||||
.map((p) => ({ provider: p.id, name: p.name }));
|
||||
setProviders([...unique, ...noAuthProviders]);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -118,6 +118,16 @@ async function runHeavyStartup() {
|
||||
import("@/sse/services/backgroundTokenRefresh.js")
|
||||
.then(({ startBackgroundTokenRefresh }) => startBackgroundTokenRefresh())
|
||||
.catch((e) => console.log("[BackgroundTokenRefresh] scheduler start failed:", e.message));
|
||||
|
||||
// Pool egress geo probe — fills the Proxy Fitness / Proxy Pools egress column.
|
||||
import("@/lib/network/poolEgressProbe.js")
|
||||
.then(({ startPoolEgressProbe }) => startPoolEgressProbe())
|
||||
.catch((e) => console.log("[PoolEgressProbe] scheduler start failed:", e.message));
|
||||
|
||||
// Periodic in-memory state sweeper — prunes expired fitness/geo/session state.
|
||||
import("@/lib/network/stateSweeper.js")
|
||||
.then(({ startStateSweeper }) => startStateSweeper())
|
||||
.catch((e) => console.log("[StateSweeper] scheduler start failed:", e.message));
|
||||
}
|
||||
|
||||
function hasQuotaAutoPingEnabled(settings) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Provider list for the Usage page.
|
||||
//
|
||||
// GUARD — DO NOT DELETE the `!p.hidden` filter below. See AGENTS.md §3.
|
||||
//
|
||||
// Two sources, deduped by provider id:
|
||||
// 1. Active LLM connections (one entry per provider).
|
||||
// 2. noAuth free providers that need no connection (e.g. opencode).
|
||||
//
|
||||
// Hidden providers are excluded — the Providers page filters `hidden`, so a
|
||||
// hidden noAuth provider (devin-cli, mimo-free) must not leak into Usage with
|
||||
// zero connections and zero traffic.
|
||||
// Covered by tests/unit/usage-provider-list.test.js.
|
||||
export function buildUsageProviderList({
|
||||
connections = [],
|
||||
freeProviders = {},
|
||||
nodeNameMap = {},
|
||||
isLLMProvider = () => true,
|
||||
} = {}) {
|
||||
const seen = new Set();
|
||||
const unique = connections
|
||||
.filter((c) => {
|
||||
if (c.isActive === false) return false;
|
||||
if (!isLLMProvider(c.provider)) return false;
|
||||
if (seen.has(c.provider)) return false;
|
||||
seen.add(c.provider);
|
||||
return true;
|
||||
})
|
||||
.map((c) => ({
|
||||
...c,
|
||||
nodeName: nodeNameMap[c.provider] || null,
|
||||
}));
|
||||
|
||||
const noAuthProviders = Object.values(freeProviders)
|
||||
.filter((p) => p.noAuth && !p.hidden && !seen.has(p.id) && isLLMProvider(p.id))
|
||||
.map((p) => ({ provider: p.id, name: p.name }));
|
||||
|
||||
return [...unique, ...noAuthProviders];
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
import { handleComboChat, handleFusionChat, detectRequiredCapabilities } from "open-sse/services/combo.js";
|
||||
import { augmentModelsWithCapacityAdapter, withCapacityAdapterStripping, getActiveAdapterStrategy } from "open-sse/services/capacityAdapter.js";
|
||||
import { handleBypassRequest } from "open-sse/utils/bypassHandler.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import { detectFormatByEndpoint } from "open-sse/translator/formats.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
@@ -291,6 +292,23 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
||||
pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null,
|
||||
onPxpipeEvent: appendPxpipeEvent,
|
||||
providerThinking,
|
||||
// Pool-scoped failure recovery: re-resolve proxy config excluding the
|
||||
// failed pool so the request retries via another pool, not a dead end.
|
||||
resolveProxyConfig: async (creds, excludePoolIds = []) => {
|
||||
const psd = { ...(creds?.providerSpecificData || {}) };
|
||||
if (psd.proxyPoolIds?.length) psd.proxyPoolScope = `${provider}::${model}`;
|
||||
const resolved = await resolveConnectionProxyConfig(psd, creds?.connectionId || creds?.id, excludePoolIds);
|
||||
if (!resolved?.proxyPoolId) return null;
|
||||
return {
|
||||
connectionProxyEnabled: resolved.connectionProxyEnabled,
|
||||
connectionProxyUrl: resolved.connectionProxyUrl,
|
||||
connectionNoProxy: resolved.connectionNoProxy,
|
||||
connectionProxyPoolId: resolved.proxyPoolId || null,
|
||||
vercelRelayUrl: resolved.vercelRelayUrl || "",
|
||||
proxyPoolId: resolved.proxyPoolId || null,
|
||||
strictProxy: resolved.strictProxy === true,
|
||||
};
|
||||
},
|
||||
// Detect source format by endpoint + body
|
||||
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
|
||||
@@ -9,6 +9,18 @@ import * as log from "../utils/logger.js";
|
||||
// Mutex to prevent race conditions during account selection
|
||||
let selectionMutex = Promise.resolve();
|
||||
|
||||
export function filterConnectionsForModel(providerId, connections, model, settings = {}) {
|
||||
const override = (settings.providerStrategies || {})[providerId] || {};
|
||||
if (override.strictModelAssignment !== true || !model) {
|
||||
return connections;
|
||||
}
|
||||
return connections.filter((connection) => {
|
||||
const assignedModel = connection.providerSpecificData?.assignedModel
|
||||
|| (providerId === "freebuff" ? connection.providerSpecificData?.freebuffModel : null);
|
||||
return assignedModel === model;
|
||||
});
|
||||
}
|
||||
|
||||
const GITHUB_MONTHLY_USAGE_LIMIT = "you've reached your additional usage limit for your plan";
|
||||
|
||||
function githubMonthlyResetMs(status, errorText, provider) {
|
||||
@@ -48,10 +60,16 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
const override = (settings.providerStrategies || {})[providerId] || {};
|
||||
const strategy = override.rotateStrategy || "none";
|
||||
let pickedId = override.proxyPoolId || null;
|
||||
let poolIds = [];
|
||||
if (strategy !== "none") {
|
||||
const allPools = await getProxyPools({ isActive: true });
|
||||
const poolIds = allPools.filter(p => p.proxyUrl).map(p => p.id);
|
||||
pickedId = pickProxyPoolId(poolIds, strategy, providerId);
|
||||
poolIds = allPools.filter(p => p.proxyUrl).map(p => p.id);
|
||||
// Scope region-aware ("smart") filtering to this provider/model so
|
||||
// pools marked unfit here are skipped.
|
||||
const scope = `${providerId}::${model || "*"}`;
|
||||
pickedId = pickProxyPoolId(poolIds, strategy, providerId, { scope });
|
||||
} else if (override.proxyPoolId) {
|
||||
poolIds = [override.proxyPoolId];
|
||||
}
|
||||
const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: pickedId || "" });
|
||||
return {
|
||||
@@ -65,11 +83,21 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
connectionNoProxy: resolvedProxy.connectionNoProxy,
|
||||
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
|
||||
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
|
||||
proxyPoolId: resolvedProxy.proxyPoolId || null,
|
||||
strictProxy: resolvedProxy.strictProxy === true,
|
||||
// Let chatCore's pool-scoped retry rotate across the same candidate
|
||||
// pool set (excluding the failed pool) instead of reusing it — this
|
||||
// is what makes per-IP limit retries work for no-auth providers.
|
||||
proxyPoolIds: poolIds,
|
||||
proxyRotationStrategy: strategy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connections = await getProviderConnections({ provider: providerId, isActive: true });
|
||||
let connections = await getProviderConnections({ provider: providerId, isActive: true });
|
||||
const settings = await getSettings();
|
||||
const providerOverride = (settings.providerStrategies || {})[providerId] || {};
|
||||
connections = filterConnectionsForModel(providerId, connections, model, settings);
|
||||
log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`);
|
||||
|
||||
if (connections.length === 0) {
|
||||
@@ -133,9 +161,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = await getSettings();
|
||||
// Per-provider strategy overrides global setting
|
||||
const providerOverride = (settings.providerStrategies || {})[providerId] || {};
|
||||
const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first";
|
||||
|
||||
let connection;
|
||||
@@ -192,7 +218,11 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
connection = availableConnections[0];
|
||||
}
|
||||
|
||||
const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||
// Scope the region-aware picker to this provider/model (e.g. freebuff::gpt-5.6-luna)
|
||||
const psdForProxy = connection.providerSpecificData?.proxyPoolIds?.length
|
||||
? { ...connection.providerSpecificData, proxyPoolScope: `${providerId}::${model || ""}` }
|
||||
: connection.providerSpecificData;
|
||||
const resolvedProxy = await resolveConnectionProxyConfig(psdForProxy || {}, connection.id);
|
||||
|
||||
return {
|
||||
authType: connection.authType,
|
||||
@@ -213,6 +243,9 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
||||
connectionNoProxy: resolvedProxy.connectionNoProxy,
|
||||
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
|
||||
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
|
||||
proxyPoolId: resolvedProxy.proxyPoolId || null,
|
||||
noFitPool: resolvedProxy.noFitPool === true,
|
||||
strictProxy: resolvedProxy.strictProxy === true,
|
||||
},
|
||||
connectionId: connection.id,
|
||||
// Include current status for optimization check
|
||||
@@ -254,9 +287,15 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||
} else if (resetsAtMs && resetsAtMs > Date.now()) {
|
||||
shouldFallback = true;
|
||||
// Antigravity quota API provides exact per-model resetAt. Do not truncate it.
|
||||
cooldownMs = resolveProviderId(provider) === "antigravity"
|
||||
// Freebucks exhaustion is likewise a hard stop until the daily Pacific
|
||||
// reset (up to ~24h) — skip the account for the day rather than re-poke it
|
||||
// every 30 min; guard at 26h so a bad server value can't lock forever.
|
||||
const providerId = resolveProviderId(provider);
|
||||
cooldownMs = providerId === "antigravity"
|
||||
? resetsAtMs - Date.now()
|
||||
: Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
|
||||
: providerId === "freebuff"
|
||||
? Math.min(resetsAtMs - Date.now(), 26 * 60 * 60 * 1000)
|
||||
: Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
|
||||
newBackoffLevel = 0;
|
||||
} else {
|
||||
({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));
|
||||
|
||||
@@ -22,7 +22,7 @@ function isTruthyEnv(value) {
|
||||
return v === "1" || v === "true" || v === "yes" || v === "on";
|
||||
}
|
||||
|
||||
function isNonServerRuntime() {
|
||||
export function isNonServerRuntime() {
|
||||
if (typeof window !== "undefined") return true;
|
||||
const phase = process.env.NEXT_PHASE || "";
|
||||
if (
|
||||
@@ -55,6 +55,9 @@ export function selectConnectionsNeedingRefresh(connections, nowMs = Date.now())
|
||||
const authType = String(conn.authType || "").toLowerCase().replace(/_/g, "");
|
||||
if (authType !== "oauth") continue;
|
||||
if (!conn.refreshToken) continue;
|
||||
// Refresh token known-dead (invalid_grant/invalid_request) — stop retrying
|
||||
// every tick; surfaced as "re-login required" instead.
|
||||
if (conn.providerSpecificData?.refreshBlocked) continue;
|
||||
|
||||
const expiresAtMs = getCredentialExpiryMs(conn);
|
||||
if (expiresAtMs === null) continue;
|
||||
@@ -80,7 +83,27 @@ async function loadActiveConnections() {
|
||||
|
||||
async function refreshOne(connection) {
|
||||
const { checkAndRefreshToken } = await import("./tokenRefresh.js");
|
||||
return checkAndRefreshToken(connection.provider, connection, { force: true });
|
||||
const result = await checkAndRefreshToken(connection.provider, connection, { force: true });
|
||||
|
||||
// Dead refresh token (revoked/reused/expired): persist the block marker so
|
||||
// future ticks skip it, then surface the re-login requirement. The marker is
|
||||
// lifted by checkAndRefreshToken on the next successful refresh.
|
||||
if (result?.refreshError) {
|
||||
const { updateProviderConnection } = await import("../../lib/db/repos/connectionsRepo.js");
|
||||
await updateProviderConnection(connection.id, {
|
||||
providerSpecificData: {
|
||||
...(connection.providerSpecificData || {}),
|
||||
refreshBlocked: result.refreshError,
|
||||
refreshBlockedAt: result.refreshErrorAt,
|
||||
},
|
||||
});
|
||||
log.warn("BG_TOKEN_REFRESH", "Refresh token unrecoverable — auto-refresh stopped, re-login required", {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
error: result.refreshError,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
formatProviderCredentials as _formatProviderCredentials,
|
||||
getAllAccessTokens as _getAllAccessTokens,
|
||||
refreshKiroToken as _refreshKiroToken,
|
||||
getRefreshLeadMs as _getRefreshLeadMs
|
||||
getRefreshLeadMs as _getRefreshLeadMs,
|
||||
isUnrecoverableRefreshError,
|
||||
} from "open-sse/services/tokenRefresh.js";
|
||||
import {
|
||||
refreshProviderCredentials as _refreshProviderCredentials,
|
||||
@@ -243,10 +244,26 @@ export async function checkAndRefreshToken(provider, credentials, options = {})
|
||||
});
|
||||
|
||||
const newCreds = await _refreshProviderCredentials(provider, creds, log);
|
||||
if (isUnrecoverableRefreshError(newCreds)) {
|
||||
// Refresh token is dead (revoked/reused/expired) — retrying forever just
|
||||
// spams xAI's endpoint every tick. Tag the result so the background
|
||||
// scheduler can stop retrying and surface "re-login required".
|
||||
log.warn("TOKEN_REFRESH", `Refresh token unrecoverable for ${provider} — re-login required`, {
|
||||
error: newCreds.error,
|
||||
});
|
||||
return { ...creds, refreshError: newCreds.error, refreshErrorAt: new Date().toISOString() };
|
||||
}
|
||||
if (newCreds?.accessToken || newCreds?.apiKey || newCreds?.copilotToken) {
|
||||
const mergedCreds = {
|
||||
...newCreds,
|
||||
existingProviderSpecificData: creds.providerSpecificData,
|
||||
// Lift any previous refreshBlocked marker — the refresh just succeeded
|
||||
// (covers in-place re-auth flows that keep the same connection row).
|
||||
existingProviderSpecificData: {
|
||||
...(creds.providerSpecificData || {}),
|
||||
...(creds.providerSpecificData?.refreshBlocked
|
||||
? { refreshBlocked: undefined, refreshBlockedAt: undefined }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Persist to DB (non-blocking path continues below)
|
||||
|
||||
Reference in New Issue
Block a user