From 613a0a819ac33524975032298437512e0baf6824 Mon Sep 17 00:00:00 2001
From: decolua
Date: Mon, 18 May 2026 16:26:35 +0700
Subject: [PATCH] # v0.4.55 (2026-05-18)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Features
- Xiaomi MiMo Token Plan: region selector (Singapore / China / Europe) — keys are cluster-specific
- Antigravity: risk confirmation dialog before first connection
- Gemini CLI: surface upstream retry delay on 429 errors
## Fixes
- MITM: cannot kill process on macOS under sudo (lsof not found in PATH)
- Stream: false-positive stall timeout on Claude reasoning / Kiro responses
- Tunnel: cannot re-enable after disable (stuck state)
- Tunnel: cloudflared error messages now include log tail for easier debugging
- Language switcher: applies selected locale immediately on close (#1234)
- Antigravity OAuth: metadata now matches the official client
## Improvements
- Gemini CLI: bump engine to 0.34.0
- Re-hide `qwen` (OAuth EOL) and `iflow` (not ready) providers
---
cli/cli.js | 24 +++----
cli/package.json | 2 +-
open-sse/config/providers.js | 14 +++++
open-sse/executors/default.js | 5 +-
package.json | 2 +-
.../providers/[id]/AddApiKeyModal.js | 15 +++++
.../dashboard/providers/[id]/page.js | 62 ++++++++++++++-----
src/app/api/providers/validate/route.js | 4 +-
src/dashboardGuard.js | 5 +-
src/lib/tunnel/cloudflared.js | 14 +++--
src/lib/tunnel/tunnelManager.js | 21 +++++--
src/shared/constants/providers.js | 4 +-
12 files changed, 125 insertions(+), 47 deletions(-)
diff --git a/cli/cli.js b/cli/cli.js
index 06cd979..6e4bfe6 100755
--- a/cli/cli.js
+++ b/cli/cli.js
@@ -197,8 +197,8 @@ function killCloudflaredByAppPort(appPort) {
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
- // Kill MITM first (admin/sudo process, needs special handling)
- killMitmByPidFile();
+ // Kill MIT first (privileged process, needs special handling)
+ killProxyByPidFile();
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
killTunnelByPidFile();
@@ -305,13 +305,13 @@ function waitForExit(pid, timeoutMs) {
return false;
}
-// Kill MITM server by PID file (MITM runs as admin/sudo, needs special handling)
-// Sends SIGTERM first so MITM can clean up /etc/hosts entries before dying.
-function killMitmByPidFile() {
+// Kill MIT server by PID file (runs privileged, needs special handling)
+// Sends SIGTERM first so MIT can clean up host entries before dying.
+function killProxyByPidFile() {
try {
- const mitmPidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
- if (!fs.existsSync(mitmPidFile)) return;
- const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10);
+ const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
+ if (!fs.existsSync(pidFile)) return;
+ const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
if (!pid) return;
if (process.platform === "win32") {
@@ -333,7 +333,7 @@ function killMitmByPidFile() {
catch { try { process.kill(pid, "SIGKILL"); } catch { } }
}
}
- try { fs.unlinkSync(mitmPidFile); } catch { }
+ try { fs.unlinkSync(pidFile); } catch { }
} catch { }
}
@@ -584,8 +584,8 @@ function startServer(latestVersion) {
const { killTray } = require("./src/cli/tray/tray");
killTray();
} catch (e) { }
- // Kill MITM server (admin/sudo process) via PID file
- killMitmByPidFile();
+ // Kill MIT server (privileged process) via PID file
+ killProxyByPidFile();
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
killTunnelByPidFile();
// Kill server process directly
@@ -772,7 +772,7 @@ function startServer(latestVersion) {
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
if (restartCount >= MAX_RESTARTS) {
- console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MITM and restarting...`);
+ console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
try {
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
if (fs.existsSync(dbPath)) {
diff --git a/cli/package.json b/cli/package.json
index 6c907c1..097f409 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "9router",
- "version": "0.4.52",
+ "version": "0.4.55",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js
index a8b0dd8..5f9d6a6 100644
--- a/open-sse/config/providers.js
+++ b/open-sse/config/providers.js
@@ -395,6 +395,8 @@ export const PROVIDERS = {
baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions",
format: "openai"
},
+ // Region map for Xiaomi MiMo Token Plan (keys are cluster-specific)
+ // Used by resolveXiaomiTokenplanBaseUrl below
// === Free-tier providers (synced from OmniRoute) ===
// Claude-format with Claude CLI header spoofing (auth: x-api-key)
agentrouter: { baseUrl: "https://agentrouter.org/v1/messages", format: "claude", headers: { ...CLAUDE_CLI_SPOOF_HEADERS } },
@@ -437,3 +439,15 @@ export function resolveOllamaLocalHost(credentials) {
const raw = credentials?.providerSpecificData?.baseUrl?.trim();
return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, "");
}
+
+export const XIAOMI_TOKENPLAN_REGIONS = {
+ sgp: "https://token-plan-sgp.xiaomimimo.com/v1",
+ cn: "https://token-plan-cn.xiaomimimo.com/v1",
+ ams: "https://token-plan-ams.xiaomimimo.com/v1"
+};
+export const XIAOMI_TOKENPLAN_DEFAULT_REGION = "sgp";
+
+export function resolveXiaomiTokenplanBaseUrl(credentials) {
+ const region = credentials?.providerSpecificData?.region;
+ return XIAOMI_TOKENPLAN_REGIONS[region] || XIAOMI_TOKENPLAN_REGIONS[XIAOMI_TOKENPLAN_DEFAULT_REGION];
+}
diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js
index 47bc9b8..bca9221 100644
--- a/open-sse/executors/default.js
+++ b/open-sse/executors/default.js
@@ -1,5 +1,5 @@
import { BaseExecutor } from "./base.js";
-import { PROVIDERS } from "../config/providers.js";
+import { PROVIDERS, resolveXiaomiTokenplanBaseUrl } from "../config/providers.js";
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js";
import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js";
@@ -39,6 +39,9 @@ export class DefaultExecutor extends BaseExecutor {
case "gemini":
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
default: {
+ if (this.provider === "xiaomi-tokenplan") {
+ return `${resolveXiaomiTokenplanBaseUrl(credentials)}/chat/completions`;
+ }
const url = this.config.baseUrl;
if (url?.includes("{accountId}")) {
const accountId = credentials?.providerSpecificData?.accountId;
diff --git a/package.json b/package.json
index ecf204f..f063e70 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "9router-app",
- "version": "0.4.52",
+ "version": "0.4.55",
"description": "9Router web dashboard",
"private": true,
"scripts": {
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
index 3145c8e..1a4c8b7 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
@@ -3,6 +3,7 @@
import { useState } from "react";
import PropTypes from "prop-types";
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
+import { AI_PROVIDERS } from "@/shared/constants/providers";
const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;
@@ -17,6 +18,8 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const isAzure = provider === "azure";
const isCloudflareAi = provider === "cloudflare-ai";
+ const providerRegions = AI_PROVIDERS?.[provider]?.regions || null;
+ const defaultRegion = AI_PROVIDERS?.[provider]?.defaultRegion || providerRegions?.[0]?.id || "";
const [formData, setFormData] = useState({
name: "",
@@ -33,6 +36,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
organization: "",
});
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
+ const [region, setRegion] = useState(defaultRegion);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
@@ -55,6 +59,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
if (isCloudflareAi) {
return { accountId: cloudflareData.accountId };
}
+ if (providerRegions && region) {
+ return { region };
+ }
return undefined;
};
@@ -234,6 +241,14 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
)}
)}
+ {providerRegions && (
+