mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
Add proactive token refresh lead times for providers and implement Codex proxy management
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
83
tests/unit/codex-refresh-token.test.js
Normal file
83
tests/unit/codex-refresh-token.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user