diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index 15096050..0b5bea19 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -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 )} {autoPing && ( - + )} - {conn.provider === "claude" && conn.authType === "oauth" && ( - + {AUTO_PING_SETTINGS_KEYS[conn.provider] && conn.authType === "oauth" && ( + diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index ee2682ca..ccbaee3a 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -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 }); diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index ebf5d120..0650d086 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -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 diff --git a/src/shared/services/claudeAutoPing.js b/src/shared/services/claudeAutoPing.js deleted file mode 100644 index 14abc127..00000000 --- a/src/shared/services/claudeAutoPing.js +++ /dev/null @@ -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(); -} diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index 234c90d6..e2914e0d 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -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); } diff --git a/src/shared/services/quotaAutoPing.js b/src/shared/services/quotaAutoPing.js new file mode 100644 index 00000000..694a5e2b --- /dev/null +++ b/src/shared/services/quotaAutoPing.js @@ -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(); +} diff --git a/tests/unit/quota-auto-ping.test.js b/tests/unit/quota-auto-ping.test.js new file mode 100644 index 00000000..de601df5 --- /dev/null +++ b/tests/unit/quota-auto-ping.test.js @@ -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" }], + }); + }); +});