mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
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:
@@ -62,6 +62,18 @@ export const CONSOLE_LOG_CONFIG = {
|
||||
// Client-side store TTL: how long fetched data stays fresh before re-fetching
|
||||
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
|
||||
export {
|
||||
FREE_PROVIDERS,
|
||||
|
||||
@@ -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,
|
||||
} from "@/lib/tunnel";
|
||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
||||
import { startClaudeAutoPing } from "@/shared/services/claudeAutoPing";
|
||||
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
||||
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
||||
@@ -88,6 +89,7 @@ export async function initializeApp() {
|
||||
startWatchdog();
|
||||
startNetworkMonitor();
|
||||
autoStartMitm();
|
||||
startClaudeAutoPing();
|
||||
} catch (error) {
|
||||
console.error("[InitApp] Error:", error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user