feat(quota): add opt-in Codex auto-ping
Generalize Claude 5h auto-ping into a provider-generic scheduler and add opt-in Codex auto-ping that warms the next 5h window via a tiny gpt-5.5 request when session.resetAt slides. Default off, per-connection toggle, failure cooldown, blocking-quota skip, drains stream before success. Closes #2107 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,9 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
: hasLegacyProxy
|
||||
? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`
|
||||
: "";
|
||||
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) {
|
||||
@@ -244,7 +247,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
</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.">
|
||||
<Tooltip text={autoPingTooltip}>
|
||||
<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"}`}
|
||||
@@ -310,5 +313,6 @@ ConnectionRow.propTypes = {
|
||||
autoPing: PropTypes.shape({
|
||||
on: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
provider: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -23,6 +23,11 @@ import BulkImportCodexModal from "./BulkImportCodexModal";
|
||||
|
||||
const ONE_BY_ONE_DELAY_MS = 1000;
|
||||
|
||||
const AUTO_PING_SETTINGS_KEYS = {
|
||||
claude: "claudeAutoPing",
|
||||
codex: "codexAutoPing",
|
||||
};
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -273,7 +278,8 @@ export default function ProviderDetailPage() {
|
||||
// Load per-provider thinking config
|
||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||
setThinkingMode(thinkingCfg.mode || "auto");
|
||||
const apCfg = settingsData.claudeAutoPing || {};
|
||||
const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId];
|
||||
const apCfg = autoPingSettingsKey ? settingsData[autoPingSettingsKey] || {} : {};
|
||||
setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} });
|
||||
if (nodesRes.ok) {
|
||||
let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null;
|
||||
@@ -388,12 +394,15 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
const saveAutoPing = async (next) => {
|
||||
const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId];
|
||||
if (!autoPingSettingsKey) return;
|
||||
|
||||
setAutoPing(next);
|
||||
try {
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ claudeAutoPing: next }),
|
||||
body: JSON.stringify({ [autoPingSettingsKey]: next }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving auto-ping config:", error);
|
||||
@@ -888,9 +897,10 @@ export default function ProviderDetailPage() {
|
||||
onMoveUp={() => handleSwapPriority(index, index - 1)}
|
||||
onMoveDown={() => handleSwapPriority(index, index + 1)}
|
||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||
autoPing={providerId === "claude" && conn.authType === "oauth" ? {
|
||||
autoPing={AUTO_PING_SETTINGS_KEYS[providerId] && conn.authType === "oauth" ? {
|
||||
on: autoPing.connections[conn.id] === true,
|
||||
onToggle: (on) => handleAutoPingConnection(conn.id, on),
|
||||
provider: providerId,
|
||||
} : null}
|
||||
onUpdateProxy={async (proxyPoolId) => {
|
||||
try {
|
||||
|
||||
@@ -52,6 +52,16 @@ const KIRO_METHOD_LABELS = {
|
||||
api_key: "API Key",
|
||||
};
|
||||
|
||||
const AUTO_PING_SETTINGS_KEYS = {
|
||||
claude: "claudeAutoPing",
|
||||
codex: "codexAutoPing",
|
||||
};
|
||||
|
||||
const AUTO_PING_TOOLTIPS = {
|
||||
claude: "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away.",
|
||||
codex: "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota.",
|
||||
};
|
||||
|
||||
function kiroMethodLabel(conn) {
|
||||
const m = conn.providerSpecificData?.authMethod;
|
||||
if (m && KIRO_METHOD_LABELS[m]) return KIRO_METHOD_LABELS[m];
|
||||
@@ -94,7 +104,7 @@ export default function ProviderLimits() {
|
||||
const [loading, setLoading] = useState({});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [autoPingMap, setAutoPingMap] = useState({});
|
||||
const [autoPingMaps, setAutoPingMaps] = useState({ claude: {}, codex: {} });
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false);
|
||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||
@@ -477,30 +487,38 @@ export default function ProviderLimits() {
|
||||
window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh));
|
||||
}, [autoRefresh, hasHydratedAutoRefresh]);
|
||||
|
||||
// Load Claude auto-ping per-connection map
|
||||
// Load auto-ping per-connection maps
|
||||
useEffect(() => {
|
||||
fetch("/api/settings", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.then((s) => setAutoPingMap(s?.claudeAutoPing?.connections || {}))
|
||||
.then((s) => setAutoPingMaps({
|
||||
claude: s?.claudeAutoPing?.connections || {},
|
||||
codex: s?.codexAutoPing?.connections || {},
|
||||
}))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleAutoPing = useCallback(async (connectionId, on) => {
|
||||
const next = { ...autoPingMap, [connectionId]: on };
|
||||
setAutoPingMap(next);
|
||||
const toggleAutoPing = useCallback(async (connectionId, provider, on) => {
|
||||
const settingsKey = AUTO_PING_SETTINGS_KEYS[provider];
|
||||
if (!settingsKey) return;
|
||||
|
||||
const previous = autoPingMaps;
|
||||
const nextProviderMap = { ...(autoPingMaps[provider] || {}), [connectionId]: on };
|
||||
const nextMaps = { ...autoPingMaps, [provider]: nextProviderMap };
|
||||
setAutoPingMaps(nextMaps);
|
||||
try {
|
||||
const r = await fetch("/api/settings", { cache: "no-store" });
|
||||
const s = r.ok ? await r.json() : {};
|
||||
const cfg = { ...(s.claudeAutoPing || {}), connections: next };
|
||||
const cfg = { ...(s[settingsKey] || {}), connections: nextProviderMap };
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ claudeAutoPing: cfg }),
|
||||
body: JSON.stringify({ [settingsKey]: cfg }),
|
||||
});
|
||||
} catch {
|
||||
setAutoPingMap(autoPingMap);
|
||||
setAutoPingMaps(previous);
|
||||
}
|
||||
}, [autoPingMap]);
|
||||
}, [autoPingMaps]);
|
||||
|
||||
// Auto-refresh interval
|
||||
useEffect(() => {
|
||||
@@ -945,11 +963,6 @@ export default function ProviderLimits() {
|
||||
{getConnectionSecondaryLabel(conn)}
|
||||
</p>
|
||||
) : null}
|
||||
{isCodex && (
|
||||
<p className="text-[11px] text-text-muted truncate">
|
||||
Reset eligible: {resetCreditCount}
|
||||
</p>
|
||||
)}
|
||||
{conn.provider === "kiro" && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="rounded-full bg-brand-500/10 px-2 py-0.5 text-[10px] font-semibold text-brand-600 dark:text-brand-300">
|
||||
@@ -995,41 +1008,42 @@ export default function ProviderLimits() {
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isCodex && (
|
||||
<Tooltip text={`Codex reset credits remaining: ${resetCreditCount}`}>
|
||||
<div
|
||||
className={`hidden h-8 items-center gap-1 rounded-lg border px-2 text-[11px] sm:flex ${
|
||||
resetCreditCount > 0
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
|
||||
<span className="tabular-nums">{resetCreditCount}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isCodex && resetCreditCount > 0 && (
|
||||
<Tooltip text={`Use one Codex reset credit. Available: ${resetCreditCount}`}>
|
||||
<Tooltip
|
||||
text={
|
||||
resetCreditCount > 0
|
||||
? `Use one Codex reset credit. Available: ${resetCreditCount}`
|
||||
: "No Codex reset credits available"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetConfirmState({ connection: conn, resetCreditCount })}
|
||||
disabled={isLoading || rowBusy}
|
||||
className="flex h-8 items-center gap-1 rounded-lg border border-primary/30 px-2 text-[11px] text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
|
||||
disabled={resetCreditCount <= 0 || isLoading || rowBusy}
|
||||
aria-label={
|
||||
resetCreditCount > 0
|
||||
? `Use one Codex reset credit. ${resetCreditCount} available.`
|
||||
: "No Codex reset credits available"
|
||||
}
|
||||
className={`flex h-8 min-w-10 items-center justify-center gap-1 rounded-lg border px-2 text-[11px] font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60 disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||
resetCreditCount > 0
|
||||
? "border-primary/30 bg-primary/5 text-primary hover:bg-primary/10"
|
||||
: "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[15px] ${isResettingLimit ? "animate-spin" : ""}`}>
|
||||
{isResettingLimit ? "progress_activity" : "bolt"}
|
||||
{isResettingLimit ? "progress_activity" : "restart_alt"}
|
||||
</span>
|
||||
<span className="hidden lg:inline">Reset limit</span>
|
||||
<span>{resetCreditCount}</span>
|
||||
</button>
|
||||
</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.">
|
||||
{AUTO_PING_SETTINGS_KEYS[conn.provider] && conn.authType === "oauth" && (
|
||||
<Tooltip text={AUTO_PING_TOOLTIPS[conn.provider]}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAutoPing(conn.id, !(autoPingMap[conn.id] === true))}
|
||||
onClick={() => toggleAutoPing(conn.id, conn.provider, !(autoPingMaps[conn.provider]?.[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"}`}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5 ${autoPingMaps[conn.provider]?.[conn.id] === true ? "text-primary" : "text-text-muted"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">bolt</span>
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
|
||||
import { resetComboRotation } from "open-sse/services/combo.js";
|
||||
import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -96,6 +97,16 @@ export async function PATCH(request) {
|
||||
resetComboRotation();
|
||||
}
|
||||
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(body, "claudeAutoPing") ||
|
||||
Object.prototype.hasOwnProperty.call(body, "codexAutoPing")
|
||||
) {
|
||||
// Run once immediately after opt-in changes so users don't wait for the next scheduler tick.
|
||||
runQuotaAutoPingTick().catch((error) => {
|
||||
console.warn("[AutoPing] settings-triggered tick failed:", error.message);
|
||||
});
|
||||
}
|
||||
|
||||
const { password, oidcClientSecret, ...safeSettings } = settings;
|
||||
safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret);
|
||||
return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS });
|
||||
|
||||
@@ -62,16 +62,34 @@ 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
|
||||
// Quota auto-ping: keep 5h windows warm by sending a tiny request right after reset.
|
||||
export const QUOTA_AUTOPING_CONFIG = {
|
||||
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
|
||||
failureCooldownMs: 900000, // avoid failed ping spam while upstream/auth is unhealthy
|
||||
providers: {
|
||||
claude: {
|
||||
settingsKey: "claudeAutoPing", // preserve existing settings contract
|
||||
quotaKey: "session (5h)", // quota key returned by usage handler
|
||||
pingModel: "claude-haiku-4-5-20251001",
|
||||
pingText: "hi",
|
||||
pingMaxTokens: 1,
|
||||
},
|
||||
codex: {
|
||||
settingsKey: "codexAutoPing",
|
||||
quotaKey: "session",
|
||||
pingWhenResetAtSlides: true,
|
||||
resetAtDriftMs: 30000,
|
||||
minPingIntervalMs: 600000,
|
||||
skipWhenBlockingQuotaExhausted: true,
|
||||
// Free and Plus Codex accounts both expose gpt-5.5; avoid fallback probes that waste requests.
|
||||
pingModel: "gpt-5.5",
|
||||
pingText: "hi",
|
||||
pingInstructions: "Reply with OK.",
|
||||
pingReasoningEffort: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Re-export from providers.js for backward compatibility
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
// 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, resetCache: {} });
|
||||
|
||||
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) {
|
||||
// Cached resetAt is stable for the whole 5h window; skip usage poll until near reset
|
||||
const cachedReset = g.resetCache[conn.id];
|
||||
if (cachedReset && Date.now() < new Date(cachedReset).getTime() - C.refreshAheadMs) return;
|
||||
|
||||
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;
|
||||
|
||||
// Cache resetAt to gate future ticks
|
||||
g.resetCache[conn.id] = resetAt;
|
||||
|
||||
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,7 +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 { startQuotaAutoPing } from "@/shared/services/quotaAutoPing";
|
||||
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
||||
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
||||
@@ -89,7 +89,7 @@ export async function initializeApp() {
|
||||
startWatchdog();
|
||||
startNetworkMonitor();
|
||||
autoStartMitm();
|
||||
startClaudeAutoPing();
|
||||
startQuotaAutoPing();
|
||||
} catch (error) {
|
||||
console.error("[InitApp] Error:", error);
|
||||
}
|
||||
|
||||
298
src/shared/services/quotaAutoPing.js
Normal file
298
src/shared/services/quotaAutoPing.js
Normal file
@@ -0,0 +1,298 @@
|
||||
// Quota auto-ping scheduler: warms 5h windows by sending tiny opt-in requests 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 { getCodexUsage } from "open-sse/services/usage/codex.js";
|
||||
import { getExecutor } from "open-sse/executors/index.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 { QUOTA_AUTOPING_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
const C = QUOTA_AUTOPING_CONFIG;
|
||||
const CLAUDE_PING_URL = "https://api.anthropic.com/v1/messages?beta=true";
|
||||
|
||||
const providerHandlers = {
|
||||
claude: {
|
||||
getUsage: getClaudeUsage,
|
||||
sendPing: sendClaudePing,
|
||||
},
|
||||
codex: {
|
||||
getUsage: getCodexUsage,
|
||||
sendPing: sendCodexPing,
|
||||
},
|
||||
};
|
||||
|
||||
// Survive Next.js hot reload and keep one scheduler per server process.
|
||||
const g = (global.__quotaAutoPing ??= {
|
||||
interval: null,
|
||||
running: false,
|
||||
resetCache: {},
|
||||
failureCache: {},
|
||||
});
|
||||
|
||||
function cacheKey(provider, connectionId) {
|
||||
return `${provider}:${connectionId}`;
|
||||
}
|
||||
|
||||
function normalizeResetKey(resetAt) {
|
||||
const ms = new Date(resetAt).getTime();
|
||||
if (!Number.isFinite(ms)) return resetAt;
|
||||
return new Date(Math.floor(ms / 60000) * 60000).toISOString();
|
||||
}
|
||||
|
||||
function getResetDriftMs(previousResetAt, nextResetAt) {
|
||||
const previousMs = new Date(previousResetAt).getTime();
|
||||
const nextMs = new Date(nextResetAt).getTime();
|
||||
if (!Number.isFinite(previousMs) || !Number.isFinite(nextMs)) return 0;
|
||||
return nextMs - previousMs;
|
||||
}
|
||||
|
||||
function toFiniteNumber(value, fallback = null) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isQuotaExhausted(quota) {
|
||||
if (!quota || quota.unlimited === true) return false;
|
||||
const remaining = toFiniteNumber(quota.remaining);
|
||||
if (remaining !== null) return remaining <= 0;
|
||||
|
||||
const used = toFiniteNumber(quota.used);
|
||||
const total = toFiniteNumber(quota.total);
|
||||
return total !== null && total > 0 && used !== null && used >= total;
|
||||
}
|
||||
|
||||
function wasPingedRecently(connection, intervalMs, nowMs = Date.now()) {
|
||||
if (!intervalMs) return false;
|
||||
const lastPingAtMs = new Date(connection.lastPingAt).getTime();
|
||||
return Number.isFinite(lastPingAtMs) && nowMs - lastPingAtMs < intervalMs;
|
||||
}
|
||||
|
||||
function isBlockingQuotaName(name, sessionKey) {
|
||||
if (name === sessionKey) return false;
|
||||
return !String(name).toLowerCase().includes("session");
|
||||
}
|
||||
|
||||
function hasExhaustedBlockingQuota(quotas, sessionKey) {
|
||||
return Object.entries(quotas || {}).some(([name, quota]) => isBlockingQuotaName(name, sessionKey) && isQuotaExhausted(quota));
|
||||
}
|
||||
|
||||
function shouldPingForReset(providerConfig, cachedReset, resetAt, now) {
|
||||
if (providerConfig.pingWhenResetAtSlides) {
|
||||
return Boolean(cachedReset) && getResetDriftMs(cachedReset, resetAt) >= (providerConfig.resetAtDriftMs || 0);
|
||||
}
|
||||
|
||||
const resetMs = new Date(resetAt).getTime();
|
||||
return Number.isFinite(resetMs) && now >= resetMs - C.pingLeadMs;
|
||||
}
|
||||
|
||||
function buildProxyOptions(cfg) {
|
||||
return {
|
||||
connectionProxyEnabled: cfg.connectionProxyEnabled === true,
|
||||
connectionProxyUrl: cfg.connectionProxyUrl || "",
|
||||
connectionNoProxy: cfg.connectionNoProxy || "",
|
||||
vercelRelayUrl: cfg.vercelRelayUrl || "",
|
||||
strictProxy: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendClaudePing(connection, providerConfig, proxyOptions, deps) {
|
||||
const res = await deps.proxyAwareFetch(CLAUDE_PING_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...CLAUDE_CLI_SPOOF_HEADERS,
|
||||
"Authorization": `Bearer ${connection.accessToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: providerConfig.pingModel,
|
||||
max_tokens: providerConfig.pingMaxTokens,
|
||||
messages: [{ role: "user", content: providerConfig.pingText }],
|
||||
}),
|
||||
}, proxyOptions);
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
function buildCodexPingInput(text) {
|
||||
return [{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text }],
|
||||
}];
|
||||
}
|
||||
|
||||
async function drainResponseBody(response) {
|
||||
if (typeof response?.text === "function") {
|
||||
await response.text();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response?.body?.getReader?.();
|
||||
if (!reader) return;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done } = await reader.read();
|
||||
if (done) return;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendCodexPing(connection, providerConfig, proxyOptions, deps) {
|
||||
const executor = deps.getExecutor("codex");
|
||||
const { response } = await executor.execute({
|
||||
model: providerConfig.pingModel,
|
||||
stream: true,
|
||||
credentials: {
|
||||
accessToken: connection.accessToken,
|
||||
connectionId: connection.id,
|
||||
providerSpecificData: connection.providerSpecificData,
|
||||
},
|
||||
proxyOptions,
|
||||
log: console,
|
||||
body: {
|
||||
model: providerConfig.pingModel,
|
||||
input: buildCodexPingInput(providerConfig.pingText),
|
||||
instructions: providerConfig.pingInstructions,
|
||||
reasoning: providerConfig.pingReasoningEffort
|
||||
? { effort: providerConfig.pingReasoningEffort, summary: "auto" }
|
||||
: undefined,
|
||||
store: false,
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
try { await response.body?.cancel?.(); } catch { /* noop */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Codex only starts the 5h window after the streaming response completes.
|
||||
await drainResponseBody(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSkipAfterFailure(state, key, nowMs = Date.now()) {
|
||||
const failedAt = state.failureCache[key];
|
||||
return failedAt && nowMs - failedAt < C.failureCooldownMs;
|
||||
}
|
||||
|
||||
async function pingConnection(conn, provider, providerConfig, handler, deps, state = g) {
|
||||
const key = cacheKey(provider, conn.id);
|
||||
|
||||
// resetAt is stable for time-based windows; Codex polls every tick because inactive windows slide forward.
|
||||
const cachedReset = state.resetCache[key];
|
||||
if (!providerConfig.pingWhenResetAtSlides && cachedReset && Date.now() < new Date(cachedReset).getTime() - C.refreshAheadMs) return;
|
||||
|
||||
// Avoid hammering provider auth/quota endpoints if a ping failed recently.
|
||||
if (shouldSkipAfterFailure(state, key)) return;
|
||||
|
||||
const proxyCfg = await deps.resolveConnectionProxyConfig(conn.providerSpecificData);
|
||||
const proxyOptions = buildProxyOptions(proxyCfg);
|
||||
|
||||
let connection = conn;
|
||||
try {
|
||||
const r = await deps.refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||
connection = r.connection;
|
||||
} catch (e) {
|
||||
state.failureCache[key] = Date.now();
|
||||
console.warn(`[AutoPing] ${provider}:${conn.id}: refresh failed: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = await handler.getUsage(connection.accessToken, proxyOptions);
|
||||
const quotas = usage?.quotas || {};
|
||||
const quota = quotas?.[providerConfig.quotaKey];
|
||||
const resetAt = quota?.resetAt;
|
||||
if (!resetAt) return;
|
||||
|
||||
state.resetCache[key] = resetAt;
|
||||
|
||||
if (providerConfig.skipWhenBlockingQuotaExhausted && hasExhaustedBlockingQuota(quotas, providerConfig.quotaKey)) return;
|
||||
if (isQuotaExhausted(quota)) return;
|
||||
|
||||
const now = Date.now();
|
||||
const resetKey = normalizeResetKey(resetAt);
|
||||
const lastPingedResetKey = connection.lastPingedResetKey || normalizeResetKey(connection.lastPingedResetAt);
|
||||
|
||||
// Claude waits for reset. Codex pings only when resetAt slides, which means the 5h window is inactive.
|
||||
if (!shouldPingForReset(providerConfig, cachedReset, resetAt, now)) return;
|
||||
if (wasPingedRecently(connection, providerConfig.minPingIntervalMs, now)) return;
|
||||
if (lastPingedResetKey === resetKey) return;
|
||||
|
||||
const ok = await handler.sendPing(connection, providerConfig, proxyOptions, deps);
|
||||
if (!ok) {
|
||||
// Do not mark reset as pinged unless upstream accepted the tiny request.
|
||||
state.failureCache[key] = Date.now();
|
||||
console.warn(`[AutoPing] ${provider}:${connection.id}: ping failed (reset ${resetAt})`);
|
||||
return;
|
||||
}
|
||||
|
||||
delete state.failureCache[key];
|
||||
await deps.updateProviderConnection(connection.id, {
|
||||
lastPingedResetAt: resetAt,
|
||||
lastPingedResetKey: resetKey,
|
||||
lastPingAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[AutoPing] ${provider}:${connection.id}: ping sent (reset ${resetAt})`);
|
||||
}
|
||||
|
||||
function createDefaultDeps() {
|
||||
return {
|
||||
getSettings,
|
||||
getProviderConnections,
|
||||
updateProviderConnection,
|
||||
resolveConnectionProxyConfig,
|
||||
refreshAndUpdateCredentials,
|
||||
proxyAwareFetch,
|
||||
getExecutor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runQuotaAutoPingTick(deps = createDefaultDeps(), state = g) {
|
||||
if (state.running) return;
|
||||
state.running = true;
|
||||
try {
|
||||
const settings = await deps.getSettings();
|
||||
|
||||
for (const [provider, providerConfig] of Object.entries(C.providers)) {
|
||||
const handler = providerHandlers[provider];
|
||||
if (!handler) continue;
|
||||
|
||||
const enabledMap = settings?.[providerConfig.settingsKey]?.connections || {};
|
||||
if (Object.keys(enabledMap).length === 0) continue;
|
||||
|
||||
const conns = await deps.getProviderConnections({ provider, isActive: true });
|
||||
const targets = conns.filter((conn) => conn.authType === "oauth" && enabledMap[conn.id] === true);
|
||||
for (const conn of targets) {
|
||||
try {
|
||||
await pingConnection(conn, provider, providerConfig, handler, deps, state);
|
||||
} catch (e) {
|
||||
state.failureCache[cacheKey(provider, conn.id)] = Date.now();
|
||||
console.warn(`[AutoPing] ${provider}:${conn.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[AutoPing] tick error:", e.message);
|
||||
} finally {
|
||||
state.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startQuotaAutoPing() {
|
||||
if (g.interval) return;
|
||||
console.log("[AutoPing] scheduler started");
|
||||
runQuotaAutoPingTick().catch(() => {});
|
||||
g.interval = setInterval(() => { runQuotaAutoPingTick().catch(() => {}); }, C.tickIntervalMs);
|
||||
if (g.interval.unref) g.interval.unref();
|
||||
}
|
||||
351
tests/unit/quota-auto-ping.test.js
Normal file
351
tests/unit/quota-auto-ping.test.js
Normal file
@@ -0,0 +1,351 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("open-sse/index.js", () => ({}), { virtual: true });
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: vi.fn(),
|
||||
getProviderConnections: vi.fn(),
|
||||
updateProviderConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/network/connectionProxy", () => ({
|
||||
resolveConnectionProxyConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({
|
||||
refreshAndUpdateCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/constants/config", () => ({
|
||||
QUOTA_AUTOPING_CONFIG: {
|
||||
tickIntervalMs: 60000,
|
||||
pingLeadMs: 5000,
|
||||
refreshAheadMs: 300000,
|
||||
failureCooldownMs: 900000,
|
||||
providers: {
|
||||
claude: {
|
||||
settingsKey: "claudeAutoPing",
|
||||
quotaKey: "session (5h)",
|
||||
pingModel: "claude-haiku-4-5-20251001",
|
||||
pingText: "hi",
|
||||
pingMaxTokens: 1,
|
||||
},
|
||||
codex: {
|
||||
settingsKey: "codexAutoPing",
|
||||
quotaKey: "session",
|
||||
pingWhenResetAtSlides: true,
|
||||
resetAtDriftMs: 30000,
|
||||
minPingIntervalMs: 600000,
|
||||
skipWhenBlockingQuotaExhausted: true,
|
||||
pingModel: "gpt-5.5",
|
||||
pingText: "hi",
|
||||
pingInstructions: "Reply with OK.",
|
||||
pingReasoningEffort: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/providers/shared.js", () => ({
|
||||
CLAUDE_CLI_SPOOF_HEADERS: { "anthropic-version": "2023-06-01" },
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/services/usage/shared.js", () => ({
|
||||
U: () => ({ baseUrl: "https://chatgpt.com/backend-api/codex/responses" }),
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/services/usage/claude.js", () => ({
|
||||
getClaudeUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/services/usage/codex.js", () => ({
|
||||
getCodexUsage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("open-sse/executors/index.js", () => ({
|
||||
getExecutor: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("quota auto-ping", () => {
|
||||
let runQuotaAutoPingTick;
|
||||
let deps;
|
||||
let state;
|
||||
let getCodexUsage;
|
||||
let getClaudeUsage;
|
||||
let getExecutor;
|
||||
let codexResponseText;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
|
||||
({ getCodexUsage } = await import("open-sse/services/usage/codex.js"));
|
||||
({ getClaudeUsage } = await import("open-sse/services/usage/claude.js"));
|
||||
({ getExecutor } = await import("open-sse/executors/index.js"));
|
||||
({ runQuotaAutoPingTick } = await import("../../src/shared/services/quotaAutoPing.js"));
|
||||
|
||||
deps = {
|
||||
getSettings: vi.fn(),
|
||||
getProviderConnections: vi.fn(),
|
||||
updateProviderConnection: vi.fn(),
|
||||
resolveConnectionProxyConfig: vi.fn().mockResolvedValue({}),
|
||||
refreshAndUpdateCredentials: vi.fn(async (connection) => ({ connection, refreshed: false })),
|
||||
proxyAwareFetch: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getExecutor: vi.fn(() => ({
|
||||
execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }),
|
||||
})),
|
||||
};
|
||||
codexResponseText = vi.fn().mockResolvedValue("");
|
||||
getExecutor.mockReturnValue({
|
||||
execute: vi.fn().mockResolvedValue({ response: { ok: true, text: codexResponseText } }),
|
||||
});
|
||||
state = { running: false, resetCache: {}, failureCache: {} };
|
||||
vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("does not ping Codex when setting is absent", async () => {
|
||||
deps.getSettings.mockResolvedValue({});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getProviderConnections).not.toHaveBeenCalled();
|
||||
expect(deps.proxyAwareFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ping Codex on the first resetAt observation", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 1, resetAt: "2026-01-01T13:00:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
|
||||
expect(state.resetCache["codex:codex-1"]).toBe("2026-01-01T13:00:00.000Z");
|
||||
});
|
||||
|
||||
it("sends Codex ping when session resetAt slides", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
const executor = deps.getExecutor.mock.results[0].value;
|
||||
expect(executor.execute).toHaveBeenCalledTimes(1);
|
||||
expect(deps.updateProviderConnection).toHaveBeenCalledWith("codex-1", expect.objectContaining({
|
||||
lastPingedResetAt: "2026-01-01T17:01:00.000Z",
|
||||
lastPingedResetKey: "2026-01-01T17:01:00.000Z",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not ping Codex when resetAt is stable", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:00:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not repeat Codex ping inside the minimum ping interval", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex"
|
||||
? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", lastPingAt: "2026-01-01T11:55:00.000Z" }]
|
||||
: []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ping Codex just because reported usage is zero", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 0, resetAt: "2026-01-01T17:00:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
|
||||
expect(state.resetCache["codex:codex-1"]).toBe("2026-01-01T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("does not ping Codex when weekly quota is exhausted", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: {
|
||||
session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T17:01:00.000Z" },
|
||||
weekly: { used: 100, total: 100, remaining: 0, resetAt: "2026-01-03T12:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ping Codex when monthly quota is exhausted", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: {
|
||||
session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T17:01:00.000Z" },
|
||||
monthly: { used: 100, total: 100, remaining: 0, resetAt: "2026-02-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not ping Codex when session quota is exhausted", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 100, total: 100, remaining: 0, resetAt: "2026-01-01T17:01:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
expect(deps.updateProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends one tiny gpt-5.5 Codex request through the executor", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex"
|
||||
? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", providerSpecificData: { workspaceId: "ws-1" } }]
|
||||
: []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
const executor = deps.getExecutor.mock.results[0].value;
|
||||
expect(deps.getExecutor).toHaveBeenCalledWith("codex");
|
||||
expect(executor.execute).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: "gpt-5.5",
|
||||
stream: true,
|
||||
credentials: expect.objectContaining({
|
||||
accessToken: "token",
|
||||
connectionId: "codex-1",
|
||||
providerSpecificData: { workspaceId: "ws-1" },
|
||||
}),
|
||||
body: {
|
||||
model: "gpt-5.5",
|
||||
input: [{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hi" }],
|
||||
}],
|
||||
instructions: "Reply with OK.",
|
||||
reasoning: { effort: "none", summary: "auto" },
|
||||
store: false,
|
||||
stream: true,
|
||||
},
|
||||
}));
|
||||
expect(codexResponseText).toHaveBeenCalledTimes(1);
|
||||
expect(deps.updateProviderConnection).toHaveBeenCalledWith("codex-1", expect.objectContaining({
|
||||
lastPingedResetAt: "2026-01-01T17:01:00.000Z",
|
||||
lastPingedResetKey: "2026-01-01T17:01:00.000Z",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not ping same Codex reset twice when seconds drift", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex"
|
||||
? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token", lastPingedResetAt: "2026-01-01T11:59:44.000Z" }]
|
||||
: []
|
||||
));
|
||||
state.resetCache["codex:codex-1"] = "2026-01-01T11:59:44.000Z";
|
||||
getCodexUsage.mockResolvedValue({
|
||||
quotas: { session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T11:59:47.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips non-OAuth Codex connections", async () => {
|
||||
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "codex" ? [{ id: "codex-1", provider: "codex", authType: "apikey", accessToken: "token" }] : []
|
||||
));
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(getCodexUsage).not.toHaveBeenCalled();
|
||||
expect(deps.getExecutor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Claude session quota key behavior", async () => {
|
||||
deps.getSettings.mockResolvedValue({ claudeAutoPing: { connections: { "claude-1": true } } });
|
||||
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
|
||||
provider === "claude" ? [{ id: "claude-1", provider: "claude", authType: "oauth", accessToken: "token" }] : []
|
||||
));
|
||||
getClaudeUsage.mockResolvedValue({
|
||||
quotas: { "session (5h)": { resetAt: "2026-01-01T11:59:00.000Z" } },
|
||||
});
|
||||
|
||||
await runQuotaAutoPingTick(deps, state);
|
||||
|
||||
expect(deps.proxyAwareFetch).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(deps.proxyAwareFetch.mock.calls[0][1].body)).toMatchObject({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user