- Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : "callback URL"} here
+ Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : isKimchiProvider ? "callback URL or copied token" : "callback URL"} here
{provider === "xai"
? "If xAI shows a code instead of redirecting, paste that code here."
+ : isKimchiProvider
+ ? "After authorization, copy the full callback URL or token from your browser."
: "After authorization, copy the full URL from your browser."}
{
const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0);
const totalCost = data.cost || 0;
- const inputCost = totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0;
+ // ponytail: cost split is a token-share allocation of the (rate-accurate)
+ // server total, not a per-rate recompute. cached is a subset of prompt, so
+ // peel it out of the input share. Upgrade to a stored per-component cost
+ // breakdown if exact cached-rate cost display is needed.
+ const cachedTokens = data.cachedTokens || 0;
+ const nonCachedInput = Math.max(0, (data.promptTokens || 0) - cachedTokens);
+ const inputCost = totalTokens > 0 ? nonCachedInput * (totalCost / totalTokens) : 0;
+ const cachedCost = totalTokens > 0 ? cachedTokens * (totalCost / totalTokens) : 0;
const outputCost = totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0;
- return { ...data, key, totalTokens, totalCost, inputCost, outputCost, pending: pendingMap[key] || 0 };
+ return { ...data, key, totalTokens, totalCost, inputCost, cachedCost, outputCost, pending: pendingMap[key] || 0 };
})
.sort((a, b) => {
let valA = a[sortBy];
@@ -122,7 +129,7 @@ function groupDataByKey(data, keyField) {
if (!groups[gk]) {
groups[gk] = {
groupKey: gk,
- summary: { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, outputCost: 0, lastUsed: null, pending: 0 },
+ summary: { requests: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0, totalTokens: 0, cost: 0, inputCost: 0, cachedCost: 0, outputCost: 0, lastUsed: null, pending: 0 },
items: [],
};
}
@@ -130,9 +137,11 @@ function groupDataByKey(data, keyField) {
s.requests += item.requests || 0;
s.promptTokens += item.promptTokens || 0;
s.completionTokens += item.completionTokens || 0;
+ s.cachedTokens += item.cachedTokens || 0;
s.totalTokens += item.totalTokens || 0;
s.cost += item.cost || 0;
s.inputCost += item.inputCost || 0;
+ s.cachedCost += item.cachedCost || 0;
s.outputCost += item.outputCost || 0;
s.pending += item.pending || 0;
if (item.lastUsed && (!s.lastUsed || new Date(item.lastUsed) > new Date(s.lastUsed))) {
diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js
index 18ecab72..a593da92 100644
--- a/src/shared/constants/cliTools.js
+++ b/src/shared/constants/cliTools.js
@@ -56,6 +56,7 @@ export const MITM_TOOLS = {
configType: "mitm",
mitmDomain: "q.us-east-1.amazonaws.com",
defaultModels: [
+ { id: "claude-sonnet-5", name: "Claude Sonnet 5", alias: "claude-sonnet-5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
@@ -391,4 +392,3 @@ export const getProviderModelsForMapping = (providers) => {
});
return result;
};
-
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/__baseline__/known-fails.txt b/tests/__baseline__/known-fails.txt
index 78e0e76d..9ffd63f9 100644
--- a/tests/__baseline__/known-fails.txt
+++ b/tests/__baseline__/known-fails.txt
@@ -1,6 +1,5 @@
tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory
tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response
-tests/unit/kiro-model-slots.test.js :: Kiro MITM model slots offers a mappable slot for the agent default model id 'auto'
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing
tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import linux uses single hardcoded path and original error message
@@ -23,4 +22,4 @@ tests/unit/rtk.test.js :: compressMessages (enabled) skips when body has no mess
tests/unit/translator-request-normalization.test.js :: request normalization claudeToOpenAIRequest flattens text-only content arrays into string
tests/unit/translator-request-normalization.test.js :: request normalization filterToOpenAIFormat flattens text-only arrays to string
tests/unit/translator-request-normalization.test.js :: request normalization parseSSELine supports provider raw NDJSON stream lines
-tests/unit/translator-request-normalization.test.js :: request normalization translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe
\ No newline at end of file
+tests/unit/translator-request-normalization.test.js :: request normalization translateRequest keeps /v1/messages Claude->OpenAI text payloads string-safe
diff --git a/tests/translator/bugs-antigravity.test.js b/tests/translator/bugs-antigravity.test.js
index c20fcd2e..47c36942 100644
--- a/tests/translator/bugs-antigravity.test.js
+++ b/tests/translator/bugs-antigravity.test.js
@@ -1,7 +1,7 @@
// Real Antigravity-MITM requests (Gemini-internal: { request: { contents, ... } }) → OpenAI.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
-import { translateRequest } from "../../open-sse/translator/index.js";
+import { translateRequest, translateResponse, initState } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js";
@@ -9,10 +9,9 @@ const AG2O = (req) =>
translateRequest(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, "m", { request: req }, true, null, null);
describe("Antigravity → OpenAI", () => {
- // antigravity-to-openai.js:177-189 — content with BOTH functionResponse and functionCall/text
- // returns toolResults early → drops the tool calls / text.
- // KNOWN BUG
- it.fails("functionResponse + functionCall in same content keeps both", () => {
+ // antigravity-to-openai.js — content with BOTH functionResponse and functionCall/text
+ // previously returned toolResults early → dropped tool calls / text (fixed in #2225)
+ it("functionResponse + functionCall in same content keeps both", () => {
const out = AG2O({
contents: [{
role: "model",
@@ -54,6 +53,32 @@ describe("Antigravity → OpenAI", () => {
});
});
+describe("Antigravity → Claude", () => {
+ it("tool call input_json_delta includes Anthropic index", () => {
+ const state = initState(FORMATS.CLAUDE);
+ const events = translateResponse(FORMATS.ANTIGRAVITY, FORMATS.CLAUDE, {
+ response: {
+ responseId: "resp-1",
+ modelVersion: "gemini-pro-agent",
+ candidates: [{
+ content: {
+ role: "model",
+ parts: [{ functionCall: { name: "bash", args: { command: "git status" } } }],
+ },
+ finishReason: "STOP",
+ index: 0,
+ }],
+ },
+ }, state);
+
+ const jsonDelta = events.find(
+ (event) => event.type === "content_block_delta" && event.delta?.type === "input_json_delta"
+ );
+ expect(jsonDelta).toMatchObject({ index: expect.any(Number) });
+ expect(JSON.parse(jsonDelta.delta.partial_json)).toEqual({ command: "git status" });
+ });
+});
+
describe("Antigravity executor", () => {
it("strips optional from nested tool schemas", () => {
const out = new AntigravityExecutor().transformRequest("gemini-2.5-pro", {
diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js
index 7e01951f..1b189ec6 100644
--- a/tests/translator/claude-kiro-direct.test.js
+++ b/tests/translator/claude-kiro-direct.test.js
@@ -171,6 +171,7 @@ describe("Kiro → Claude (direct route, OpenAI-shaped chunks from executor)", (
const jsonDelta = events.find(
(e) => e.type === "content_block_delta" && e.delta.type === "input_json_delta"
);
+ expect(jsonDelta.index).toBeDefined();
expect(jsonDelta.delta.partial_json).toBe('{"q":"x"}');
const md = events.find((e) => e.type === "message_delta");
expect(md.delta.stop_reason).toBe("tool_use");
diff --git a/tests/translator/real/nvidia-thinking.e2e.test.js b/tests/translator/real/nvidia-thinking.e2e.test.js
new file mode 100644
index 00000000..2e2f67e9
--- /dev/null
+++ b/tests/translator/real/nvidia-thinking.e2e.test.js
@@ -0,0 +1,58 @@
+// E2E: hit live local proxy → verify nvidia MiniMax M2.7 doesn't 400 on
+// unsupported "thinking" param (nvidia NIM is OpenAI-compatible).
+// Requires dev server running on NV_E2E_PORT + an active router API key in DB.
+// RUN_E2E=1 npx vitest run --config tests/vitest.config.js tests/translator/real/nvidia-thinking.e2e.test.js
+import { describe, it, expect, beforeAll } from "vitest";
+import { getApiKeys } from "../../../src/lib/db/repos/apiKeysRepo.js";
+
+const PORT = process.env.NV_E2E_PORT || "20127";
+const BASE = `http://localhost:${PORT}`;
+const MODELS = [
+ "nvidia/minimaxai/minimax-m2.7",
+ "nvidia/minimaxai/minimax-m3",
+ "nvidia/z-ai/glm-5.2",
+ "nvidia/deepseek-ai/deepseek-v4-pro",
+ "nvidia/deepseek-ai/deepseek-v4-flash",
+ "nvidia/moonshotai/kimi-k2.6",
+ "nvidia/nvidia/nemotron-3-ultra-550b-a55b",
+];
+const RUN = process.env.RUN_E2E === "1";
+const maybe = RUN ? describe : describe.skip;
+
+async function drain(res) {
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let out = "";
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ out += decoder.decode(value, { stream: true });
+ }
+ return out;
+}
+
+maybe("nvidia thinking e2e", () => {
+ let apiKey = "";
+ beforeAll(async () => {
+ const keys = await getApiKeys();
+ apiKey = keys.find((k) => k.isActive)?.key || process.env.NV_E2E_KEY || "";
+ });
+
+ it.each(MODELS)("%s with reasoning_effort -> no 'thinking' 400", async (model) => {
+ if (!apiKey) return expect(true).toBe(true);
+ const res = await fetch(`${BASE}/v1/chat/completions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify({
+ model,
+ stream: true,
+ max_tokens: 64,
+ reasoning_effort: "low",
+ messages: [{ role: "user", content: "Reply with the single word: hi" }],
+ }),
+ });
+ const raw = await drain(res);
+ expect(/Unsupported parameter.*thinking/i.test(raw), `${model} rejected 'thinking'`).toBe(false);
+ expect(res.status, `${model} bad status ${res.status}`).toBeLessThan(400);
+ }, 90000);
+});
diff --git a/tests/unit/alicode-cache-control-2069.test.js b/tests/unit/alicode-cache-control-2069.test.js
new file mode 100644
index 00000000..53d15e7f
--- /dev/null
+++ b/tests/unit/alicode-cache-control-2069.test.js
@@ -0,0 +1,66 @@
+// #2069 — cache_control markers stripped for alicode/alicode-intl (DashScope) providers.
+// DashScope supports explicit cache_control: { type: "ephemeral" } in content blocks,
+// but the default filterToOpenAIFormat strips them. preserveCacheControl quirk opts-in.
+import { describe, it, expect } from "vitest";
+import { filterToOpenAIFormat } from "../../open-sse/translator/formats/openai.js";
+
+const msgWithCache = [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "large context", cache_control: { type: "ephemeral" } },
+ ],
+ },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "reply", cache_control: { type: "ephemeral" } },
+ ],
+ },
+];
+
+describe("filterToOpenAIFormat cache_control handling (#2069)", () => {
+ it("strips cache_control by default (all standard OpenAI providers)", () => {
+ const body = { messages: JSON.parse(JSON.stringify(msgWithCache)) };
+ filterToOpenAIFormat(body);
+ for (const msg of body.messages) {
+ for (const block of msg.content) {
+ expect(block.cache_control).toBeUndefined();
+ }
+ }
+ });
+
+ it("preserves cache_control when preserveCacheControl option is true (alicode/DashScope)", () => {
+ const body = { messages: JSON.parse(JSON.stringify(msgWithCache)) };
+ filterToOpenAIFormat(body, { preserveCacheControl: true });
+ for (const msg of body.messages) {
+ for (const block of msg.content) {
+ expect(block.cache_control).toEqual({ type: "ephemeral" });
+ }
+ }
+ });
+
+ it("always strips signature regardless of preserveCacheControl", () => {
+ const body = {
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "hi", signature: "sig123", cache_control: { type: "ephemeral" } },
+ ],
+ },
+ ],
+ };
+ filterToOpenAIFormat(body, { preserveCacheControl: true });
+ expect(body.messages[0].content[0].signature).toBeUndefined();
+ expect(body.messages[0].content[0].cache_control).toEqual({ type: "ephemeral" });
+ });
+
+ it("does not add cache_control when block had none (preserveCacheControl: true)", () => {
+ const body = {
+ messages: [{ role: "user", content: [{ type: "text", text: "no cache" }] }],
+ };
+ filterToOpenAIFormat(body, { preserveCacheControl: true });
+ expect(body.messages[0].content[0].cache_control).toBeUndefined();
+ });
+});
diff --git a/tests/unit/cached-token-e2e.test.js b/tests/unit/cached-token-e2e.test.js
new file mode 100644
index 00000000..f32f6a3e
--- /dev/null
+++ b/tests/unit/cached-token-e2e.test.js
@@ -0,0 +1,84 @@
+// End-to-end: a cache-bearing request flows through canonicalizeUsage →
+// saveRequestUsage → getUsageStats, proving cached tokens are persisted,
+// aggregated, and cost is computed correctly (the bug this branch fixes).
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
+import { canonicalizeUsage } from "../../open-sse/utils/usageTracking.js";
+
+const originalDataDir = process.env.DATA_DIR;
+let tempDir;
+let db;
+
+beforeAll(async () => {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-cached-e2e-"));
+ process.env.DATA_DIR = tempDir;
+ vi.resetModules();
+ db = await import("@/lib/db/index.js");
+ await db.initDb();
+});
+
+afterAll(() => {
+ if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
+ if (originalDataDir === undefined) delete process.env.DATA_DIR;
+ else process.env.DATA_DIR = originalDataDir;
+});
+
+describe("cached-token end-to-end (persist + aggregate + cost)", () => {
+ it("Claude cache usage: canonical prompt is inclusive, cached persisted, cost correct", async () => {
+ // Raw Claude usage (cache-EXCLUSIVE prompt): input 100, cache_read 200, cache_creation 30, output 50
+ const canonical = canonicalizeUsage({
+ prompt_tokens: 100,
+ completion_tokens: 50,
+ cache_read_input_tokens: 200,
+ cache_creation_input_tokens: 30,
+ });
+ expect(canonical.prompt_tokens).toBe(330); // inclusive
+
+ await db.saveRequestUsage({
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ connectionId: "c-cache",
+ tokens: canonical,
+ endpoint: "/v1/messages",
+ status: "ok",
+ });
+
+ const stats = await db.getUsageStats("24h");
+ expect(stats.totalCachedTokens).toBe(200);
+ expect(stats.totalPromptTokens).toBe(330);
+ expect(stats.byProvider.anthropic.cachedTokens).toBe(200);
+
+ // Cost: nonCached=330-200-30=100 @3 + cached 200 @0.30 + creation 30 @3.75 + output 50 @15
+ const expected = (100 * 3 + 200 * 0.3 + 30 * 3.75 + 50 * 15) / 1_000_000;
+ const hist = await db.getUsageHistory({ provider: "anthropic" });
+ expect(hist.length).toBe(1);
+ expect(hist[0].cost).toBeCloseTo(expected, 12);
+ expect(hist[0].tokens.cached_tokens).toBe(200);
+ expect(hist[0].tokens.cache_creation_input_tokens).toBe(30);
+ });
+
+ it("OpenAI cache usage: inclusive prompt passes through, cached counted once", async () => {
+ const canonical = canonicalizeUsage({
+ prompt_tokens: 1000, // already includes cached
+ completion_tokens: 200,
+ cached_tokens: 600,
+ });
+ expect(canonical.prompt_tokens).toBe(1000);
+ expect(canonical.cached_tokens).toBe(600);
+
+ await db.saveRequestUsage({
+ provider: "openai",
+ model: "gpt-4o",
+ connectionId: "c-oai",
+ tokens: canonical,
+ endpoint: "/v1/chat/completions",
+ status: "ok",
+ });
+
+ const hist = await db.getUsageHistory({ provider: "openai" });
+ expect(hist[0].tokens.prompt_tokens).toBe(1000);
+ expect(hist[0].tokens.cached_tokens).toBe(600);
+ });
+});
diff --git a/tests/unit/cached-token-usage.test.js b/tests/unit/cached-token-usage.test.js
new file mode 100644
index 00000000..878110d0
--- /dev/null
+++ b/tests/unit/cached-token-usage.test.js
@@ -0,0 +1,188 @@
+import { describe, it, expect } from "vitest";
+import { canonicalizeUsage, extractUsage, mergeUsage } from "../../open-sse/utils/usageTracking.js";
+import { calculateCostFromTokens } from "../../open-sse/providers/pricing.js";
+import { toOpenAIUsage } from "../../open-sse/translator/concerns/usage.js";
+
+// Canonical convention (single source of truth for storage + cost):
+// prompt_tokens = total input INCLUDING cache read + cache creation
+// cached_tokens = cache-read portion (subset of prompt_tokens)
+// cache_creation_input_tokens = cache-write portion (subset of prompt_tokens)
+// completion_tokens = output
+// Discriminator: Claude reports cache separately (prompt EXCLUDES cache);
+// OpenAI/Gemini report prompt INCLUDING cached_tokens.
+describe("canonicalizeUsage", () => {
+ it("folds Claude exclusive cache into an inclusive prompt count", () => {
+ // Claude: input_tokens excludes cache; cache_read + cache_creation are separate
+ const out = canonicalizeUsage({
+ prompt_tokens: 100,
+ completion_tokens: 50,
+ cache_read_input_tokens: 200,
+ cache_creation_input_tokens: 30,
+ });
+ expect(out.prompt_tokens).toBe(330); // 100 + 200 + 30
+ expect(out.completion_tokens).toBe(50);
+ expect(out.cached_tokens).toBe(200);
+ expect(out.cache_creation_input_tokens).toBe(30);
+ });
+
+ it("passes through OpenAI inclusive prompt unchanged", () => {
+ // OpenAI: prompt_tokens already includes cached_tokens (a subset)
+ const out = canonicalizeUsage({
+ prompt_tokens: 330,
+ completion_tokens: 50,
+ cached_tokens: 200,
+ });
+ expect(out.prompt_tokens).toBe(330);
+ expect(out.cached_tokens).toBe(200);
+ expect(out.cache_creation_input_tokens).toBe(0);
+ });
+
+ it("passes through Gemini inclusive prompt (cachedContent already counted)", () => {
+ const out = canonicalizeUsage({
+ prompt_tokens: 500,
+ completion_tokens: 80,
+ cached_tokens: 120,
+ reasoning_tokens: 40,
+ });
+ expect(out.prompt_tokens).toBe(500);
+ expect(out.cached_tokens).toBe(120);
+ expect(out.reasoning_tokens).toBe(40);
+ });
+
+ it("handles no-cache usage", () => {
+ const out = canonicalizeUsage({ prompt_tokens: 100, completion_tokens: 50 });
+ expect(out.prompt_tokens).toBe(100);
+ expect(out.cached_tokens).toBe(0);
+ expect(out.cache_creation_input_tokens).toBe(0);
+ });
+
+ it("is idempotent (running twice yields the same canonical shape)", () => {
+ const once = canonicalizeUsage({
+ prompt_tokens: 100,
+ completion_tokens: 50,
+ cache_read_input_tokens: 200,
+ cache_creation_input_tokens: 30,
+ });
+ const twice = canonicalizeUsage(once);
+ expect(twice.prompt_tokens).toBe(330);
+ expect(twice.cached_tokens).toBe(200);
+ expect(twice.cache_creation_input_tokens).toBe(30);
+ expect(twice.completion_tokens).toBe(50);
+ });
+
+ it("returns null for invalid input", () => {
+ expect(canonicalizeUsage(null)).toBeNull();
+ expect(canonicalizeUsage(undefined)).toBeNull();
+ });
+
+ it("folds a Claude cache-miss first write (cache_creation only, no cache_read yet)", () => {
+ // Cache-miss on first write: upstream emits cache_creation_input_tokens but
+ // no cache_read_input_tokens at all (not even 0). Must still fold into prompt
+ // instead of falling through to the OpenAI passthrough branch.
+ const out = canonicalizeUsage({
+ prompt_tokens: 100,
+ completion_tokens: 20,
+ cache_creation_input_tokens: 500,
+ });
+ expect(out.prompt_tokens).toBe(600); // 100 + 0 (no read) + 500
+ expect(out.cached_tokens).toBe(0);
+ expect(out.cache_creation_input_tokens).toBe(500);
+ });
+});
+
+describe("calculateCostFromTokens (canonical inclusive convention)", () => {
+ const pricing = { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 };
+
+ it("prices cached + cache_creation as subsets of an inclusive prompt without double-counting", () => {
+ // prompt=330 includes 200 cached + 30 cache_creation → 100 full-price input
+ const cost = calculateCostFromTokens(
+ { prompt_tokens: 330, completion_tokens: 50, cached_tokens: 200, cache_creation_input_tokens: 30 },
+ pricing
+ );
+ const expected =
+ (100 * 3 + 200 * 0.3 + 30 * 3.75 + 50 * 15) / 1_000_000;
+ expect(cost).toBeCloseTo(expected, 12);
+ });
+
+ it("does not let cache_creation drive nonCached negative", () => {
+ // pathological: cached + creation exceeds prompt → nonCached clamps at 0
+ const cost = calculateCostFromTokens(
+ { prompt_tokens: 100, completion_tokens: 0, cached_tokens: 80, cache_creation_input_tokens: 40 },
+ pricing
+ );
+ const expected = (0 * 3 + 80 * 0.3 + 40 * 3.75) / 1_000_000;
+ expect(cost).toBeCloseTo(expected, 12);
+ });
+
+ it("matches plain input pricing when no cache present", () => {
+ const cost = calculateCostFromTokens({ prompt_tokens: 100, completion_tokens: 50 }, pricing);
+ expect(cost).toBeCloseTo((100 * 3 + 50 * 15) / 1_000_000, 12);
+ });
+});
+
+describe("Anthropic streaming usage (message_start carries cache, message_delta output-only)", () => {
+ it("extractUsage reads input + cache from message_start", () => {
+ const u = extractUsage({
+ type: "message_start",
+ message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 } },
+ });
+ expect(u.prompt_tokens).toBe(100);
+ expect(u.cache_read_input_tokens).toBe(200);
+ expect(u.cache_creation_input_tokens).toBe(30);
+ });
+
+ it("merges message_start cache with message_delta output without clobbering", () => {
+ // Real Anthropic SSE: cache only in message_start, real output only in message_delta.
+ const start = extractUsage({
+ type: "message_start",
+ message: { usage: { input_tokens: 100, output_tokens: 1, cache_read_input_tokens: 200, cache_creation_input_tokens: 30 } },
+ });
+ const delta = extractUsage({ type: "message_delta", usage: { output_tokens: 50 } });
+ const merged = mergeUsage(start, delta);
+ expect(merged.prompt_tokens).toBe(100);
+ expect(merged.cache_read_input_tokens).toBe(200);
+ expect(merged.cache_creation_input_tokens).toBe(30);
+ expect(merged.completion_tokens).toBe(50);
+
+ // And it canonicalizes to a cache-inclusive prompt for storage/cost.
+ const canon = canonicalizeUsage(merged);
+ expect(canon.prompt_tokens).toBe(330); // 100 + 200 + 30
+ expect(canon.cached_tokens).toBe(200);
+ expect(canon.cache_creation_input_tokens).toBe(30);
+ expect(canon.completion_tokens).toBe(50);
+ });
+
+ it("does not let a NaN field poison the running max-merge", () => {
+ // typeof NaN === "number", so a naive Math.max(prev, NaN) is NaN — one
+ // malformed chunk must not wipe out an already-accumulated good value.
+ const prev = { prompt_tokens: 100, cache_read_input_tokens: 200 };
+ const bad = { prompt_tokens: NaN, completion_tokens: 50 };
+ const merged = mergeUsage(prev, bad);
+ expect(merged.prompt_tokens).toBe(100);
+ expect(merged.cache_read_input_tokens).toBe(200);
+ expect(merged.completion_tokens).toBe(50);
+ });
+});
+
+describe("Kiro usage pass-through", () => {
+ it("passes through plain input/output when no cache fields are present", () => {
+ const out = toOpenAIUsage({ inputTokens: 100, outputTokens: 50 }, "kiro");
+ expect(out.prompt_tokens).toBe(100);
+ expect(out.completion_tokens).toBe(50);
+ expect(out.total_tokens).toBe(150);
+ expect(out.prompt_tokens_details).toBeUndefined();
+ });
+
+ it("forward-compat: surfaces cache fields if Kiro event shape grows them", () => {
+ // ponytail: Amazon Q upstream doesn't expose cache today, but if it starts
+ // sending cache_read_input_tokens / cache_creation_input_tokens / cachedTokens,
+ // cost tracking should pick them up automatically without another change.
+ const out = toOpenAIUsage(
+ { inputTokens: 500, outputTokens: 100, cache_read_input_tokens: 200, cache_creation_input_tokens: 50 },
+ "kiro"
+ );
+ expect(out.prompt_tokens_details).toBeDefined();
+ expect(out.prompt_tokens_details.cached_tokens).toBe(200);
+ expect(out.prompt_tokens_details.cache_creation_tokens).toBe(50);
+ });
+});
diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js
index 3839809b..31512e9a 100644
--- a/tests/unit/capabilities.test.js
+++ b/tests/unit/capabilities.test.js
@@ -2,6 +2,15 @@ import { describe, expect, it } from "vitest";
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
describe("getCapabilitiesForModel", () => {
+ const claudeSonnet5Expected = {
+ contextWindow: 1000000,
+ maxOutput: 128000,
+ thinkingFormat: "claude-adaptive",
+ reasoning: true,
+ vision: true,
+ search: true,
+ };
+
it("reports Kiro Claude Opus 4.8 as a 1M context model", () => {
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000);
expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000);
@@ -9,4 +18,12 @@ describe("getCapabilitiesForModel", () => {
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8-thinking").contextWindow).toBe(1000000);
expect(getCapabilitiesForModel("kiro", "claude-opus-4-8-thinking").contextWindow).toBe(1000000);
});
+
+ it("reports Kiro Claude Sonnet 5 as a 1M adaptive-thinking model", () => {
+ expect(getCapabilitiesForModel("kiro", "claude-sonnet-5")).toMatchObject(claudeSonnet5Expected);
+ expect(getCapabilitiesForModel("kiro", "anthropic/claude-sonnet-5")).toMatchObject(claudeSonnet5Expected);
+ expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking")).toMatchObject(claudeSonnet5Expected);
+ expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-agentic")).toMatchObject(claudeSonnet5Expected);
+ expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking-agentic")).toMatchObject(claudeSonnet5Expected);
+ });
});
diff --git a/tests/unit/codebuddy-cn-bonus-recurring.test.js b/tests/unit/codebuddy-cn-bonus-recurring.test.js
new file mode 100644
index 00000000..7d75942f
--- /dev/null
+++ b/tests/unit/codebuddy-cn-bonus-recurring.test.js
@@ -0,0 +1,30 @@
+// CodeBuddy CN mixes recurring refill packs with one-shot bonus packs.
+// Bonus packs ("Bonus Pack N") must surface recurring:false so the dashboard
+// shows "Expires in" instead of implying a monthly refill. The usage handler
+// tags the flag and parseQuotaData must forward it.
+import { describe, it, expect } from "vitest";
+import { parseQuotaData } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
+
+describe("parseQuotaData codebuddy-cn recurring flag", () => {
+ it("forwards recurring:false for bonus packs and true for refill packs", () => {
+ const data = {
+ plan: "CodeBuddy CN",
+ quotas: {
+ Monthly: { used: 6.54, total: 500, resetAt: "2026-07-31T00:00:00Z", recurring: true },
+ "Bonus Pack 1": { used: 12, total: 100, resetAt: "2026-07-15T00:00:00Z", recurring: false },
+ },
+ };
+
+ const out = parseQuotaData("codebuddy-cn", data);
+ const byName = Object.fromEntries(out.map((q) => [q.name, q]));
+
+ expect(byName["Monthly"].recurring).toBe(true);
+ expect(byName["Bonus Pack 1"].recurring).toBe(false);
+ });
+
+ it("defaults recurring to true when the flag is absent (back-compat)", () => {
+ const data = { quotas: { Monthly: { used: 0, total: 100, resetAt: null } } };
+ const out = parseQuotaData("codebuddy-cn", data);
+ expect(out[0].recurring).toBe(true);
+ });
+});
diff --git a/tests/unit/codex-reset-credits.test.js b/tests/unit/codex-reset-credits.test.js
new file mode 100644
index 00000000..c8b4c6fd
--- /dev/null
+++ b/tests/unit/codex-reset-credits.test.js
@@ -0,0 +1,198 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ proxyAwareFetch: vi.fn(),
+ getProviderConnectionById: vi.fn(),
+ resolveConnectionProxyConfig: vi.fn(),
+ refreshAndUpdateCredentials: vi.fn(),
+ getCodexRateLimitResetCredits: vi.fn(),
+ consumeCodexRateLimitResetCredit: vi.fn(),
+}));
+
+vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
+ proxyAwareFetch: mocks.proxyAwareFetch,
+}));
+
+vi.mock("open-sse/index.js", () => ({}));
+
+vi.mock("@/lib/localDb", () => ({
+ getProviderConnectionById: mocks.getProviderConnectionById,
+}));
+
+vi.mock("@/lib/network/connectionProxy", () => ({
+ resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig,
+}));
+
+vi.mock("@/app/api/usage/[connectionId]/route.js", () => ({
+ refreshAndUpdateCredentials: mocks.refreshAndUpdateCredentials,
+}));
+
+vi.mock("open-sse/services/usage.js", () => ({
+ getCodexRateLimitResetCredits: mocks.getCodexRateLimitResetCredits,
+ consumeCodexRateLimitResetCredit: mocks.consumeCodexRateLimitResetCredit,
+}));
+
+describe("Codex reset credits", () => {
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ mocks.resolveConnectionProxyConfig.mockResolvedValue({});
+ });
+
+ it("returns normalized reset credit expiry details", async () => {
+ mocks.proxyAwareFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({
+ available_count: 2,
+ credits: [
+ {
+ status: "available",
+ granted_at: "2026-06-18T00:25:18Z",
+ expires_at: "2026-07-18T00:25:18Z",
+ },
+ {
+ status: "redeemed",
+ granted_at: "bad-date",
+ expires_at: null,
+ },
+ ],
+ }),
+ });
+
+ const { getCodexRateLimitResetCredits } = await import("../../open-sse/services/usage/codex.js");
+ const result = await getCodexRateLimitResetCredits("token", { strictProxy: false }, { workspaceId: "acct_123" });
+
+ expect(mocks.proxyAwareFetch).toHaveBeenCalledWith(
+ expect.stringContaining("/rate-limit-reset-credits"),
+ expect.objectContaining({
+ method: "GET",
+ headers: expect.objectContaining({
+ Authorization: "Bearer token",
+ "ChatGPT-Account-ID": "acct_123",
+ }),
+ }),
+ { strictProxy: false },
+ );
+ expect(result).toEqual({
+ availableCount: 2,
+ credits: [
+ {
+ status: "available",
+ grantedAt: "2026-06-18T00:25:18.000Z",
+ expiresAt: "2026-07-18T00:25:18.000Z",
+ },
+ {
+ status: "redeemed",
+ grantedAt: null,
+ expiresAt: null,
+ },
+ ],
+ });
+ });
+
+ it("GET refreshes OAuth credentials before returning reset credit details", async () => {
+ const connection = {
+ id: "conn_1",
+ provider: "codex",
+ authType: "oauth",
+ accessToken: "old-token",
+ refreshToken: "refresh-token",
+ providerSpecificData: { workspaceId: "acct_123" },
+ };
+ const refreshedConnection = { ...connection, accessToken: "new-token" };
+ const resetCredits = {
+ availableCount: 1,
+ credits: [{ status: "available", grantedAt: "2026-06-18T00:25:18.000Z", expiresAt: "2026-07-18T00:25:18.000Z" }],
+ };
+ mocks.getProviderConnectionById.mockResolvedValue(connection);
+ mocks.resolveConnectionProxyConfig.mockResolvedValue({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local" });
+ mocks.refreshAndUpdateCredentials.mockResolvedValue({ connection: refreshedConnection });
+ mocks.getCodexRateLimitResetCredits.mockResolvedValue(resetCredits);
+
+ const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js");
+ const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), {
+ params: Promise.resolve({ connectionId: "conn_1" }),
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual(resetCredits);
+ expect(mocks.refreshAndUpdateCredentials).toHaveBeenCalledWith(
+ connection,
+ false,
+ expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }),
+ );
+ expect(mocks.getCodexRateLimitResetCredits).toHaveBeenCalledWith(
+ "new-token",
+ expect.objectContaining({ connectionProxyEnabled: true, connectionProxyUrl: "http://proxy.local", strictProxy: false }),
+ { workspaceId: "acct_123" },
+ );
+ });
+
+ it("GET force-refreshes OAuth credentials when reset credit fetch reports expired auth", async () => {
+ const connection = {
+ id: "conn_1",
+ provider: "codex",
+ authType: "oauth",
+ accessToken: "old-token",
+ refreshToken: "refresh-token",
+ providerSpecificData: {},
+ };
+ const refreshedConnection = { ...connection, accessToken: "new-token" };
+ const forcedConnection = { ...connection, accessToken: "forced-token" };
+ const resetCredits = { availableCount: 0, credits: [] };
+ mocks.getProviderConnectionById.mockResolvedValue(connection);
+ mocks.refreshAndUpdateCredentials
+ .mockResolvedValueOnce({ connection: refreshedConnection })
+ .mockResolvedValueOnce({ connection: forcedConnection });
+ mocks.getCodexRateLimitResetCredits
+ .mockRejectedValueOnce(new Error("Unauthorized 401"))
+ .mockResolvedValueOnce(resetCredits);
+
+ const { GET } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js");
+ const response = await GET(new Request("http://localhost/api/usage/conn_1/codex-reset-credits"), {
+ params: Promise.resolve({ connectionId: "conn_1" }),
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual(resetCredits);
+ expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(1, connection, false, expect.any(Object));
+ expect(mocks.refreshAndUpdateCredentials).toHaveBeenNthCalledWith(2, refreshedConnection, true, expect.any(Object));
+ expect(mocks.getCodexRateLimitResetCredits).toHaveBeenNthCalledWith(2, "forced-token", expect.any(Object), {});
+ });
+
+ it("POST returns 409 when there are no reset credits to consume", async () => {
+ mocks.getProviderConnectionById.mockResolvedValue({
+ id: "conn_1",
+ provider: "codex",
+ authType: "access_token",
+ accessToken: "token",
+ providerSpecificData: {},
+ });
+ mocks.consumeCodexRateLimitResetCredit.mockResolvedValue({
+ ok: false,
+ noCredit: true,
+ status: 200,
+ code: "no_credit",
+ windowsReset: 0,
+ });
+
+ const { POST } = await import("../../src/app/api/usage/[connectionId]/codex-reset-credits/route.js");
+ const response = await POST(new Request("http://localhost/api/usage/conn_1/codex-reset-credits", { method: "POST" }), {
+ params: Promise.resolve({ connectionId: "conn_1" }),
+ });
+
+ expect(response.status).toBe(409);
+ expect(await response.json()).toMatchObject({
+ code: "no_credit",
+ reset: false,
+ windows_reset: 0,
+ message: "No Codex reset credits available.",
+ });
+ expect(mocks.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(
+ "token",
+ expect.any(String),
+ expect.objectContaining({ strictProxy: false }),
+ );
+ });
+});
diff --git a/tests/unit/compatible-provider-connections.test.js b/tests/unit/compatible-provider-connections.test.js
index c20c3059..0fe146f0 100644
--- a/tests/unit/compatible-provider-connections.test.js
+++ b/tests/unit/compatible-provider-connections.test.js
@@ -38,14 +38,14 @@ async function setupTestContext(nodeData) {
};
}
-function makeRequest(provider) {
+function makeRequest(provider, name = "Test Connection") {
return new Request("https://9router.local/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
apiKey: "test-key",
- name: "Test Connection",
+ name,
defaultModel: "test-model",
}),
});
@@ -145,26 +145,25 @@ describe("compatible provider connections API", () => {
});
});
- it("returns 400 for a duplicate connection on the same compatible node", async () => {
+ it("allows multiple connections on the same compatible node", async () => {
const ctx = await setupTestContext({
- id: "openai-compatible-duplicate-test",
+ id: "openai-compatible-multiple-test",
type: "openai-compatible",
- name: "Duplicate Guard Node",
- prefix: "dup",
+ name: "Multiple Connections Node",
+ prefix: "mul",
apiType: "chat",
- baseUrl: "https://duplicate-guard.test/v1",
+ baseUrl: "https://multiple-connections.test/v1",
});
cleanup = ctx.cleanup;
- const firstResponse = await ctx.POST(makeRequest(ctx.node.id));
- const secondResponse = await ctx.POST(makeRequest(ctx.node.id));
- const secondBody = await secondResponse.json();
+ const firstResponse = await ctx.POST(makeRequest(ctx.node.id, "Key A"));
+ const secondResponse = await ctx.POST(makeRequest(ctx.node.id, "Key B"));
const storedConnections = await ctx.getProviderConnections({ provider: ctx.node.id });
expect(firstResponse.status).toBe(201);
- expect(secondResponse.status).toBe(400);
- expect(secondBody.error).toContain("Only one connection is allowed");
- expect(storedConnections).toHaveLength(1);
+ expect(secondResponse.status).toBe(201);
+ expect(storedConnections).toHaveLength(2);
expectCompatibleConnection(storedConnections[0], ctx.node, { apiType: "chat" });
+ expectCompatibleConnection(storedConnections[1], ctx.node, { apiType: "chat" });
});
});
diff --git a/tests/unit/headroom-responses-format.test.js b/tests/unit/headroom-responses-format.test.js
index 0050a731..a796e16b 100644
--- a/tests/unit/headroom-responses-format.test.js
+++ b/tests/unit/headroom-responses-format.test.js
@@ -47,4 +47,61 @@ describe("compressWithHeadroom openai-responses format (#1998)", () => {
expect(Array.isArray(body.input[0].content)).toBe(true);
expect(typeof body.input[0].content).not.toBe("string");
});
+
+ it("skips Responses tool/reasoning history instead of collapsing it into a message (#2132)", async () => {
+ global.fetch = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({
+ messages: [{ role: "user", content: "compressed tool history" }],
+ tokens_saved: 10,
+ }),
+ }));
+
+ const input = [
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "investigate bug" }],
+ },
+ {
+ type: "function_call",
+ call_id: "call_apply_patch_123",
+ name: "apply_patch",
+ arguments: "*** Begin Patch\n*** End Patch",
+ },
+ {
+ type: "function_call_output",
+ call_id: "call_apply_patch_123",
+ output: "ok",
+ },
+ {
+ type: "reasoning",
+ summary: [{ type: "summary_text", text: "Need a plan" }],
+ },
+ ];
+ const body = {
+ input: structuredClone(input),
+ tools: [
+ {
+ type: "custom",
+ name: "apply_patch",
+ format: { type: "grammar", syntax: "lark", definition: "start: /.+/" },
+ },
+ ],
+ };
+ const diagnostics = {};
+
+ const data = await compressWithHeadroom(body, {
+ enabled: true,
+ url: "http://headroom.test",
+ model: "gpt-5",
+ format: "openai-responses",
+ diagnostics,
+ });
+
+ expect(data).toBeNull();
+ expect(global.fetch).not.toHaveBeenCalled();
+ expect(body.input).toEqual(input);
+ expect(diagnostics.reason).toBe("skipped: openai-responses tool/reasoning input is not safe to compress");
+ });
});
diff --git a/tests/unit/kimchi-strip-reasoning.test.js b/tests/unit/kimchi-strip-reasoning.test.js
new file mode 100644
index 00000000..0cb55dfb
--- /dev/null
+++ b/tests/unit/kimchi-strip-reasoning.test.js
@@ -0,0 +1,128 @@
+/**
+ * Kimchi executor: strip reasoning_content echoed by clients.
+ *
+ * Background: when 9Router streams a thinking model (deepseek-r1,
+ * minimax-m3) to a client, the response carries `reasoning_content`.
+ * Most OpenAI-compatible SDKs echo the whole history on the next turn,
+ * so Kimchi's upstream counts the scratch block as input tokens.
+ * Multi-turn conversations balloon to 100k+ input tokens and the model
+ * starts returning empty content.
+ *
+ * `stripReasoningContent` is intentionally conservative: it only strips
+ * `reasoning_content` that is clearly a real thinking block. The 1-char
+ * placeholder that `injectReasoningContent` (in `DefaultExecutor`) may
+ * insert for upstream validation is preserved — stripping it would
+ * re-trigger upstream complaints about missing reasoning on the next
+ * turn.
+ */
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+
+import KimchiExecutor, { stripReasoningContent } from "../../open-sse/executors/kimchi.js";
+import DefaultExecutor from "../../open-sse/executors/default.js";
+
+describe("kimchi stripReasoningContent", () => {
+ it("removes long reasoning_content from assistant messages but keeps content", () => {
+ const body = {
+ messages: [
+ { role: "user", content: "solve x+5=12" },
+ {
+ role: "assistant",
+ content: "x = 7",
+ reasoning_content: "subtract 5 from both sides ... (long reasoning block)",
+ },
+ { role: "user", content: "now try x+10=20" },
+ ],
+ };
+ stripReasoningContent(body);
+ assert.equal(body.messages[1].reasoning_content, undefined);
+ assert.equal(body.messages[1].content, "x = 7");
+ });
+
+ it("preserves the 1-char placeholder that injectReasoningContent sets", () => {
+ // `injectReasoningContent` may insert " " (single space) on assistant
+ // messages so the upstream's validation doesn't complain about missing
+ // reasoning. Stripping that placeholder would defeat its purpose.
+ const body = {
+ messages: [
+ { role: "user", content: "hi" },
+ { role: "assistant", content: "hello", reasoning_content: " " },
+ ],
+ };
+ stripReasoningContent(body);
+ assert.equal(body.messages[1].reasoning_content, " ");
+ assert.equal(body.messages[1].content, "hello");
+ });
+
+ it("preserves short custom reasoning under the threshold", () => {
+ // Anything ≤8 chars is treated as a placeholder-shaped value, kept
+ // verbatim. Real thinking content from a thinking model is always
+ // well above this threshold.
+ const body = {
+ messages: [
+ { role: "assistant", content: "ok", reasoning_content: "short" },
+ ],
+ };
+ stripReasoningContent(body);
+ assert.equal(body.messages[0].reasoning_content, "short");
+ });
+
+ it("leaves non-assistant messages untouched", () => {
+ const body = {
+ messages: [
+ { role: "user", content: "hi" },
+ { role: "system", content: "be helpful" },
+ ],
+ };
+ stripReasoningContent(body);
+ assert.equal(body.messages[0].content, "hi");
+ assert.equal(body.messages[1].content, "be helpful");
+ });
+
+ it("returns early on missing/empty messages array", () => {
+ assert.doesNotThrow(() => stripReasoningContent({}));
+ assert.doesNotThrow(() => stripReasoningContent({ messages: null }));
+ assert.doesNotThrow(() => stripReasoningContent({ messages: [] }));
+ });
+
+ it("ignores assistant messages that have no reasoning_content", () => {
+ const body = {
+ messages: [
+ { role: "user", content: "hi" },
+ { role: "assistant", content: "hello" },
+ ],
+ };
+ stripReasoningContent(body);
+ assert.deepEqual(body.messages[1], { role: "assistant", content: "hello" });
+ });
+
+ it("handles multi-turn: strips old turns, keeps recent one", () => {
+ const LONG = "x".repeat(1000);
+ const body = {
+ messages: [
+ { role: "user", content: "q1" },
+ { role: "assistant", content: "a1", reasoning_content: LONG },
+ { role: "user", content: "q2" },
+ { role: "assistant", content: "a2", reasoning_content: " " }, // placeholder
+ ],
+ };
+ stripReasoningContent(body);
+ assert.equal(body.messages[1].reasoning_content, undefined);
+ assert.equal(body.messages[3].reasoning_content, " ");
+ });
+});
+
+describe("kimchi executor wiring", () => {
+ it("KimchiExecutor extends DefaultExecutor via prototype chain", () => {
+ const inst = new KimchiExecutor();
+ assert.ok(
+ inst instanceof DefaultExecutor,
+ "KimchiExecutor must extend DefaultExecutor so transformRequest runs through super",
+ );
+ });
+
+ it("default export is KimchiExecutor class", () => {
+ assert.equal(typeof KimchiExecutor, "function");
+ assert.equal(KimchiExecutor.name, "KimchiExecutor");
+ });
+});
diff --git a/tests/unit/kimchi.test.js b/tests/unit/kimchi.test.js
new file mode 100644
index 00000000..1f300f8d
--- /dev/null
+++ b/tests/unit/kimchi.test.js
@@ -0,0 +1,234 @@
+import { describe, it, before } from "node:test";
+import assert from "node:assert/strict";
+
+// Load the registry entry once for the suite so a load failure is reported
+// next to the failing test instead of cascading as "undefined" in every
+// later assertion.
+let kimchiEntry;
+
+describe("kimchi registry entry", () => {
+ before(async () => {
+ kimchiEntry = (await import("../../open-sse/providers/registry/kimchi.js")).default;
+ });
+
+ it("is an oauth provider auto-listed via byCategory", () => {
+ assert.equal(kimchiEntry.id, "kimchi");
+ assert.equal(kimchiEntry.category, "oauth");
+ });
+
+ it("points at the OpenAI-compatible gateway with an authenticated UA", () => {
+ assert.equal(
+ kimchiEntry.transport.baseUrl,
+ "https://llm.kimchi.dev/openai/v1/chat/completions",
+ );
+ // UA must be a non-empty string the gateway can identify; the value
+ // itself is owned by the Kimchi CLI release and may change upstream.
+ const ua = kimchiEntry.transport.headers["User-Agent"];
+ assert.ok(typeof ua === "string" && ua.length > 0, `User-Agent missing: ${ua}`);
+ });
+
+ it("uses Bearer auth", () => {
+ assert.deepEqual(kimchiEntry.transport.auth, {
+ combined: true,
+ header: "Authorization",
+ scheme: "bearer",
+ });
+ });
+
+ it("exposes the upstream static models", () => {
+ const ids = kimchiEntry.models.map((m) => m.id);
+ assert.ok(ids.includes("kimi-k2.7"));
+ assert.ok(ids.includes("minimax-m3"));
+ assert.ok(ids.includes("nemotron-3-ultra-fp4"));
+ assert.ok(ids.length >= 5, `expected >= 5 static models, got ${ids.length}`);
+ });
+
+ it("passes through models not in the static list", () => {
+ assert.equal(kimchiEntry.passthroughModels, true);
+ });
+});
+
+// ── Pure-function clones of the service logic (tested in isolation so
+// node --test works without resolving the Next.js Webpack "open-sse"
+// alias that src/lib/oauth/services/kimchi.js's dependency imports). ──
+
+function buildKimchiAuthUrl(callbackUrl, state) {
+ const params = new URLSearchParams({ callback: callbackUrl, state });
+ return `https://app.kimchi.dev/cli-auth?${params.toString()}`;
+}
+
+async function _handleCallback(params, expectedState) {
+ if (params.error) {
+ throw new Error(params.error_description || params.error);
+ }
+ const candidate = params.state;
+ if (!candidate || candidate !== expectedState) {
+ throw new Error(
+ "This request isn't valid. Please restart the Kimchi login flow.",
+ );
+ }
+ const token = params.token;
+ if (!token) {
+ throw new Error("No token was returned by the Kimchi authentication server");
+ }
+ return { token };
+}
+
+describe("kimchi oauth", () => {
+ it("builds the cli-auth URL with encoded callback + state", () => {
+ const url = buildKimchiAuthUrl("http://127.0.0.1:4321/callback", "abc123");
+ const parsed = new URL(url);
+ assert.equal(parsed.origin, "https://app.kimchi.dev");
+ assert.equal(parsed.pathname, "/cli-auth");
+ assert.equal(parsed.searchParams.get("callback"), "http://127.0.0.1:4321/callback");
+ assert.equal(parsed.searchParams.get("state"), "abc123");
+ });
+
+ it("rejects a callback whose state does not match", async () => {
+ await assert.rejects(
+ () => _handleCallback({ token: "castai_v1_x", state: "wrong" }, "expected"),
+ /restart/i,
+ );
+ });
+
+ it("accepts a callback with matching state and returns the token", async () => {
+ const res = await _handleCallback({ token: "castai_v1_x", state: "match" }, "match");
+ assert.equal(res.token, "castai_v1_x");
+ });
+});
+
+// ── kimchiModels service (pure mapping logic, tested in isolation) ──
+
+// Clone of the metadata→model mapper so node --test resolves without the
+// open-sse/Webpack alias chain the real module imports.
+function mapKimchiMetadata(raw) {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((m) => ({
+ id: m.slug,
+ name: m.display_name || m.slug,
+ contextLength: m.limits?.context_window || null,
+ maxOutputTokens: m.limits?.max_output_tokens || null,
+ isReasoning: m.reasoning === true,
+ }));
+}
+
+describe("kimchiModels", () => {
+ it("maps Kimchi metadata entries to 9router model shape", () => {
+ const raw = [{
+ slug: "glm-5.2-fp8",
+ display_name: "GLM 5.2",
+ reasoning: true,
+ limits: { context_window: 1048576, max_output_tokens: 1048576 },
+ }];
+ const models = mapKimchiMetadata(raw);
+ assert.equal(models.length, 1);
+ assert.deepEqual(models[0], {
+ id: "glm-5.2-fp8",
+ name: "GLM 5.2",
+ contextLength: 1048576,
+ maxOutputTokens: 1048576,
+ isReasoning: true,
+ });
+ });
+
+ it("falls back to slug as name when display_name is empty", () => {
+ const models = mapKimchiMetadata([{ slug: "kimi-k2.7", display_name: "", reasoning: false, limits: {} }]);
+ assert.equal(models[0].name, "kimi-k2.7");
+ assert.equal(models[0].contextLength, null);
+ assert.equal(models[0].isReasoning, false);
+ });
+
+ it("returns empty array for non-array input", () => {
+ assert.deepEqual(mapKimchiMetadata(null), []);
+ assert.deepEqual(mapKimchiMetadata({}), []);
+ });
+});
+
+// ── validateToken logic (pure decision over a status code) ──
+
+// Mirrors the decision in KimchiService.validateToken without importing the
+// service (which pulls the open-sse Webpack alias chain).
+function decideValidity(status) {
+ if (status === 200) return { valid: true };
+ if (status === 401) return { valid: false, error: "Kimchi token invalid or expired" };
+ if (status === 403) return { valid: false, error: "Kimchi token lacks required scope" };
+ return { valid: true }; // fail-open on unknown / network error
+}
+
+describe("kimchi validateToken", () => {
+ it("200 → valid", () => {
+ assert.deepEqual(decideValidity(200), { valid: true });
+ });
+ it("401 → invalid, expired message", () => {
+ const r = decideValidity(401);
+ assert.equal(r.valid, false);
+ assert.match(r.error, /invalid or expired/i);
+ });
+ it("403 → invalid, scope message", () => {
+ const r = decideValidity(403);
+ assert.equal(r.valid, false);
+ assert.match(r.error, /scope/i);
+ });
+ it("unknown / network error → fail-open valid", () => {
+ assert.equal(decideValidity(500).valid, true);
+ assert.equal(decideValidity(0).valid, true);
+ });
+});
+
+// ── OAuth dedup logic (pure clone of connectionsRepo matcher) ──
+// Mimics the find() predicate in createProviderConnection for OAuth
+// connections, so we can test the IdP-collision fix in isolation.
+function findExistingOAuth(all, incoming) {
+ const incomingEmail = incoming.email;
+ const incomingUsername = incoming.providerSpecificData?.username;
+ const incomingWs = incoming.providerSpecificData?.chatgptAccountId;
+ return all.find((c) => {
+ if (c.authType !== "oauth" || c.email !== incomingEmail) return false;
+ const existingWs = c.providerSpecificData?.chatgptAccountId;
+ if (incomingWs && existingWs) return incomingWs === existingWs;
+ if (incomingWs && !existingWs) return false;
+ if (!incomingWs && existingWs) return false;
+ const existingUsername = c.providerSpecificData?.username;
+ if (incomingUsername && existingUsername) {
+ return incomingUsername === existingUsername;
+ }
+ if (incomingUsername || existingUsername) return false;
+ return true;
+ });
+}
+
+describe("kimchi OAuth dedup", () => {
+ const google = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "google-oauth2|123" } };
+ const hf = { authType: "oauth", email: "x@y.com", providerSpecificData: { username: "huggingface|456" } };
+ const legacy = { authType: "oauth", email: "x@y.com", providerSpecificData: {} };
+ const other = { authType: "oauth", email: "z@y.com", providerSpecificData: { username: "google-oauth2|789" } };
+
+ it("different email never matches", () => {
+ assert.equal(findExistingOAuth([other], google), undefined);
+ });
+
+ it("same email + same username = dedup (re-login same IdP)", () => {
+ const found = findExistingOAuth([google], { ...google });
+ assert.equal(found, google);
+ });
+
+ it("same email + different username = NO match (cross-IdP, the bug)", () => {
+ assert.equal(findExistingOAuth([google], hf), undefined);
+ });
+
+ it("legacy row without username matches incoming without username (backward compat)", () => {
+ assert.equal(findExistingOAuth([legacy], { ...legacy }), legacy);
+ });
+
+ it("incoming without username does not match legacy row with username", () => {
+ assert.equal(findExistingOAuth([google], { ...legacy }), undefined);
+ });
+
+ it("workspaces still dedupe on workspace ID when both sides have one", () => {
+ const ws1 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } };
+ const ws1dup = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws1" } };
+ const ws2 = { authType: "oauth", email: "a@b.com", providerSpecificData: { chatgptAccountId: "ws2" } };
+ assert.equal(findExistingOAuth([ws1], ws1dup), ws1);
+ assert.equal(findExistingOAuth([ws1], ws2), undefined);
+ });
+});
diff --git a/tests/unit/kiro-model-slots.test.js b/tests/unit/kiro-model-slots.test.js
index 3eb9c7b8..ffc3e382 100644
--- a/tests/unit/kiro-model-slots.test.js
+++ b/tests/unit/kiro-model-slots.test.js
@@ -1,12 +1,10 @@
import { describe, expect, it } from "vitest";
+import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js";
import { MITM_TOOLS } from "../../src/shared/constants/cliTools.js";
-// Guards the fix in commit 356607c: Kiro's agent/"vibe" mode sends modelId
-// "auto" for the main turn and "simple-task" for background sub-tasks. Both
-// need a mappable defaultModels slot — otherwise getMappedModel (src/mitm/server.js)
-// returns null and the /generateAssistantResponse call is passed through to AWS
-// instead of being routed to the user's chosen provider (surfacing as Kiro's
-// "monthly usage limit" once the AWS quota is gone).
+// Guards Kiro model ids that still need mappable defaultModels slots. Without
+// a slot, getMappedModel (src/mitm/server.js) returns null and the request is
+// passed through to AWS instead of being routed to the user's chosen provider.
describe("Kiro MITM model slots", () => {
const kiro = MITM_TOOLS.kiro;
@@ -16,10 +14,10 @@ describe("Kiro MITM model slots", () => {
expect(Array.isArray(kiro.defaultModels)).toBe(true);
});
- it("offers a mappable slot for the agent default model id 'auto'", () => {
- const auto = kiro.defaultModels.find((m) => m.id === "auto");
- expect(auto).toBeTruthy();
- expect(auto.alias).toBe("auto");
+ it("offers a mappable slot for Claude Sonnet 5", () => {
+ const sonnet5 = kiro.defaultModels.find((m) => m.id === "claude-sonnet-5");
+ expect(sonnet5).toBeTruthy();
+ expect(sonnet5.alias).toBe("claude-sonnet-5");
});
it("offers a mappable slot for the background sub-task model id 'simple-task'", () => {
@@ -28,3 +26,15 @@ describe("Kiro MITM model slots", () => {
expect(simpleTask.alias).toBe("simple-task");
});
});
+
+describe("Kiro static provider models", () => {
+ it("includes Claude Sonnet 5 and its synthetic Kiro variants", () => {
+ const ids = (PROVIDER_MODELS.kr || []).map((model) => model.id);
+ expect(ids).toEqual(expect.arrayContaining([
+ "claude-sonnet-5",
+ "claude-sonnet-5-thinking",
+ "claude-sonnet-5-agentic",
+ "claude-sonnet-5-thinking-agentic",
+ ]));
+ });
+});
diff --git a/tests/unit/kiro-thinking-strip.test.js b/tests/unit/kiro-thinking-strip.test.js
new file mode 100644
index 00000000..91b5009c
--- /dev/null
+++ b/tests/unit/kiro-thinking-strip.test.js
@@ -0,0 +1,124 @@
+import { describe, it, expect } from "vitest";
+import { KiroExecutor } from "../../open-sse/executors/kiro.js";
+
+function createMockFrame(eventType, payloadObj) {
+ const payloadStr = JSON.stringify(payloadObj);
+ const payloadBytes = new TextEncoder().encode(payloadStr);
+
+ const headerName = ":event-type";
+ const headerNameBytes = new TextEncoder().encode(headerName);
+ const headerValueBytes = new TextEncoder().encode(eventType);
+
+ // nameLen(1) + name + type(1) + valueLen(2) + value
+ const headerLength = 1 + headerNameBytes.length + 1 + 2 + headerValueBytes.length;
+ const totalLength = 12 + headerLength + payloadBytes.length + 4;
+
+ const buffer = new Uint8Array(totalLength);
+ const view = new DataView(buffer.buffer);
+
+ view.setUint32(0, totalLength, false);
+ view.setUint32(4, headerLength, false);
+
+ let offset = 12;
+ buffer[offset++] = headerNameBytes.length;
+ buffer.set(headerNameBytes, offset);
+ offset += headerNameBytes.length;
+
+ buffer[offset++] = 7; // String type
+ view.setUint16(offset, headerValueBytes.length, false);
+ offset += 2;
+ buffer.set(headerValueBytes, offset);
+ offset += headerValueBytes.length;
+
+ buffer.set(payloadBytes, offset);
+
+ return buffer;
+}
+
+async function readAllSSE(stream) {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let result = "";
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ result += decoder.decode(value, { stream: true });
+ }
+ return result;
+}
+
+describe("KiroExecutor thinking tag stripping", () => {
+ it("strips
tags from assistantResponseEvent", async () => {
+ const executor = new KiroExecutor();
+
+ // Create frames
+ const f1 = createMockFrame("assistantResponseEvent", { content: "Here is my answer. Let me think..." });
+ const f2 = createMockFrame("assistantResponseEvent", { content: "still thinking... Yes, 42." });
+
+ const readableStream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(f1);
+ controller.enqueue(f2);
+ controller.close();
+ }
+ });
+
+ const mockResponse = { body: readableStream };
+ const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test");
+
+ const output = await readAllSSE(transformedResponse.body);
+
+ // Check that we got chat.completion.chunk outputs
+ expect(output).toContain("chat.completion.chunk");
+ // Ensure the thinking parts are gone
+ expect(output).not.toContain("");
+ expect(output).not.toContain("Let me think...");
+ expect(output).not.toContain("still thinking...");
+ expect(output).not.toContain("");
+
+ // Check that the normal content is preserved
+ // Parse the data chunks
+ const dataLines = output.split("\n").filter(line => line.startsWith("data: "));
+ const contents = dataLines.map(line => {
+ if (line.includes("[DONE]")) return "";
+ try {
+ return JSON.parse(line.slice(6)).choices[0].delta.content || "";
+ } catch {
+ return "";
+ }
+ });
+
+ const fullText = contents.join("");
+ expect(fullText).toBe("Here is my answer. Yes, 42.");
+ });
+
+ it("handles empty content after stripping when hasReasoningContent is true", async () => {
+ const executor = new KiroExecutor();
+
+ const f0 = createMockFrame("reasoningContentEvent", { text: "I am reasoning" });
+ const f1 = createMockFrame("assistantResponseEvent", { content: "purely thinking..." });
+
+ const readableStream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(f0);
+ controller.enqueue(f1);
+ controller.close();
+ }
+ });
+
+ const mockResponse = { body: readableStream };
+ const transformedResponse = executor.transformEventStreamToSSE(mockResponse, "claude-test");
+
+ const output = await readAllSSE(transformedResponse.body);
+
+ const dataLines = output.split("\n").filter(line => line.startsWith("data: ") && !line.includes("[DONE]"));
+ const objects = dataLines.map(line => JSON.parse(line.slice(6)));
+
+ // First chunk should have reasoning_content
+ expect(objects[0].choices[0].delta.reasoning_content).toBe("I am reasoning");
+
+ // We shouldn't get an empty content chunk from f1 since it was entirely stripped and reasoning was present
+ const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined);
+ expect(contentChunks.length).toBe(0);
+ });
+});
diff --git a/tests/unit/mitm-root-ca.test.js b/tests/unit/mitm-root-ca.test.js
new file mode 100644
index 00000000..f3e3f0a4
--- /dev/null
+++ b/tests/unit/mitm-root-ca.test.js
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { createRequire } from "module";
+import fs from "fs";
+import os from "os";
+import path from "path";
+
+const require = createRequire(import.meta.url);
+
+function loadRootCAWithDataDir(dataDir) {
+ const rootCAPath = require.resolve("../../src/mitm/cert/rootCA.js");
+ const pathsPath = require.resolve("../../src/mitm/paths.js");
+ delete require.cache[rootCAPath];
+ delete require.cache[pathsPath];
+
+ const oldDataDir = process.env.DATA_DIR;
+ process.env.DATA_DIR = dataDir;
+ try {
+ return require("../../src/mitm/cert/rootCA.js");
+ } finally {
+ if (oldDataDir === undefined) delete process.env.DATA_DIR;
+ else process.env.DATA_DIR = oldDataDir;
+ }
+}
+
+describe("MITM Root CA generation", () => {
+ it("creates Root CA files synchronously for direct server startup", () => {
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-mitm-ca-"));
+ const { generateRootCA } = loadRootCAWithDataDir(dataDir);
+
+ generateRootCA();
+
+ expect(fs.existsSync(path.join(dataDir, "mitm", "rootCA.key"))).toBe(true);
+ expect(fs.existsSync(path.join(dataDir, "mitm", "rootCA.crt"))).toBe(true);
+ });
+});
diff --git a/tests/unit/openai-responses-terminal-event.test.js b/tests/unit/openai-responses-terminal-event.test.js
index db2e3ad8..32ee66c3 100644
--- a/tests/unit/openai-responses-terminal-event.test.js
+++ b/tests/unit/openai-responses-terminal-event.test.js
@@ -67,6 +67,19 @@ describe("OpenAI Responses streaming termination", () => {
expect(output).toContain("data: [DONE]");
});
+ it("does not add response.failed when a Responses stream sends response.done", async () => {
+ const output = await runTransform([
+ `event: response.done`,
+ `data: ${JSON.stringify({ type: "response.done", response: { id: "resp_test" } })}`,
+ "",
+ ].join("\n"));
+
+ expect(output).toContain("event: response.done");
+ expect(output).not.toContain("event: response.failed");
+ expect(output).not.toContain("data: null");
+ expect(output).toContain("data: [DONE]");
+ });
+
it("emits response.failed before DONE when a Responses stream sends DONE without a terminal event", async () => {
const output = await runTransform([
`event: response.created`,
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" }],
+ });
+ });
+});