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:
asepharyana
2026-09-23 11:44:58 +07:00
96 changed files with 7709 additions and 1787 deletions
@@ -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)) {
+1 -1
View File
@@ -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,
+37 -4
View File
@@ -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;
}
}
}
}
+40 -4
View File
@@ -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 });
+2 -1
View File
@@ -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";
+4
View File
@@ -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 });
}
}
+14
View File
@@ -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 });
}
}
+3
View File
@@ -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 });
+62
View File
@@ -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 });
}
}
+1 -1
View File
@@ -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 || "",
+1
View File
@@ -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_";
+117 -45
View File
@@ -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,
+115
View File
@@ -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 };
+47
View File
@@ -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 };
+3
View File
@@ -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}
+16
View File
@@ -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: {},
}),
};
+131
View File
@@ -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;
+40 -1
View File
@@ -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() {
+9 -10
View File
@@ -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>
);
}
+4 -1
View File
@@ -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>
+1
View File
@@ -291,6 +291,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
"qoder",
"qoder-cn",
"grok-cli",
"freebuff",
];
if (deviceCodeProviders.includes(provider)) {
setIsDeviceCode(true);
+12
View File
@@ -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 */}
+6 -14
View File
@@ -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(() => {});
}, []);
+10
View File
@@ -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) {
+38
View File
@@ -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
View File
@@ -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) => {
+47 -8
View File
@@ -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));
+25 -2
View File
@@ -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;
}
/**
+19 -2
View File
@@ -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)