diff --git a/changelog.d/features/6977-quota-auto-ping.md b/changelog.d/features/6977-quota-auto-ping.md new file mode 100644 index 000000000000..694f4fbf8994 --- /dev/null +++ b/changelog.d/features/6977-quota-auto-ping.md @@ -0,0 +1 @@ +- **feat(quota):** Add opt-in auto-ping to keep Codex quota windows warm — per-connection toggle in Settings → AI that sends a tiny request right after a Codex session window resets, so it isn't cold on the next real request ([#6977](https://github.com/diegosouzapw/OmniRoute/issues/6977)) diff --git a/src/app/(dashboard)/dashboard/settings/ai/page.tsx b/src/app/(dashboard)/dashboard/settings/ai/page.tsx index 561f493749f8..a640fd356e50 100644 --- a/src/app/(dashboard)/dashboard/settings/ai/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/ai/page.tsx @@ -6,6 +6,7 @@ import VisionBridgeSettingsTab from "../components/VisionBridgeSettingsTab"; import SystemPromptTab from "../components/SystemPromptTab"; import ResponsesStatePolicyTab from "../components/ResponsesStatePolicyTab"; import CodexFastTierTab from "../components/CodexFastTierTab"; +import CodexAutoPingTab from "../components/CodexAutoPingTab"; import ClaudeFastModeTab from "../components/ClaudeFastModeTab"; import MemorySkillsTab from "../components/MemorySkillsTab"; import ModelsDevSyncTab from "../components/ModelsDevSyncTab"; @@ -23,6 +24,7 @@ export default function SettingsAiPage() { + diff --git a/src/app/(dashboard)/dashboard/settings/components/CodexAutoPingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CodexAutoPingTab.tsx new file mode 100644 index 000000000000..c5b8074f5d8c --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/CodexAutoPingTab.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Card, Toggle, InfoTooltip } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +type CodexConnection = { + id: string; + provider: string; + authType?: string; + name?: string | null; + email?: string | null; + displayName?: string | null; +}; + +function connectionLabel(conn: CodexConnection): string { + return conn.displayName || conn.name || conn.email || conn.id; +} + +function useCodexAutoPingSettings() { + const [connections, setConnections] = useState([]); + const [enabledMap, setEnabledMap] = useState>({}); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + const [status, setStatus] = useState<"" | "saved" | "error">(""); + + useEffect(() => { + let cancelled = false; + Promise.all([ + fetch("/api/settings").then((res) => res.json()), + fetch("/api/providers").then((res) => res.json()), + ]) + .then(([settingsData, providersData]) => { + if (cancelled) return; + const codexOAuthConnections: CodexConnection[] = (providersData?.connections || []).filter( + (c: CodexConnection) => c.provider === "codex" && c.authType === "oauth" + ); + setConnections(codexOAuthConnections); + setEnabledMap(settingsData?.codexAutoPing?.connections || {}); + setLoading(false); + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const toggleConnection = async (connectionId: string, checked: boolean) => { + if (savingId) return; + setSavingId(connectionId); + setStatus(""); + const previous = enabledMap; + const next = { ...enabledMap, [connectionId]: checked }; + setEnabledMap(next); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ codexAutoPing: { connections: next } }), + }); + if (res.ok) { + setStatus("saved"); + setTimeout(() => setStatus(""), 2000); + } else { + setEnabledMap(previous); + setStatus("error"); + } + } catch { + setEnabledMap(previous); + setStatus("error"); + } finally { + setSavingId(null); + } + }; + + return { connections, enabledMap, loading, savingId, status, toggleConnection }; +} + +function CodexAutoPingHeader({ status }: { status: "" | "saved" | "error" }) { + const t = useTranslations("settings"); + return ( +
+
+ +
+
+

+ {t("codexAutoPingTitle")} + +

+

{t("codexAutoPingDesc")}

+
+ {status === "saved" && ( + + check_circle{" "} + {t("saved")} + + )} + {status === "error" && ( + + error{" "} + {t("codexAutoPingSaveError")} + + )} +
+ ); +} + +function CodexAutoPingConnectionList({ + connections, + enabledMap, + savingId, + onToggle, +}: { + connections: CodexConnection[]; + enabledMap: Record; + savingId: string | null; + onToggle: (connectionId: string, checked: boolean) => void; +}) { + const t = useTranslations("settings"); + return ( +
+ {connections.map((conn) => ( +
+ {connectionLabel(conn)} + onToggle(conn.id, value)} + disabled={savingId === conn.id} + ariaLabel={t("codexAutoPingToggleAria", { connection: connectionLabel(conn) })} + /> +
+ ))} +
+ ); +} + +export default function CodexAutoPingTab() { + const t = useTranslations("settings"); + const { connections, enabledMap, loading, savingId, status, toggleConnection } = + useCodexAutoPingSettings(); + + if (!loading && connections.length === 0) return null; + + return ( + + + {loading ? ( +

{t("loading")}

+ ) : ( + + )} +
+ ); +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 0e168dea1c33..3c374db3ace3 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 9c6fa7049645..4711bead6f05 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 26a1d163cf7f..826682d64a22 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 7acf3cd262ca..b3628f545365 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index d126a5f2fd56..d7a8f9dfa4f5 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 8aee18ac77d3..5ef0eae6a488 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 36f2b74f49f0..28fbb325aca7 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5545,7 +5545,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d59d99eed08c..3b4ebb6b0722 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -6065,6 +6065,11 @@ "codexFastTierDesc": "Globally inject service_tier=priority for OpenAI Codex requests.", "codexFastTierHint": "When enabled, OmniRoute adds service_tier=priority to outbound Codex requests for connections that don't already specify a tier. Priority tier requires an OpenAI Enterprise API key or the ChatGPT-auth Codex path; other key types will receive a tier-related error from OpenAI. Per-connection settings on the Codex provider page take precedence.", "codexFastTierSaveError": "Failed to update Codex Fast Tier setting", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}", "codexFastTierTierLabel": "Service tier", "codexFastTierTierPriority": "Priority", "codexFastTierTierFlex": "Flex", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 85afdd2ee08b..299c9c44d59e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 221645981f8b..888f17340079 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index c73ea66b5900..efc93f2d3416 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index bb860762099b..4c318fea0195 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 029711483246..d967fada7576 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 0be3e6522f58..5c8a191ce14e 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 5711806051e1..951f4c126957 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index e7d80ee10e1f..c08dd0ce67ec 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index ea0cceeb8c56..fe3a36126203 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index d6003d586123..c3a286b2b180 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 5b815aac4fb5..b43fd53fc7df 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -6170,7 +6170,12 @@ "searchProviderAria": "Provider di ricerca", "searchProviderPlaceholder": "Cerca provider...", "selectProviderPlaceholder": "Seleziona provider...", - "update": "Aggiorna" + "update": "Aggiorna", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index faf3580a9a58..27a12e6b611f 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index bfc39f5b2d2a..43b30efcefb2 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index d7c003a3bf2d..f60cec20ffe4 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 45a45a35cb0c..3f00d8fd6723 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 77735063a2dd..7ec23f4aa65c 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index fd3f4d09f99b..0a9990acee75 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index a49f2180cd71..80e4e01c710a 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index a7bc4342d9ba..2b16c878c850 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 5f62319c7df4..afb0d8a46895 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -6244,6 +6244,11 @@ "routingStrategyTitle": "Estratégia de Roteamento", "searchModelOverrideTargets": "Buscar provedor/modelo...", "selectedModel": "Modelo selecionado", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}", "proxyFreePoolSearchPlaceholder": "Buscar host…", "proxyFreePoolSearchLabel": "Buscar proxies do pool gratuito", "proxyFreePoolSortLabel": "Ordenar por", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f713c89c30f3..879b138be9de 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -6082,7 +6082,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index d889f148ac88..3e4437cc46dc 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index e804afcf273c..107b8a6cc3c6 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index fa2d5a90cbad..623b317ebffe 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 67208ff3ae3a..eb4b9de0a395 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -6081,7 +6081,12 @@ "modelLockoutExponentialBackoff": "__MISSING__:Exponential Backoff", "modelLockoutExponentialBackoffDescription": "__MISSING__:When enabled, each consecutive failure increases the cooldown duration exponentially.", "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", - "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised." + "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index f2fbd0234fb8..f1a9e3132dc7 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index c67eb7ed7648..44e13aa11089 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 297378de64e7..4cd10468d546 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 2a38911bfb60..75544379361b 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 203b710c8893..468a120f18f6 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index e6321eadf3c1..420502bed55a 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Швидкий старт", "homeQuickStartDesc": "Показувати панель швидкого старту на головній сторінці.", "homeProviderTopology": "Топологія провайдерів", - "homeProviderTopologyDesc": "Показувати топологію провайдерів на головній сторінці." + "homeProviderTopologyDesc": "Показувати топологію провайдерів на головній сторінці.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "Двигун RTK", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 05a7affead23..80d175f52547 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 4a4308153c20..1c9f3237d2f9 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5532,7 +5532,12 @@ "homeQuickStart": "Quick Start", "homeQuickStartDesc": "Show the Quick Start panel on the Home page.", "homeProviderTopology": "Provider Topology", - "homeProviderTopologyDesc": "Show the Provider Topology on the Home page." + "homeProviderTopologyDesc": "Show the Provider Topology on the Home page.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b5d42e3faea7..00fa7fc87d34 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5916,7 +5916,12 @@ "responsesStateModeStrip": "剥离", "responsesStateModePreserve": "保留", "responsesStateHint": "自动模式会剥离 previous_response_id,除非连接明确启用 OpenAI 响应存储。对于无状态客户端(如 VS Code 自定义端点),剥离模式最安全;上下文取决于客户端发送完整历史记录。", - "responsesStateSaveError": "更新响应状态设置失败" + "responsesStateSaveError": "更新响应状态设置失败", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK 引擎", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 29ca3aae8b08..ffe9da7ae4c5 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -6173,7 +6173,12 @@ "modelLockoutMaxBackoffSteps": "__MISSING__:Max Backoff Steps", "modelLockoutMaxBackoffStepsDescription": "__MISSING__:Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised.", "disableSessionStickiness": "__MISSING__:Disable session stickiness", - "disableSessionStickinessDesc": "__MISSING__:Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence." + "disableSessionStickinessDesc": "__MISSING__:Round-robin and random combos rotate to a different connection on every request instead of pinning a whole conversation to one connection by the first-message hash. Leave off to preserve prompt-cache hits for multi-turn chats. Per-combo overrides take precedence.", + "codexAutoPingTitle": "Codex Quota Auto-Ping", + "codexAutoPingDesc": "Opt-in per connection: sends a tiny request right after a Codex session window resets so it isn't cold when you need it.", + "codexAutoPingWarning": "Consumes a small amount of real Codex quota on every ping. Off by default — enable only for connections you actively rely on.", + "codexAutoPingSaveError": "Failed to update Codex Auto-Ping setting", + "codexAutoPingToggleAria": "Toggle Codex quota auto-ping for {connection}" }, "contextRtk": { "title": "RTK 引擎", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index eaaa629b03f7..d32fd67f1ab0 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -241,6 +241,9 @@ export async function registerNodejs(): Promise { console.log("[STARTUP] Quota cache background refresh started"); startProviderLimitsSyncScheduler(); console.log("[STARTUP] Provider limits sync scheduler started"); + const { startQuotaAutoPing } = await import("@/lib/services/quotaAutoPing"); + startQuotaAutoPing(); + console.log("[STARTUP] Quota auto-ping scheduler started (opt-in, no-op until enabled)"); const cloudSyncInitialized = await ensureCloudSyncInitialized(); console.log( `[STARTUP] Cloud/model sync background bootstrap ${cloudSyncInitialized ? "initialized" : "skipped"}` diff --git a/src/lib/db/migrations/123_quota_auto_ping.sql b/src/lib/db/migrations/123_quota_auto_ping.sql new file mode 100644 index 000000000000..3ef7982e73bf --- /dev/null +++ b/src/lib/db/migrations/123_quota_auto_ping.sql @@ -0,0 +1,7 @@ +-- Quota auto-ping (#6977): opt-in warm-up pings that keep a connection's quota +-- window fresh right after it resets, so the first real request doesn't land in +-- a cold/inactive window. Tracks the last successful ping and which reset window +-- it targeted, so the scheduler never re-pings the same reset twice. +-- Plain TEXT columns — rowToCamel passes them through as-is; NULL = never pinged. +ALTER TABLE provider_connections ADD COLUMN last_ping_at TEXT; +ALTER TABLE provider_connections ADD COLUMN last_pinged_reset_key TEXT; diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 47a18fee9fd6..1217c860b3c6 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -495,6 +495,8 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) { proxy_enabled = @proxyEnabled, per_key_proxy_enabled = @perKeyProxyEnabled, rate_limit_overrides_json = @rateLimitOverridesJson, + last_ping_at = @lastPingAt, + last_pinged_reset_key = @lastPingedResetKey, updated_at = @updatedAt WHERE id = @id ` @@ -543,6 +545,8 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) { proxyEnabled: normalizeBooleanColumn(data.proxyEnabled, true) ? 1 : 0, perKeyProxyEnabled: normalizeBooleanColumn(data.perKeyProxyEnabled, false) ? 1 : 0, rateLimitOverridesJson: serializeJsonField(data.rateLimitOverrides), + lastPingAt: data.lastPingAt || null, + lastPingedResetKey: data.lastPingedResetKey || null, updatedAt: now, }); } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 710684d0a49a..35ad6b128331 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -160,6 +160,13 @@ export async function getSettings() { // (`:free` suffix, zero-price pricing, or FREE_MODEL_BUDGETS membership). Default // false preserves prior behaviour; opt-in only. hidePaidModels: false, + // #6977: Opt-in per-connection auto-ping that warms a Codex OAuth connection's + // quota window right after it resets, so the first real request doesn't land in + // a cold window. `connections` maps connection id -> enabled. Default empty map + // (nobody opted in) — the scheduler is a no-op until an operator flips a + // connection on, since pinging burns a small amount of real quota (Hard Rule #20 + // spirit: never mutate/consume on the operator's behalf by default). + codexAutoPing: { connections: {} }, }; for (const row of rows) { const record = toRecord(row); diff --git a/src/lib/services/quotaAutoPing.ts b/src/lib/services/quotaAutoPing.ts new file mode 100644 index 000000000000..069ed5a8c26d --- /dev/null +++ b/src/lib/services/quotaAutoPing.ts @@ -0,0 +1,453 @@ +/** + * quotaAutoPing.ts — opt-in Codex quota window warm-up (#6977). + * + * A connection's Codex "session" quota window resets on a rolling 5h basis but + * only starts counting down once a request lands inside it — an idle window + * just keeps sliding forward. That means the FIRST real request after a long + * idle period pays for the whole warm-up latency. This scheduler watches each + * opted-in connection's reported `resetAt` and, once it slides forward (i.e. + * the window rolled while nobody was using it), fires one tiny non-billed-model + * request through the real Codex executor to nudge the window "live" again. + * + * Strictly opt-in (`settings.codexAutoPing.connections[id] === true`, default + * off — see src/lib/db/settings.ts) because every ping consumes a small amount + * of real quota. Reimplemented in TS from the shipped 9router + * src/shared/services/quotaAutoPing.js (Codex half only; Antigravity is a + * follow-up — no upstream reference exists for its 2-bucket reset shape). + * + * All external effects are behind an injectable `deps` object and the tick + * loop takes an injectable `now()` clock, so tests are fully deterministic — + * no real timers, no real DB, no real network. + */ + +import { logger } from "@omniroute/open-sse/utils/logger.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; +import { getCodexUsage } from "@omniroute/open-sse/services/usage/codex.ts"; +import { getSettings, getProviderConnections, updateProviderConnection } from "@/lib/localDb"; +import { refreshAndUpdateCredentials } from "@/lib/usage/providerLimits"; +import { getCircuitBreaker } from "@/shared/utils/circuitBreaker"; +import { + QUOTA_AUTOPING_FAILURE_COOLDOWN_MS, + QUOTA_AUTOPING_PROVIDERS, + QUOTA_AUTOPING_REFRESH_AHEAD_MS, + QUOTA_AUTOPING_TICK_INTERVAL_MS, + type QuotaAutoPingProviderConfig, +} from "@/shared/constants/quotaAutoPing"; + +const log = logger("QuotaAutoPing"); + +type JsonRecord = Record; + +export interface QuotaAutoPingConnection { + id: string; + provider: string; + authType?: string; + accessToken?: string; + refreshToken?: string; + tokenExpiresAt?: string | null; + expiresAt?: string | null; + providerSpecificData?: JsonRecord; + rateLimitedUntil?: string | null; + lastPingAt?: string | null; + lastPingedResetKey?: string | null; +} + +export interface QuotaAutoPingDeps { + getSettings: () => Promise; + getProviderConnections: ( + filter: JsonRecord + ) => Promise; + updateProviderConnection: (id: string, data: JsonRecord) => Promise; + refreshAndUpdateCredentials: ( + connection: QuotaAutoPingConnection + ) => Promise<{ connection: QuotaAutoPingConnection }>; + getCodexUsage: ( + accessToken?: string, + providerSpecificData?: JsonRecord + ) => Promise; + getExecutor: (provider: string) => { execute: (input: JsonRecord) => Promise }; + canExecuteProvider: (provider: string) => boolean; +} + +export interface QuotaAutoPingState { + running: boolean; + resetCache: Record; + failureCache: Record; +} + +export function createQuotaAutoPingState(): QuotaAutoPingState { + return { running: false, resetCache: {}, failureCache: {} }; +} + +export function createDefaultQuotaAutoPingDeps(): QuotaAutoPingDeps { + return { + getSettings, + getProviderConnections, + updateProviderConnection, + refreshAndUpdateCredentials: async (connection) => + refreshAndUpdateCredentials(connection as never), + getCodexUsage, + getExecutor, + canExecuteProvider: (provider) => getCircuitBreaker(provider).canExecute(), + }; +} + +function cacheKey(provider: string, connectionId: string): string { + return `${provider}:${connectionId}`; +} + +function normalizeResetKey(resetAt: string): string { + const ms = new Date(resetAt).getTime(); + if (!Number.isFinite(ms)) return resetAt; + // Round to the minute so a few seconds of clock jitter between reads never + // registers as "a different window" (mirrors 9router parity). + return new Date(Math.floor(ms / 60000) * 60000).toISOString(); +} + +function getResetDriftMs(previousResetAt: string, nextResetAt: string): number { + 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: unknown): number | 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 null; +} + +function isQuotaExhausted(quota: JsonRecord | undefined): boolean { + 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 hasExhaustedBlockingQuota(quotas: JsonRecord, sessionKey: string): boolean { + return Object.entries(quotas).some(([name, quota]) => { + if (name === sessionKey) return false; + if (String(name).toLowerCase().includes("session")) return false; + return isQuotaExhausted(quota as JsonRecord); + }); +} + +function wasPingedRecently( + connection: QuotaAutoPingConnection, + intervalMs: number, + nowMs: number +): boolean { + if (!intervalMs || !connection.lastPingAt) return false; + const lastPingAtMs = new Date(connection.lastPingAt).getTime(); + return Number.isFinite(lastPingAtMs) && nowMs - lastPingAtMs < intervalMs; +} + +function shouldSkipAfterFailure( + state: QuotaAutoPingState, + key: string, + nowMs: number +): boolean { + const failedAt = state.failureCache[key]; + return Boolean(failedAt) && nowMs - failedAt < QUOTA_AUTOPING_FAILURE_COOLDOWN_MS; +} + +function isRateLimited(connection: QuotaAutoPingConnection, nowMs: number): boolean { + if (!connection.rateLimitedUntil) return false; + const untilMs = new Date(connection.rateLimitedUntil).getTime(); + return Number.isFinite(untilMs) && untilMs > nowMs; +} + +function buildCodexPingBody(providerConfig: QuotaAutoPingProviderConfig): JsonRecord { + return { + model: providerConfig.pingModel, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: providerConfig.pingText }], + }, + ], + instructions: providerConfig.pingInstructions, + reasoning: providerConfig.pingReasoningEffort + ? { effort: providerConfig.pingReasoningEffort, summary: "auto" } + : undefined, + store: false, + stream: true, + }; +} + +async function drainResponseBody(response: Response | undefined): Promise { + if (!response) return; + if (typeof response.text === "function") { + await response.text().catch(() => undefined); + return; + } + const reader = (response as { body?: ReadableStream }).body?.getReader?.(); + if (!reader) return; + try { + for (;;) { + const { done } = await reader.read(); + if (done) return; + } + } finally { + reader.releaseLock?.(); + } +} + +async function sendCodexPing( + connection: QuotaAutoPingConnection, + providerConfig: QuotaAutoPingProviderConfig, + deps: QuotaAutoPingDeps +): Promise { + const executor = deps.getExecutor("codex"); + const result = await executor.execute({ + model: providerConfig.pingModel, + stream: true, + credentials: { + accessToken: connection.accessToken, + connectionId: connection.id, + providerSpecificData: connection.providerSpecificData, + }, + log: null, + body: buildCodexPingBody(providerConfig), + }); + const response = (result as { response?: Response }).response; + if (!response || !response.ok) { + try { + await (response as { body?: { cancel?: () => Promise } })?.body?.cancel?.(); + } catch { + // Ignore — best-effort cleanup of an already-failed ping. + } + return false; + } + // Codex only starts the 5h window after the streaming response completes. + await drainResponseBody(response); + return true; +} + +function shouldPingForReset( + providerConfig: QuotaAutoPingProviderConfig, + cachedReset: string | undefined, + resetAt: string, + nowMs: number +): boolean { + void nowMs; + if (!cachedReset) return false; + return getResetDriftMs(cachedReset, resetAt) >= providerConfig.resetAtDriftMs; +} + +/** + * Cheap pre-fetch guards — none of these require a network call. Extracted so + * `pingConnection` reads as a single linear flow instead of a wall of `if`s. + */ +function isPingCandidateBlocked( + connection: QuotaAutoPingConnection, + provider: "codex", + providerConfig: QuotaAutoPingProviderConfig, + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + key: string, + cachedReset: string | undefined, + nowMs: number +): boolean { + if (!deps.canExecuteProvider(provider)) return true; // provider circuit breaker OPEN + if (isRateLimited(connection, nowMs)) return true; // connection cooldown active + if (shouldSkipAfterFailure(state, key, nowMs)) return true; + + // Codex has no fixed reset schedule (pingWhenResetAtSlides): resetAt keeps + // sliding forward while the window is idle, so we must re-fetch usage every + // tick to detect the slide. Providers with a real fixed reset (future + // Antigravity buckets) would skip re-fetching until close to the cached + // resetAt — the guard is preserved here for that case. + return Boolean( + !providerConfig.pingWhenResetAtSlides && + cachedReset && + nowMs < new Date(cachedReset).getTime() - QUOTA_AUTOPING_REFRESH_AHEAD_MS + ); +} + +/** Post-fetch decision — everything we know once usage has been read. */ +function shouldSendPing( + providerConfig: QuotaAutoPingProviderConfig, + quotas: JsonRecord, + quota: JsonRecord | undefined, + cachedReset: string | undefined, + resetAt: string, + current: QuotaAutoPingConnection, + resetKey: string, + nowMs: number +): boolean { + if ( + providerConfig.skipWhenBlockingQuotaExhausted && + hasExhaustedBlockingQuota(quotas, providerConfig.quotaKey) + ) { + return false; + } + if (isQuotaExhausted(quota)) return false; + if (!shouldPingForReset(providerConfig, cachedReset, resetAt, nowMs)) return false; + if (wasPingedRecently(current, providerConfig.minPingIntervalMs, nowMs)) return false; + if (current.lastPingedResetKey === resetKey) return false; + return true; +} + +async function refreshConnectionForPing( + connection: QuotaAutoPingConnection, + provider: "codex", + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + key: string, + nowMs: number +): Promise { + try { + const refreshed = await deps.refreshAndUpdateCredentials(connection); + return refreshed.connection; + } catch (err) { + state.failureCache[key] = nowMs; + log.warn(`${provider}:${connection.id}: credential refresh failed`, { + error: sanitizeErrorMessage((err as Error)?.message ?? String(err)), + }); + return null; + } +} + +async function pingConnection( + connection: QuotaAutoPingConnection, + provider: "codex", + providerConfig: QuotaAutoPingProviderConfig, + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + nowMs: number +): Promise { + const key = cacheKey(provider, connection.id); + const cachedReset = state.resetCache[key]; + if (isPingCandidateBlocked(connection, provider, providerConfig, deps, state, key, cachedReset, nowMs)) { + return; + } + + const current = await refreshConnectionForPing(connection, provider, deps, state, key, nowMs); + if (!current) return; + + const usage = await deps.getCodexUsage(current.accessToken, current.providerSpecificData); + const quotas = (usage.quotas as JsonRecord) || {}; + const quota = quotas[providerConfig.quotaKey] as JsonRecord | undefined; + const resetAt = quota?.resetAt as string | undefined; + if (!resetAt) return; + state.resetCache[key] = resetAt; + + const resetKey = normalizeResetKey(resetAt); + if (!shouldSendPing(providerConfig, quotas, quota, cachedReset, resetAt, current, resetKey, nowMs)) { + return; + } + + const ok = await sendCodexPing(current, providerConfig, deps); + if (!ok) { + state.failureCache[key] = nowMs; + log.warn(`${provider}:${current.id}: ping failed`, { resetAt }); + return; + } + + delete state.failureCache[key]; + await deps.updateProviderConnection(current.id, { + lastPingedResetKey: resetKey, + lastPingAt: new Date(nowMs).toISOString(), + }); + log.info(`${provider}:${current.id}: ping sent`, { resetAt }); +} + +function getEnabledConnectionIds( + settings: JsonRecord, + providerConfig: QuotaAutoPingProviderConfig +): Record { + return ( + ((settings[providerConfig.settingsKey] as JsonRecord | undefined)?.connections as + | Record + | undefined) || {} + ); +} + +async function pingProviderConnections( + provider: "codex", + providerConfig: QuotaAutoPingProviderConfig, + enabledMap: Record, + deps: QuotaAutoPingDeps, + state: QuotaAutoPingState, + nowMs: number +): Promise { + const connections = await deps.getProviderConnections({ provider, isActive: true }); + const targets = connections.filter( + (conn) => conn.authType === "oauth" && enabledMap[conn.id] === true + ); + for (const connection of targets) { + try { + await pingConnection(connection, provider, providerConfig, deps, state, nowMs); + } catch (err) { + state.failureCache[cacheKey(provider, connection.id)] = nowMs; + log.warn(`${provider}:${connection.id}: tick error`, { + error: sanitizeErrorMessage((err as Error)?.message ?? String(err)), + }); + } + } +} + +/** + * Run one scheduler tick: for every provider with at least one opted-in + * connection, ping the connections whose quota window just rolled over. + * Safe to call concurrently — re-entrant calls while already `running` are a + * no-op, matching the reference scheduler's guard. + */ +export async function runQuotaAutoPingTick( + deps: QuotaAutoPingDeps = createDefaultQuotaAutoPingDeps(), + state: QuotaAutoPingState = createQuotaAutoPingState(), + now: () => number = Date.now +): Promise { + if (state.running) return; + state.running = true; + const nowMs = now(); + try { + const settings = await deps.getSettings(); + for (const [provider, providerConfig] of Object.entries(QUOTA_AUTOPING_PROVIDERS)) { + const enabledMap = getEnabledConnectionIds(settings, providerConfig); + if (Object.keys(enabledMap).length === 0) continue; + await pingProviderConnections( + provider as "codex", + providerConfig, + enabledMap, + deps, + state, + nowMs + ); + } + } catch (err) { + log.warn("tick error", { error: sanitizeErrorMessage((err as Error)?.message ?? String(err)) }); + } finally { + state.running = false; + } +} + +let schedulerInterval: ReturnType | null = null; +const schedulerState = createQuotaAutoPingState(); + +/** Start the in-process scheduler. Idempotent — a second call is a no-op. */ +export function startQuotaAutoPing(): void { + if (schedulerInterval) return; + log.info("scheduler started"); + runQuotaAutoPingTick(createDefaultQuotaAutoPingDeps(), schedulerState).catch(() => undefined); + schedulerInterval = setInterval(() => { + runQuotaAutoPingTick(createDefaultQuotaAutoPingDeps(), schedulerState).catch(() => undefined); + }, QUOTA_AUTOPING_TICK_INTERVAL_MS); + schedulerInterval.unref?.(); +} + +/** Stop the in-process scheduler. Idempotent — a second call is a no-op. */ +export function stopQuotaAutoPing(): void { + if (!schedulerInterval) return; + clearInterval(schedulerInterval); + schedulerInterval = null; + log.info("scheduler stopped"); +} diff --git a/src/shared/constants/quotaAutoPing.ts b/src/shared/constants/quotaAutoPing.ts new file mode 100644 index 000000000000..15e2380b9d46 --- /dev/null +++ b/src/shared/constants/quotaAutoPing.ts @@ -0,0 +1,49 @@ +/** + * quotaAutoPing.ts — config for the opt-in Codex quota auto-ping (#6977). + * + * Phase 1 (this file): Codex only — resetAt is inferred by watching the + * "session" window slide forward (Codex has no explicit reset event; an + * inactive window quietly rolls to a fresh one on the next usage read). + * Antigravity (2 buckets, no upstream reference) is a follow-up — see the + * PR body for #6977. + */ + +export const QUOTA_AUTOPING_TICK_INTERVAL_MS = 60_000; +// How long a connection must stay quiet after a failed ping before we retry it. +export const QUOTA_AUTOPING_FAILURE_COOLDOWN_MS = 15 * 60 * 1000; +// Once a resetAt is observed and cached, skip re-fetching usage until we're +// within this window of it (Codex resetAt slides constantly while idle, so we +// still poll every tick, but this bounds how eagerly we ping right after a slide). +export const QUOTA_AUTOPING_REFRESH_AHEAD_MS = 5 * 60 * 1000; + +export type QuotaAutoPingProviderConfig = { + settingsKey: string; + quotaKey: string; + /** Codex has no fixed reset schedule — ping whenever resetAt slides forward. */ + pingWhenResetAtSlides: true; + /** Minimum forward drift (ms) before a slide counts as "the window rolled". */ + resetAtDriftMs: number; + /** Never re-ping the same connection more often than this, even across resets. */ + minPingIntervalMs: number; + /** Skip the ping when a non-session quota (e.g. weekly) is already exhausted. */ + skipWhenBlockingQuotaExhausted: true; + pingModel: string; + pingText: string; + pingInstructions: string; + pingReasoningEffort: string; +}; + +export const QUOTA_AUTOPING_PROVIDERS: Record<"codex", QuotaAutoPingProviderConfig> = { + codex: { + settingsKey: "codexAutoPing", + quotaKey: "session", + pingWhenResetAtSlides: true, + resetAtDriftMs: 30_000, + minPingIntervalMs: 10 * 60 * 1000, + skipWhenBlockingQuotaExhausted: true, + pingModel: "gpt-5.1-codex-mini", + pingText: "hi", + pingInstructions: "Reply with OK.", + pingReasoningEffort: "none", + }, +}; diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index e3b170925ebf..6347dbc108f7 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -178,6 +178,14 @@ export const updateSettingsSchema = z.object({ }) .optional(), codexSessionAffinityTtlMs: z.number().int().min(0).max(86_400_000).optional(), + // #6977: opt-in per-connection Codex quota auto-ping. `connections` maps a + // provider_connections id -> enabled; default is an empty map (off for everyone) + // until the operator flips a specific OAuth connection on from the settings UI. + codexAutoPing: z + .object({ + connections: z.record(z.string().max(100), z.boolean()).optional(), + }) + .optional(), responsesPreviousResponseIdMode: z.enum(RESPONSES_PREVIOUS_RESPONSE_ID_MODES).optional(), // Routing settings (#134) fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(), diff --git a/tests/unit/quota-auto-ping.test.ts b/tests/unit/quota-auto-ping.test.ts new file mode 100644 index 000000000000..955d598f536a --- /dev/null +++ b/tests/unit/quota-auto-ping.test.ts @@ -0,0 +1,347 @@ +/** + * #6977 — opt-in Codex quota auto-ping scheduler. + * + * Ported (Codex half) from the shipped 9router + * src/shared/services/quotaAutoPing.js + its 351-line vitest suite. All + * effects (settings, DB reads/writes, credential refresh, usage fetch, the + * Codex executor, circuit breaker gate) are injected via `deps`, and time is + * injected via `now()`, so every test is fully deterministic — no real + * timers, no real DB, no real network. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// This module transitively imports src/lib/db/core.ts (via @/lib/localDb), +// which lazily opens the DB singleton on first use. Point DATA_DIR at a throwaway +// tmpdir *before* importing so the suite never touches the operator's real +// ~/.omniroute/storage.sqlite (deps are injected below anyway — nothing here +// exercises the real DB, this only prevents an accidental production open). +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-autoping-")); + +const { + runQuotaAutoPingTick, + createQuotaAutoPingState, +} = await import("../../src/lib/services/quotaAutoPing.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +test.after(() => { + resetDbInstance(); +}); + +const NOW_ISO = "2026-01-01T12:00:00.000Z"; +const NOW_MS = new Date(NOW_ISO).getTime(); + +function baseDeps(overrides = {}) { + const calls = { + updateProviderConnection: [], + executorExecute: [], + getExecutor: [], + }; + const deps = { + getSettings: async () => ({ codexAutoPing: { connections: { "codex-1": true } } }), + getProviderConnections: async ({ provider }) => + provider === "codex" + ? [{ id: "codex-1", provider: "codex", authType: "oauth", accessToken: "token" }] + : [], + updateProviderConnection: async (id, data) => { + calls.updateProviderConnection.push([id, data]); + return null; + }, + refreshAndUpdateCredentials: async (connection) => ({ connection }), + getCodexUsage: async () => ({ quotas: {} }), + getExecutor: (provider) => { + calls.getExecutor.push(provider); + return { + execute: async (input) => { + calls.executorExecute.push(input); + return { response: { ok: true, text: async () => "" } }; + }, + }; + }, + canExecuteProvider: () => true, + ...overrides, + }; + return { deps, calls }; +} + +test("#6977 does not touch anything when setting is absent", async () => { + const { deps, calls } = baseDeps({ getSettings: async () => ({}) }); + const state = createQuotaAutoPingState(); + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); + assert.equal(calls.updateProviderConnection.length, 0); +}); + +test("#6977 does not ping on the first resetAt observation (only caches it)", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + quotas: { session: { used: 1, resetAt: "2026-01-01T17:00:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); + assert.equal(calls.updateProviderConnection.length, 0); + assert.equal(state.resetCache["codex:codex-1"], "2026-01-01T17:00:00.000Z"); +}); + +test("#6977 sends a ping once the session resetAt slides forward", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 1); + assert.equal(calls.executorExecute.length, 1); + assert.equal(calls.updateProviderConnection.length, 1); + const [id, data] = calls.updateProviderConnection[0]; + assert.equal(id, "codex-1"); + assert.equal(data.lastPingedResetKey, "2026-01-01T17:01:00.000Z"); + assert.equal(typeof data.lastPingAt, "string"); +}); + +test("#6977 does not ping when resetAt is stable (no slide)", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:00:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); + assert.equal(calls.updateProviderConnection.length, 0); +}); + +test("#6977 does not repeat a ping inside the minimum ping interval", async () => { + const { deps, calls } = baseDeps({ + getProviderConnections: async ({ provider }) => + provider === "codex" + ? [ + { + id: "codex-1", + provider: "codex", + authType: "oauth", + accessToken: "token", + lastPingAt: "2026-01-01T11:55:00.000Z", + }, + ] + : [], + getCodexUsage: async () => ({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); + assert.equal(calls.updateProviderConnection.length, 0); +}); + +test("#6977 never re-pings the same resetKey twice even across small clock drift", async () => { + const { deps, calls } = baseDeps({ + getProviderConnections: async ({ provider }) => + provider === "codex" + ? [ + { + id: "codex-1", + provider: "codex", + authType: "oauth", + accessToken: "token", + lastPingedResetKey: "2026-01-01T11:59:00.000Z", + }, + ] + : [], + getCodexUsage: async () => ({ + quotas: { session: { used: 0, total: 100, remaining: 100, resetAt: "2026-01-01T11:59:03.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T11:59:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); +}); + +test("#6977 skips when the session quota itself is exhausted", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + quotas: { session: { used: 100, total: 100, remaining: 0, resetAt: "2026-01-01T17:01:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); +}); + +test("#6977 skips when a blocking (non-session) quota is exhausted", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + 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" }, + }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); +}); + +test("#6977 skips non-OAuth Codex connections", async () => { + const { deps, calls } = baseDeps({ + getProviderConnections: async ({ provider }) => + provider === "codex" + ? [{ id: "codex-1", provider: "codex", authType: "apikey", accessToken: "token" }] + : [], + }); + const state = createQuotaAutoPingState(); + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); +}); + +test("#6977 skips a connection whose provider circuit breaker is open", async () => { + const { deps, calls } = baseDeps({ + canExecuteProvider: () => false, + getCodexUsage: async () => ({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); +}); + +test("#6977 skips a connection currently in cooldown (rateLimitedUntil in the future)", async () => { + const { deps, calls } = baseDeps({ + getProviderConnections: async ({ provider }) => + provider === "codex" + ? [ + { + id: "codex-1", + provider: "codex", + authType: "oauth", + accessToken: "token", + rateLimitedUntil: "2026-01-01T13:00:00.000Z", + }, + ] + : [], + }); + const state = createQuotaAutoPingState(); + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); +}); + +test("#6977 does not re-ping while inside the failure cooldown window", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + state.failureCache["codex:codex-1"] = NOW_MS - 60_000; // failed 1 minute ago + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); +}); + +test("#6977 caches the failure and skips the DB write when the ping itself fails", async () => { + const { deps, calls } = baseDeps({ + getCodexUsage: async () => ({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }), + getExecutor: () => ({ + execute: async () => ({ response: { ok: false } }), + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.updateProviderConnection.length, 0); + assert.equal(state.failureCache["codex:codex-1"], NOW_MS); +}); + +test("#6977 sends the tiny ping request through the real Codex executor with the configured model", async () => { + const { deps, calls } = baseDeps({ + getProviderConnections: async ({ provider }) => + provider === "codex" + ? [ + { + id: "codex-1", + provider: "codex", + authType: "oauth", + accessToken: "token", + providerSpecificData: { workspaceId: "ws-1" }, + }, + ] + : [], + getCodexUsage: async () => ({ + quotas: { session: { used: 1, total: 100, remaining: 99, resetAt: "2026-01-01T17:01:00.000Z" } }, + }), + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.deepEqual(calls.getExecutor, ["codex"]); + const input = calls.executorExecute[0]; + assert.equal(input.stream, true); + assert.equal(input.credentials.accessToken, "token"); + assert.equal(input.credentials.connectionId, "codex-1"); + assert.deepEqual(input.credentials.providerSpecificData, { workspaceId: "ws-1" }); + assert.equal(input.body.stream, true); + assert.equal(input.body.store, false); + assert.deepEqual(input.body.input, [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ]); +}); + +test("#6977 does not ping when credential refresh throws", async () => { + const { deps, calls } = baseDeps({ + refreshAndUpdateCredentials: async () => { + throw new Error("refresh exploded"); + }, + }); + const state = createQuotaAutoPingState(); + state.resetCache["codex:codex-1"] = "2026-01-01T17:00:00.000Z"; + + await runQuotaAutoPingTick(deps, state, () => NOW_MS); + + assert.equal(calls.getExecutor.length, 0); + assert.equal(state.failureCache["codex:codex-1"], NOW_MS); +});