diff --git a/README.md b/README.md index 31ca660..b43e90a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
9Router Dashboard - # 9Router - FREE AI Coding + Cheap Backups + # 9Router - FREE AI Coding **Use Claude, Codex, Gemini for FREE β€’ Ultra-cheap alternatives from $0.20/1M tokens** @@ -32,6 +32,26 @@ Stop wasting your AI subscriptions and paying full price: --- +## πŸ”§ How It Works + +``` +1. Install & Start + npm install -g 9router β†’ Dashboard opens + +2. Connect Providers + Dashboard β†’ OAuth login (Claude, Gemini...) + OR β†’ For free providers (iFlow, Qwen, Kiro...) + OR β†’ Add API keys (GLM, iFlow...) + +3. Point Your CLI (Cursor/Cline/Any tool..) + Cursor/Cline β†’ http://localhost:20128/v1 + + Your Tool β†’ 9Router β†’ Providers + (Auto route + fallback) +``` + +--- + ## ⚑ Quick Start **Install globally:** @@ -41,9 +61,9 @@ npm install -g 9router 9router ``` -πŸŽ‰ Dashboard opens β†’ Connect Claude Code β†’ Start coding! +πŸŽ‰ Dashboard opens β†’ Connect Free Providersβ€―β€―β†’ Start coding! -**Use in Cursor/Cline:** +**Use in Claude Code/Codex/Cursor/Cline/.....:** ``` Endpoint: http://localhost:20128/v1 @@ -83,7 +103,6 @@ When subscription quota runs out, pay pennies: ### πŸ†“ FREE FOREVER (Fallback) -No API key needed, unlimited: | Provider | Top Models | Notes | |----------|-----------|-------| @@ -128,6 +147,19 @@ Tier 3 (FREE): iFlow β†’ Qwen β†’ Kiro --- +## ☁️ Cloud Deployment + +### Why Cloud? + +Use `https://9router.com` when localhost doesn't work: + +- βœ… **Cursor IDE** - doesn't support localhost +- βœ… **Mobile coding** - iPad, phone access +- βœ… **No install needed** - use from anywhere +- βœ… **Global fast** - Cloudflare edge network (300+ locations) + +--- + ## πŸ“– Setup Guide
@@ -472,17 +504,6 @@ Daily routine: β†’ Code 24/7 with minimal extra cost! ``` -### Model Selection Guide - -| Task | Best Model | Cost | Why | -|------|-----------|------|-----| -| Complex reasoning | `cc/claude-opus-4-5` | Subscription | Best quality | -| Fast coding | `cx/gpt-5.2-codex` | Subscription | Fastest Codex | -| Budget coding | `glm/glm-4.7` | $0.6/1M | Daily quota | -| Long context | `minimax/MiniMax-M2.1` | $0.20/1M | 1M context cheap | -| Emergency backup | `if/kimi-k2-thinking` | FREE | Unlimited | - ---- ## πŸ“Š Available Models diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js index 4634c6f..7de6f05 100644 --- a/open-sse/config/constants.js +++ b/open-sse/config/constants.js @@ -39,9 +39,9 @@ export const PROVIDERS = { baseUrl: "https://chatgpt.com/backend-api/codex/responses", format: "openai-responses", // Use OpenAI Responses API format (reuse translator) headers: { - "Version": "0.21.0", + "Version": "0.92.0", "Openai-Beta": "responses=experimental", - "User-Agent": "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)" + "User-Agent": "codex-cli/0.92.0 (Windows 10.0.26100; x64)" }, // OpenAI OAuth configuration clientId: "app_EMoamEEZ73f0CkXaXp7hrann", diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index ec94c4f..9b98fa4 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -32,6 +32,11 @@ export class CodexExecutor extends BaseExecutor { delete body.top_logprobs; delete body.n; delete body.seed; + delete body.max_tokens; + delete body.user; // Cursor sends this but Codex doesn't support it + delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it + delete body.metadata; // Cursor sends this but Codex doesn't support it + delete body.stream_options; // Cursor sends this but Codex doesn't support it return body; } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 6057317..abcdfd3 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -253,11 +253,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, model); const targetFormat = modelTargetFormat || getTargetFormat(provider); - const stream = body.stream !== false; + + // Force streaming for OpenAI/Codex models (they don't support non-streaming mode properly) + const stream = (provider === 'openai' || provider === 'codex') ? true : (body.stream !== false); // Create request logger for this session: sourceFormat_targetFormat_model const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model); - + // 0. Log client raw request (before any conversion) if (clientRawRequest) { reqLogger.logClientRawRequest( @@ -266,7 +268,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred clientRawRequest.headers ); } - + // 1. Log raw request from client reqLogger.logRawRequest(body); @@ -275,7 +277,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Translate request (pass reqLogger for intermediate logging) let translatedBody = body; translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger); - + // Extract toolNameMap for response translation (Claude OAuth) const toolNameMap = translatedBody._toolNameMap; delete translatedBody._toolNameMap; @@ -290,11 +292,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred trackPendingRequest(model, provider, connectionId, true); // Log start - appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {}); + appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); - const msgCount = translatedBody.messages?.length - || translatedBody.contents?.length - || translatedBody.request?.contents?.length + const msgCount = translatedBody.messages?.length + || translatedBody.contents?.length + || translatedBody.request?.contents?.length || 0; log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); @@ -316,18 +318,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred signal: streamController.signal, log }); - + providerResponse = result.response; providerUrl = result.url; providerHeaders = result.headers; finalBody = result.transformedBody; - + // Log target request (final request to provider) reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); - + } catch (error) { trackPendingRequest(model, provider, connectionId, false); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : 502}` }).catch(() => {}); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : 502}` }).catch(() => { }); if (error.name === "AbortError") { streamController.handleError(error); return createErrorResult(499, "Request aborted"); @@ -347,7 +349,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (newCredentials?.accessToken || newCredentials?.copilotToken) { log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); - + // Update credentials Object.assign(credentials, newCredentials); @@ -383,16 +385,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false); const { statusCode, message, retryAfterMs } = await parseUpstreamError(providerResponse, provider); - appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => {}); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); const errMsg = formatProviderError(new Error(message), provider, model, statusCode); console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); - + // Log Antigravity retry time if available if (retryAfterMs && provider === "antigravity") { const retrySeconds = Math.ceil(retryAfterMs / 1000); log?.debug?.("RETRY", `Antigravity quota reset in ${retrySeconds}s (${retryAfterMs}ms)`); } - + // Log error with full request body for debugging reqLogger.logError(new Error(message), finalBody || translatedBody); @@ -411,7 +413,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Log usage for non-streaming responses const usage = extractUsageFromResponse(responseBody, provider); - appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => {}); + appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => { }); if (usage) { const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] πŸ“Š [USAGE] ${provider.toUpperCase()} | in=${usage.prompt_tokens || 0} | out=${usage.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`; console.log(`${COLORS.green}${msg}${COLORS.reset}`); @@ -444,7 +446,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } // Streaming response - + // Notify success - caller can clear error status if needed if (onRequestSuccess) { await onRequestSuccess(); @@ -459,8 +461,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Create transform stream with logger for streaming response let transformStream; - if (needsTranslation(targetFormat, sourceFormat)) { - transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId); + // For Codex provider, always translate response from openai-responses to openai format + // This ensures clients like Cursor get the expected chat completions format + const needsCodexTranslation = (provider === 'codex' || provider === 'openai') && targetFormat === 'openai-responses'; + if (needsCodexTranslation || needsTranslation(targetFormat, sourceFormat)) { + // For Codex, translate FROM openai-responses TO openai (client's expected format) + const responseSourceFormat = needsCodexTranslation ? 'openai-responses' : targetFormat; + const responseTargetFormat = needsCodexTranslation ? 'openai' : sourceFormat; + transformStream = createSSETransformStreamWithLogger(responseSourceFormat, responseTargetFormat, provider, reqLogger, toolNameMap, model, connectionId); } else { transformStream = createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId); } diff --git a/package.json b/package.json index 9d29a13..7e023da 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "9Router web dashboard", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --webpack", + "build": "next build --webpack", "start": "next start" }, "dependencies": { @@ -18,21 +18,21 @@ "jose": "^6.1.3", "lowdb": "^7.0.1", "monaco-editor": "^0.55.1", - "next": "^15.2.0", + "next": "^16.1.6", "node-machine-id": "^1.1.12", - "open": "^10.1.0", - "ora": "^5.4.1", - "react": "19.2.1", - "react-dom": "19.2.1", - "socks-proxy-agent": "^8.0.4", - "undici": "^7.16.0", + "open": "^11.0.0", + "ora": "^9.1.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "socks-proxy-agent": "^8.0.5", + "undici": "^7.19.2", "uuid": "^13.0.0", - "zustand": "^5.0.9" + "zustand": "^5.0.10" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "eslint": "^9", - "eslint-config-next": "16.0.10", + "eslint-config-next": "16.1.6", "tailwindcss": "^4" } -} +} \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 1fead1e..2df7d21 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -19,7 +19,7 @@ export default function APIPageClient({ machineId }) { const [showCloudModal, setShowCloudModal] = useState(false); const [showDisableModal, setShowDisableModal] = useState(false); const [cloudSyncing, setCloudSyncing] = useState(false); - const [setCloudStatus] = useState(null); + const [cloudStatus, setCloudStatus] = useState(null); const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | "" const { copied, copy } = useCopyToClipboard(); diff --git a/src/middleware.js b/src/proxy.js similarity index 95% rename from src/middleware.js rename to src/proxy.js index 5b12c0b..4b97c90 100644 --- a/src/middleware.js +++ b/src/proxy.js @@ -5,7 +5,7 @@ const SECRET = new TextEncoder().encode( process.env.JWT_SECRET || "9router-default-secret-change-me" ); -export async function middleware(request) { +export async function proxy(request) { const { pathname } = request.nextUrl; // Protect all dashboard routes