feat: Claude auto-ping to warm 5h window after reset
Auto-sends a minimal request right after each Claude OAuth connection's 5h quota window resets, so a fresh window starts immediately without waiting. Per-connection toggle on providers and quota dashboards. - claudeAutoPing scheduler (server-side, 60s tick) hooked into initializeApp - per-connection enable map in settings.claudeAutoPing.connections - toggle + tooltip in ConnectionRow and ProviderLimits (Claude OAuth only) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d03f9fb823
commit
740093d852
6 changed files with 204 additions and 2 deletions
|
|
@ -3,10 +3,10 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Badge, Toggle } from "@/shared/components";
|
import { Badge, Toggle, Tooltip } from "@/shared/components";
|
||||||
import CooldownTimer from "./CooldownTimer";
|
import CooldownTimer from "./CooldownTimer";
|
||||||
|
|
||||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null }) {
|
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
|
||||||
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||||
const proxyDropdownRef = useRef(null);
|
const proxyDropdownRef = useRef(null);
|
||||||
|
|
@ -235,6 +235,17 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{autoPing && (
|
||||||
|
<Tooltip text="When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away.">
|
||||||
|
<button
|
||||||
|
onClick={() => autoPing.onToggle(!autoPing.on)}
|
||||||
|
className={`flex w-full flex-col items-center rounded px-2 py-1 transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${autoPing.on ? "text-primary" : "text-text-muted hover:text-primary"}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">bolt</span>
|
||||||
|
<span className="text-[10px] leading-tight">Auto-ping</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<button onClick={onEdit} className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5">
|
<button onClick={onEdit} className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5">
|
||||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||||
<span className="text-[10px] leading-tight">Edit</span>
|
<span className="text-[10px] leading-tight">Edit</span>
|
||||||
|
|
@ -288,4 +299,8 @@ ConnectionRow.propTypes = {
|
||||||
state: PropTypes.string,
|
state: PropTypes.string,
|
||||||
error: PropTypes.string,
|
error: PropTypes.string,
|
||||||
}),
|
}),
|
||||||
|
autoPing: PropTypes.shape({
|
||||||
|
on: PropTypes.bool,
|
||||||
|
onToggle: PropTypes.func,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export default function ProviderDetailPage() {
|
||||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||||
|
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||||
|
|
@ -258,6 +259,8 @@ export default function ProviderDetailPage() {
|
||||||
// Load per-provider thinking config
|
// Load per-provider thinking config
|
||||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||||
setThinkingMode(thinkingCfg.mode || "auto");
|
setThinkingMode(thinkingCfg.mode || "auto");
|
||||||
|
const apCfg = settingsData.claudeAutoPing || {};
|
||||||
|
setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} });
|
||||||
if (nodesRes.ok) {
|
if (nodesRes.ok) {
|
||||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||||
|
|
||||||
|
|
@ -370,6 +373,23 @@ export default function ProviderDetailPage() {
|
||||||
saveThinkingConfig(mode);
|
saveThinkingConfig(mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveAutoPing = async (next) => {
|
||||||
|
setAutoPing(next);
|
||||||
|
try {
|
||||||
|
await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ claudeAutoPing: next }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error saving auto-ping config:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoPingConnection = (connectionId, on) => {
|
||||||
|
saveAutoPing({ ...autoPing, connections: { ...autoPing.connections, [connectionId]: on } });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConnections();
|
fetchConnections();
|
||||||
fetchAliases();
|
fetchAliases();
|
||||||
|
|
@ -793,6 +813,10 @@ export default function ProviderDetailPage() {
|
||||||
onMoveUp={() => handleSwapPriority(index, index - 1)}
|
onMoveUp={() => handleSwapPriority(index, index - 1)}
|
||||||
onMoveDown={() => handleSwapPriority(index, index + 1)}
|
onMoveDown={() => handleSwapPriority(index, index + 1)}
|
||||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||||
|
autoPing={providerId === "claude" && conn.authType === "oauth" ? {
|
||||||
|
on: autoPing.connections[conn.id] === true,
|
||||||
|
onToggle: (on) => handleAutoPingConnection(conn.id, on),
|
||||||
|
} : null}
|
||||||
onUpdateProxy={async (proxyPoolId) => {
|
onUpdateProxy={async (proxyPoolId) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/providers/${conn.id}`, {
|
const res = await fetch(`/api/providers/${conn.id}`, {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export default function ProviderLimits() {
|
||||||
const [loading, setLoading] = useState({});
|
const [loading, setLoading] = useState({});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
|
const [autoPingMap, setAutoPingMap] = useState({});
|
||||||
const [lastUpdated, setLastUpdated] = useState(null);
|
const [lastUpdated, setLastUpdated] = useState(null);
|
||||||
const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false);
|
const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false);
|
||||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||||
|
|
@ -423,6 +424,31 @@ export default function ProviderLimits() {
|
||||||
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
|
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
|
||||||
}, [autoRefresh, hasHydratedAutoRefresh]);
|
}, [autoRefresh, hasHydratedAutoRefresh]);
|
||||||
|
|
||||||
|
// Load Claude auto-ping per-connection map
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/settings", { cache: "no-store" })
|
||||||
|
.then((r) => (r.ok ? r.json() : {}))
|
||||||
|
.then((s) => setAutoPingMap(s?.claudeAutoPing?.connections || {}))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleAutoPing = useCallback(async (connectionId, on) => {
|
||||||
|
const next = { ...autoPingMap, [connectionId]: on };
|
||||||
|
setAutoPingMap(next);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/settings", { cache: "no-store" });
|
||||||
|
const s = r.ok ? await r.json() : {};
|
||||||
|
const cfg = { ...(s.claudeAutoPing || {}), connections: next };
|
||||||
|
await fetch("/api/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ claudeAutoPing: cfg }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setAutoPingMap(autoPingMap);
|
||||||
|
}
|
||||||
|
}, [autoPingMap]);
|
||||||
|
|
||||||
// Auto-refresh interval
|
// Auto-refresh interval
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasHydratedAutoRefresh || !autoRefresh) {
|
if (!hasHydratedAutoRefresh || !autoRefresh) {
|
||||||
|
|
@ -793,6 +819,7 @@ export default function ProviderLimits() {
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
{/* Refresh all button */}
|
{/* Refresh all button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -898,6 +925,18 @@ export default function ProviderLimits() {
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{conn.provider === "claude" && conn.authType === "oauth" && (
|
||||||
|
<Tooltip text="When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away.">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleAutoPing(conn.id, !(autoPingMap[conn.id] === true))}
|
||||||
|
aria-label="Toggle auto-ping"
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${autoPingMap[conn.id] === true ? "text-primary" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">bolt</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip text="Refresh quota">
|
<Tooltip text="Refresh quota">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,18 @@ export const CONSOLE_LOG_CONFIG = {
|
||||||
// Client-side store TTL: how long fetched data stays fresh before re-fetching
|
// Client-side store TTL: how long fetched data stays fresh before re-fetching
|
||||||
export const CLIENT_STORE_TTL_MS = 60000;
|
export const CLIENT_STORE_TTL_MS = 60000;
|
||||||
|
|
||||||
|
// Claude auto-ping: keep 5h window warm by sending a tiny request right after reset
|
||||||
|
export const CLAUDE_AUTOPING_CONFIG = {
|
||||||
|
settingsKey: "claudeAutoPing", // settings table field
|
||||||
|
tickIntervalMs: 60000, // scheduler tick
|
||||||
|
pingLeadMs: 5000, // fire once reset passes (within tolerance)
|
||||||
|
pingModel: "claude-haiku-4-5-20251001", // cheapest model
|
||||||
|
pingText: "hi",
|
||||||
|
pingMaxTokens: 1,
|
||||||
|
refreshAheadMs: 300000, // refetch usage when within 5min of reset
|
||||||
|
fiveHourKey: "session (5h)", // quota key returned by usage handler
|
||||||
|
};
|
||||||
|
|
||||||
// Re-export from providers.js for backward compatibility
|
// Re-export from providers.js for backward compatibility
|
||||||
export {
|
export {
|
||||||
FREE_PROVIDERS,
|
FREE_PROVIDERS,
|
||||||
|
|
|
||||||
110
src/shared/services/claudeAutoPing.js
Normal file
110
src/shared/services/claudeAutoPing.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Claude auto-ping scheduler: warms the 5h window by sending a tiny request right after reset.
|
||||||
|
import "open-sse/index.js";
|
||||||
|
|
||||||
|
import { getSettings, getProviderConnections, updateProviderConnection } from "@/lib/localDb";
|
||||||
|
import { getClaudeUsage } from "open-sse/services/usage/claude.js";
|
||||||
|
import { CLAUDE_CLI_SPOOF_HEADERS } from "open-sse/providers/shared.js";
|
||||||
|
import { proxyAwareFetch } from "open-sse/utils/proxyFetch.js";
|
||||||
|
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||||
|
import { refreshAndUpdateCredentials } from "@/app/api/usage/[connectionId]/route.js";
|
||||||
|
import { CLAUDE_AUTOPING_CONFIG } from "@/shared/constants/config";
|
||||||
|
|
||||||
|
const C = CLAUDE_AUTOPING_CONFIG;
|
||||||
|
const PING_URL = "https://api.anthropic.com/v1/messages?beta=true";
|
||||||
|
|
||||||
|
const g = (global.__claudeAutoPing ??= { interval: null, running: false });
|
||||||
|
|
||||||
|
function buildProxyOptions(cfg) {
|
||||||
|
return {
|
||||||
|
connectionProxyEnabled: cfg.connectionProxyEnabled === true,
|
||||||
|
connectionProxyUrl: cfg.connectionProxyUrl || "",
|
||||||
|
connectionNoProxy: cfg.connectionNoProxy || "",
|
||||||
|
vercelRelayUrl: cfg.vercelRelayUrl || "",
|
||||||
|
strictProxy: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send minimal "hi" to start a fresh 5h window
|
||||||
|
async function sendPing(accessToken, proxyOptions) {
|
||||||
|
const res = await proxyAwareFetch(PING_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...CLAUDE_CLI_SPOOF_HEADERS,
|
||||||
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: C.pingModel,
|
||||||
|
max_tokens: C.pingMaxTokens,
|
||||||
|
messages: [{ role: "user", content: C.pingText }],
|
||||||
|
}),
|
||||||
|
}, proxyOptions);
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pingConnection(conn) {
|
||||||
|
const proxyCfg = await resolveConnectionProxyConfig(conn.providerSpecificData);
|
||||||
|
const proxyOptions = buildProxyOptions(proxyCfg);
|
||||||
|
|
||||||
|
// Refresh token if needed, then read 5h reset time
|
||||||
|
let connection = conn;
|
||||||
|
try {
|
||||||
|
const r = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||||
|
connection = r.connection;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[AutoPing] ${conn.id}: refresh failed: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = await getClaudeUsage(connection.accessToken, proxyOptions);
|
||||||
|
const resetAt = usage?.quotas?.[C.fiveHourKey]?.resetAt;
|
||||||
|
if (!resetAt) return;
|
||||||
|
|
||||||
|
const resetMs = new Date(resetAt).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Only ping once per reset cycle, right after window flips
|
||||||
|
if (now < resetMs - C.pingLeadMs) return;
|
||||||
|
if (connection.lastPingedResetAt === resetAt) return;
|
||||||
|
|
||||||
|
const ok = await sendPing(connection.accessToken, proxyOptions);
|
||||||
|
await updateProviderConnection(connection.id, {
|
||||||
|
lastPingedResetAt: resetAt,
|
||||||
|
lastPingAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
console.log(`[AutoPing] ${connection.id}: ping ${ok ? "sent" : "failed"} (reset ${resetAt})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (g.running) return;
|
||||||
|
g.running = true;
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const enabledMap = settings[C.settingsKey]?.connections || {};
|
||||||
|
if (Object.keys(enabledMap).length === 0) return;
|
||||||
|
|
||||||
|
const conns = await getProviderConnections({ provider: "claude", isActive: true });
|
||||||
|
// Only ping connections the user explicitly enabled
|
||||||
|
const targets = conns.filter((c) => c.authType === "oauth" && enabledMap[c.id] === true);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
|
for (const conn of targets) {
|
||||||
|
try {
|
||||||
|
await pingConnection(conn);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[AutoPing] ${conn.id}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[AutoPing] tick error:", e.message);
|
||||||
|
} finally {
|
||||||
|
g.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startClaudeAutoPing() {
|
||||||
|
if (g.interval) return;
|
||||||
|
g.interval = setInterval(() => { tick().catch(() => {}); }, C.tickIntervalMs);
|
||||||
|
if (g.interval.unref) g.interval.unref();
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
|
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
|
||||||
} from "@/lib/tunnel";
|
} from "@/lib/tunnel";
|
||||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
||||||
|
import { startClaudeAutoPing } from "@/shared/services/claudeAutoPing";
|
||||||
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
||||||
|
|
||||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
||||||
|
|
@ -88,6 +89,7 @@ export async function initializeApp() {
|
||||||
startWatchdog();
|
startWatchdog();
|
||||||
startNetworkMonitor();
|
startNetworkMonitor();
|
||||||
autoStartMitm();
|
autoStartMitm();
|
||||||
|
startClaudeAutoPing();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[InitApp] Error:", error);
|
console.error("[InitApp] Error:", error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue