Add proactive token refresh lead times for providers and implement Codex proxy management

This commit is contained in:
decolua
2026-04-14 11:41:06 +07:00
parent 4bff21cb80
commit 04cdb75839
14 changed files with 232 additions and 27 deletions

View File

@@ -146,6 +146,16 @@ export const LOAD_CODE_ASSIST_METADATA = {
export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**";
// Proactive token refresh lead times per provider (ms)
export const REFRESH_LEAD_MS = {
codex: 5 * 24 * 60 * 60 * 1000, // 5 days
claude: 4 * 60 * 60 * 1000, // 4 hours
iflow: 24 * 60 * 60 * 1000, // 24 hours
qwen: 20 * 60 * 1000, // 20 minutes
"kimi-coding": 5 * 60 * 1000, // 5 minutes
antigravity: 5 * 60 * 1000, // 5 minutes
};
// OAuth endpoints
export const OAUTH_ENDPOINTS = {
google: {

View File

@@ -1,5 +1,4 @@
// Default instructions for Codex models
// Source: CLIProxyAPI internal/misc/codex_instructions/
export const CODEX_DEFAULT_INSTRUCTIONS = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.

View File

@@ -150,7 +150,7 @@ export class CodexExecutor extends BaseExecutor {
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const effort = body.reasoning_effort || modelEffort || 'medium';
const effort = body.reasoning_effort || modelEffort || 'low';
body.reasoning = { effort, summary: "auto" };
} else if (!body.reasoning.summary) {
body.reasoning.summary = "auto";

View File

@@ -1,7 +1,7 @@
import { platform, arch } from "os";
import { DefaultExecutor } from "./default.js";
/** portal.qwen.ai — aligned with CLIProxyAPI qwen_executor */
/** portal.qwen.ai */
const qwenCodeVersion = "0.13.2";
const qwenStainless = {
runtimeVersion: "v22.17.0",

View File

@@ -1,7 +1,6 @@
/**
* Project ID Service - Fetch and cache real Project IDs from Google Cloud Code API
*
* Reference: CLIProxyAPI internal/auth/antigravity/auth.go (FetchProjectID + OnboardUser)
*
* Instead of generating random project IDs (e.g. "useful-spark-a1b2c"),
* this service fetches the real Project ID bound to the authenticated user's account.

View File

@@ -1,9 +1,14 @@
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js";
// Token expiry buffer (refresh if expires within 5 minutes)
// Default token expiry buffer (refresh if expires within 5 minutes)
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
// Get provider-specific refresh lead time, falls back to default buffer
export function getRefreshLeadMs(provider) {
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
}
/**
* Refresh OAuth access token using refresh token
*/

View File

@@ -1,7 +1,6 @@
// Gemini helper functions for translator
// Unsupported JSON Schema constraints that should be removed for Antigravity
// Reference: CLIProxyAPI/internal/util/gemini_schema.go (removeUnsupportedKeywords)
export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [
// Basic constraints (not supported by Gemini API)
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
@@ -270,7 +269,6 @@ function flattenTypeArrays(obj) {
}
// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively
// Reference: CLIProxyAPI/internal/util/gemini_schema.go
export function cleanJSONSchemaForAntigravity(schema) {
if (!schema || typeof schema !== "object") return schema;

View File

@@ -865,7 +865,7 @@ export default function ProviderDetailPage() {
<h2 className="text-lg font-semibold">Connections</h2>
<div className="flex items-center gap-4">
{/* Thinking config */}
{thinkingConfig && (
{/* {thinkingConfig && (
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted font-medium">Thinking</span>
<select
@@ -878,7 +878,7 @@ export default function ProviderDetailPage() {
))}
</select>
</div>
)}
)} */}
{/* Round Robin toggle */}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted font-medium">Round Robin</span>

View File

@@ -7,6 +7,7 @@ import {
pollForToken
} from "@/lib/oauth/providers";
import { createProviderConnection } from "@/models";
import { startCodexProxy, stopCodexProxy } from "@/lib/oauth/utils/server";
/**
* Dynamic OAuth API Route
@@ -30,6 +31,26 @@ export async function GET(request, { params }) {
return NextResponse.json(authData);
}
if (action === "start-proxy") {
if (provider !== "codex") {
return NextResponse.json({ error: "Proxy only supported for codex" }, { status: 400 });
}
const appPort = searchParams.get("app_port");
if (!appPort) {
return NextResponse.json({ error: "Missing app_port" }, { status: 400 });
}
const result = await startCodexProxy(Number(appPort));
return NextResponse.json(result);
}
if (action === "stop-proxy") {
if (provider !== "codex") {
return NextResponse.json({ error: "Proxy only supported for codex" }, { status: 400 });
}
stopCodexProxy();
return NextResponse.json({ success: true });
}
if (action === "device-code") {
const providerData = getProvider(provider);
if (providerData.flowType !== "device_code") {

View File

@@ -114,3 +114,71 @@ export function waitForCallback(timeoutMs = 300000) {
});
}
// Singleton proxy server for Codex OAuth callback on fixed port
let codexProxyServer = null;
let codexProxyTimeout = null;
const CODEX_PROXY_TIMEOUT_MS = 300000; // 5 minutes
/**
* Start a proxy server on Codex fixed port (1455) that redirects callback to the app port.
* Returns { success: true } if started, or { success: false } if port is busy.
*/
export function startCodexProxy(appPort) {
return new Promise((resolve) => {
// Already running
if (codexProxyServer) {
resolve({ success: true });
return;
}
const CODEX_PORT = 1455;
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname === "/callback" || url.pathname === "/auth/callback") {
// Redirect to app port with all query params preserved
const redirectUrl = `http://localhost:${appPort}/callback${url.search}`;
res.writeHead(302, { Location: redirectUrl });
res.end();
// Auto-close after redirect
stopCodexProxy();
return;
}
res.writeHead(404);
res.end("Not found");
});
server.listen(CODEX_PORT, "127.0.0.1", () => {
codexProxyServer = server;
// Auto-cleanup after timeout
codexProxyTimeout = setTimeout(() => stopCodexProxy(), CODEX_PROXY_TIMEOUT_MS);
resolve({ success: true });
});
server.on("error", (err) => {
if (err.code === "EADDRINUSE") {
resolve({ success: false, reason: "port_busy" });
} else {
resolve({ success: false, reason: err.message });
}
});
});
}
/**
* Stop the Codex proxy server and cleanup
*/
export function stopCodexProxy() {
if (codexProxyTimeout) {
clearTimeout(codexProxyTimeout);
codexProxyTimeout = null;
}
if (codexProxyServer) {
codexProxyServer.close();
codexProxyServer = null;
}
}

View File

@@ -40,8 +40,8 @@ const MITM_PORT = 443;
const MITM_WIN_NODE_PORT = 8443;
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
const MITM_MAX_RESTARTS = 2;
const MITM_RESTART_DELAYS_MS = [5000, 10000];
const MITM_MAX_RESTARTS = 5;
const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000];
const MITM_RESTART_RESET_MS = 60000;
let mitmRestartCount = 0;
@@ -323,8 +323,7 @@ async function scheduleMitmRestart(apiKey) {
if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0;
if (mitmRestartCount >= MITM_MAX_RESTARTS) {
err("Max restart attempts reached. Auto-disabling MITM.");
await saveMitmSettings(false, null);
err("Max restart attempts reached. Giving up.");
return;
}

View File

@@ -155,14 +155,23 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
// Authorization code flow - build redirect URI (some providers require fixed ports)
const appPort = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
let redirectUri;
let codexProxyActive = false;
if (provider === "codex") {
// Codex requires fixed port 1455
// Try to start proxy on fixed port 1455 → redirect callback to app port
try {
const proxyRes = await fetch(`/api/oauth/codex/start-proxy?app_port=${appPort}`);
const proxyData = await proxyRes.json();
codexProxyActive = proxyData.success;
} catch {
codexProxyActive = false;
}
// Always use fixed port 1455 as redirect_uri (Codex requirement)
redirectUri = "http://localhost:1455/auth/callback";
} else {
// Use app's current port for OAuth callback
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
redirectUri = `http://localhost:${port}/callback`;
redirectUri = `http://localhost:${appPort}/callback`;
}
// Build authorize URL, optionally passing provider-specific metadata (e.g. gitlab clientId)
@@ -177,16 +186,21 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setAuthData({ ...data, redirectUri });
// For Codex or non-localhost: use manual input mode
if (provider === "codex" || !isLocalhost) {
if (provider === "codex" && codexProxyActive) {
// Proxy active: callback will redirect to app port automatically
setStep("waiting");
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
if (!popupRef.current) {
setStep("input");
}
} else if (!isLocalhost || provider === "codex") {
// Non-localhost or proxy failed: manual input mode
setStep("input");
window.open(data.authUrl, "_blank");
} else {
// Localhost (non-Codex): Open popup and wait for message
setStep("waiting");
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
// Check if popup was blocked
if (!popupRef.current) {
setStep("input");
}
@@ -209,8 +223,11 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
pollingAbortRef.current = false;
startOAuthFlow();
} else if (!isOpen) {
// Abort polling when modal closes
// Abort polling and cleanup proxy when modal closes
pollingAbortRef.current = true;
if (provider === "codex") {
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
}
}
}, [isOpen, provider, startOAuthFlow]);
@@ -319,10 +336,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
};
// Clear session on modal close
// Clear session on modal close + cleanup proxy
const handleClose = useCallback(() => {
if (provider === "codex") {
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
}
onClose();
}, [onClose]);
}, [onClose, provider]);
if (!provider || !providerInfo) return null;

View File

@@ -20,7 +20,8 @@ import {
refreshTokenByProvider as _refreshTokenByProvider,
formatProviderCredentials as _formatProviderCredentials,
getAllAccessTokens as _getAllAccessTokens,
refreshKiroToken as _refreshKiroToken
refreshKiroToken as _refreshKiroToken,
getRefreshLeadMs as _getRefreshLeadMs
} from "open-sse/services/tokenRefresh.js";
export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS;
@@ -196,10 +197,12 @@ export async function checkAndRefreshToken(provider, credentials) {
const now = Date.now();
const remaining = expiresAt - now;
if (remaining < TOKEN_EXPIRY_BUFFER_MS) {
const refreshLead = _getRefreshLeadMs(provider);
if (remaining < refreshLead) {
log.info("TOKEN_REFRESH", "Token expiring soon, refreshing proactively", {
provider,
expiresIn: Math.round(remaining / 1000),
refreshLeadMs: refreshLead,
});
const newCreds = await getAccessToken(provider, creds);

View File

@@ -0,0 +1,83 @@
/**
* Unit tests for Codex (OpenAI) refresh token mechanism
*
* Verifies that:
* - Early refresh lead times are configured per provider (synced with CLIProxyAPI)
* - New refresh_token from response is persisted (token rotation)
* - Falls back to old refresh_token when server doesn't return new one
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const originalFetch = global.fetch;
describe("Codex Refresh Token", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
global.fetch = originalFetch;
});
describe("refreshCodexToken", () => {
it("should return new refresh_token when server provides one (token rotation)", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
access_token: "new-access",
refresh_token: "rotated-refresh-token",
expires_in: 3600,
}),
});
const { refreshCodexToken } = await import("../../open-sse/services/tokenRefresh.js");
const result = await refreshCodexToken("old-refresh-token", null);
expect(result.refreshToken).toBe("rotated-refresh-token");
expect(result.accessToken).toBe("new-access");
});
it("should keep old refresh_token when server does not return new one", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({
access_token: "new-access",
expires_in: 3600,
}),
});
const { refreshCodexToken } = await import("../../open-sse/services/tokenRefresh.js");
const result = await refreshCodexToken("old-refresh-token", null);
expect(result.refreshToken).toBe("old-refresh-token");
});
});
describe("getRefreshLeadMs (early refresh config)", () => {
it("should return provider-specific lead time for OAuth providers", async () => {
const { getRefreshLeadMs } = await import("../../open-sse/services/tokenRefresh.js");
// Synced with CLIProxyAPI refresh_registry
expect(getRefreshLeadMs("codex")).toBe(5 * 24 * 60 * 60 * 1000); // 5 days
expect(getRefreshLeadMs("claude")).toBe(4 * 60 * 60 * 1000); // 4 hours
expect(getRefreshLeadMs("iflow")).toBe(24 * 60 * 60 * 1000); // 24 hours
expect(getRefreshLeadMs("qwen")).toBe(20 * 60 * 1000); // 20 minutes
expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // 5 minutes
expect(getRefreshLeadMs("antigravity")).toBe(5 * 60 * 1000); // 5 minutes
});
it("should fallback to default buffer for unknown providers", async () => {
const { getRefreshLeadMs, TOKEN_EXPIRY_BUFFER_MS } = await import("../../open-sse/services/tokenRefresh.js");
expect(getRefreshLeadMs("unknown-provider")).toBe(TOKEN_EXPIRY_BUFFER_MS);
expect(getRefreshLeadMs("openai")).toBe(TOKEN_EXPIRY_BUFFER_MS);
});
it("codex lead should be greater than default buffer", async () => {
const { getRefreshLeadMs, TOKEN_EXPIRY_BUFFER_MS } = await import("../../open-sse/services/tokenRefresh.js");
expect(getRefreshLeadMs("codex")).toBeGreaterThan(TOKEN_EXPIRY_BUFFER_MS);
});
});
});