Compare commits
10
Commits
5798b30841
...
39e36d3d0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e36d3d0c | ||
|
|
910db749aa | ||
|
|
6af26a9ee8 | ||
|
|
cbffeb9770 | ||
|
|
21583c03e5 | ||
|
|
0f488c7027 | ||
|
|
1a02713150 | ||
|
|
b53260ca54 | ||
|
|
d1de324586 | ||
|
|
ce9ac43da5 |
No files matched your search
+26
-4
@@ -1,11 +1,33 @@
|
||||
# v0.5.82 (unreleased)
|
||||
# v0.5.86 (2026-09-23)
|
||||
|
||||
## Features
|
||||
- **Qoder CN**: add the `qoder-cn` provider for qoder.com.cn — same device/OAuth flow and COSY-signed chat protocol as intl Qoder, served from the CN gateway (`gateway.qoder.com.cn` / `openapi.qoder.com.cn`). Region resolution is derived from the provider id (`qoder-cn` → CN) across executor, model catalog, quota usage, validation, and the dashboard.
|
||||
- **Xiaomi MiMo**: server-assisted desktop login for headless/Docker deployments, five account clusters (cn/sgp/ams/ru/in), and v2.6 pro/flash/pro-ultraspeed models with dual-route (account service vs. cloud API)
|
||||
- **Claude**: add Claude Opus 5.5 support
|
||||
- **i18n**: translate React text rewrites via characterData mutation observer
|
||||
|
||||
## Fixes
|
||||
- **Qoder**: prevent proxy failures from silently replaying signed inference requests over a direct connection with the same COSY request ID (`403/103 Duplicate request`).
|
||||
- **Qoder**: return first-frame upstream errors, including `403/103 Duplicate request`, as HTTP failures instead of assistant text; handle fragmented frames and heartbeat prefixes while preserving billing error mapping.
|
||||
- **Proxy Pools**: keep request headers intact through Vercel/Cloudflare/Deno relays (spreading a `Headers` instance yielded `{}`, dropping auth and content-type)
|
||||
- **Xiaomi MiMo login**: keep the session in the httpOnly cookie only, require dashboard auth on the proxy branch, and stop forwarding authorization headers upstream
|
||||
|
||||
# v0.5.85 (2026-09-22)
|
||||
|
||||
## Features
|
||||
- **System One**: add `/v1/systemone` decision endpoint for Jev models (OpenCode Zen and OpenRouter lanes), wire into sidebar and Media Providers page with interactive probe testing
|
||||
- **CLI Tools**: add dynamic configuration, settings APIs, and official logos for Pi, OMP, Crush, ForgeCode, Smelt, and CodeWhale
|
||||
- **Analytics & Usage**: add Requests mode, provider/model breakdown charts, All Time period filter, and refined overview cards
|
||||
- **Combos**: add Cursor/Claude Default presets; support bulk select/delete and bulk strategy changes (Fallback / Round Robin / Fusion)
|
||||
- **Model Capabilities**: expose model capability metadata on `/v1/models` and aggregate capabilities across combo targets
|
||||
- **OpenCode Zen & MiMo**: add OpenCode Zen (`opencode-zen`) provider with free-tier fingerprint; switch default vision fallback to MiMo V2.6 Flash Free
|
||||
- **Qoder CN**: add `qoder-cn` provider for qoder.com.cn with OAuth flow, COSY protocol, and CN gateway routing
|
||||
|
||||
## Fixes
|
||||
- **Translator**: map Claude `refusal` stop_reason to `content_filter` and surface explanation; strip replayed reasoning fields for Groq, Mistral, and Cerebras (#4220)
|
||||
- **Antigravity**: drop requestType `agent` to avoid false 429 `RESOURCE_EXHAUSTED`; separate weekly and short-window (5-hour) quotas and deduplicate dashboard rows
|
||||
- **Responses API**: report usage on `response.completed` so clients can auto-compact (#3432)
|
||||
- **Hugging Face**: migrate to Inference Providers router (`router.huggingface.co`), expand image models catalog, and add STT route
|
||||
- **Qoder**: prevent signed request replay (`403/103 Duplicate request`), handle code 110 billing blocks, and preserve upstream SSE error status
|
||||
- **Performance**: bound usage `lastUsed` scan to a 2-day window; map large budget tokens to `max` reasoning tier
|
||||
- **Docker**: publish verified multi-platform images (linux/amd64 and linux/arm64) with configurable apk build mirrors
|
||||
|
||||
# v0.5.81 (2026-09-18)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router",
|
||||
"version": "0.5.81",
|
||||
"version": "0.5.86",
|
||||
"description": "9Router CLI - Start and manage 9Router server",
|
||||
"bin": {
|
||||
"9router": "./cli.js"
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { DefaultExecutor } from "./default.js";
|
||||
import { getMimoAccountCookie, invalidateMimoAccountCookieCache, MIMO_API_BASE, MIMO_API_UA } from "../shared/mimoAccount.js";
|
||||
import { getMimoAccountCookie, invalidateMimoAccountCookieCache, resolveMimoServerBase, MIMO_API_UA } from "../shared/mimoAccount.js";
|
||||
|
||||
// Desktop-exclusive Preview models. These are served by the account service's
|
||||
// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key).
|
||||
// See shared/mimoAccount.js for the session handshake.
|
||||
const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]);
|
||||
// Dual-route v2.6 models.
|
||||
// v2.6 models dynamically route to the account service when desktop session credentials
|
||||
// (mimoPassToken or account cookie) are present to consume weekly quota, falling back to
|
||||
// the cloud API (sk- key) otherwise.
|
||||
const ACCOUNT_MODELS = new Set([
|
||||
"mimo-v2.6-pro",
|
||||
"mimo-v2.6-flash",
|
||||
"mimo-v2.6-pro-ultraspeed",
|
||||
]);
|
||||
|
||||
// Session cookie resolved in execute() (async) and read back by buildHeaders()
|
||||
// (sync — BaseExecutor.execute does not await it). Carried on the per-request
|
||||
@@ -23,15 +28,24 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
||||
super("xiaomi-mimo");
|
||||
}
|
||||
|
||||
static isPreviewModel(model) {
|
||||
return PREVIEW_MODELS.has(bareModel(model));
|
||||
static isAccountRoute(model, credentials) {
|
||||
const bare = bareModel(model);
|
||||
if (!ACCOUNT_MODELS.has(bare)) return false;
|
||||
return Boolean(
|
||||
credentials?.[COOKIE_KEY] ||
|
||||
credentials?.providerSpecificData?.mimoPassToken
|
||||
);
|
||||
}
|
||||
|
||||
isAccountRoute(model, credentials) {
|
||||
return XiaomiMimoExecutor.isAccountRoute(model, credentials);
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
// Preview models live on the account-service route, which is not one of the
|
||||
// Account route models live on the account-service route, which is not one of the
|
||||
// declared transports — resolve it before the default runtimeTransport path.
|
||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
||||
return `${MIMO_API_BASE}/api/route/chat/completions`;
|
||||
if (this.isAccountRoute(model, credentials)) {
|
||||
return `${resolveMimoServerBase(credentials?.providerSpecificData)}/api/route/chat/completions`;
|
||||
}
|
||||
// Cloud API models keep default handling, so a Claude-format client reaches
|
||||
// the /anthropic/v1/messages transport.
|
||||
@@ -39,8 +53,8 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true, url, model) {
|
||||
if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) {
|
||||
// Preview models authenticate with the account-session cookie, not the key.
|
||||
if (this.isAccountRoute(model, credentials) && credentials?.[COOKIE_KEY]) {
|
||||
// Account route models authenticate with the account-session cookie, not the key.
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
@@ -52,17 +66,22 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
// super runs stripUnsupportedParams, which flattens Preview content-part
|
||||
// super runs stripUnsupportedParams, which flattens content-part
|
||||
// arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js).
|
||||
const out = super.transformRequest(model, body, stream, credentials);
|
||||
|
||||
// Preview models: thinking/params get defaults only — never override what the
|
||||
// caller set explicitly. (body.model is already `xiaomi/<id>` via upstreamModelId.)
|
||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
||||
if (out.thinking == null) out.thinking = { type: "enabled" };
|
||||
// Account route models: bridge reasoning_effort to official output_config.effort
|
||||
// (matches MiMo Desktop app.asar behavior).
|
||||
if (this.isAccountRoute(model, credentials)) {
|
||||
const rawEffort = out.reasoning_effort || body?.reasoning_effort || body?.output_config?.effort;
|
||||
if (rawEffort) {
|
||||
delete out.reasoning_effort;
|
||||
const norm = String(rawEffort).toLowerCase() === "xhigh" ? "high" : String(rawEffort).toLowerCase();
|
||||
out.output_config = { ...(out.output_config || {}), effort: norm };
|
||||
}
|
||||
|
||||
if (out.temperature == null) out.temperature = 1.0;
|
||||
if (out.top_p == null) out.top_p = 0.95;
|
||||
if (!out.max_tokens) out.max_tokens = 4096;
|
||||
}
|
||||
|
||||
return out;
|
||||
@@ -70,13 +89,11 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
||||
|
||||
async execute(args) {
|
||||
const { model, credentials, proxyOptions = null } = args;
|
||||
if (!XiaomiMimoExecutor.isPreviewModel(model)) return super.execute(args);
|
||||
if (!this.isAccountRoute(model, credentials)) return super.execute(args);
|
||||
|
||||
const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions);
|
||||
if (!cookie) {
|
||||
throw new Error(
|
||||
"Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.",
|
||||
);
|
||||
return super.execute(args);
|
||||
}
|
||||
credentials[COOKIE_KEY] = cookie;
|
||||
const result = await super.execute(args);
|
||||
@@ -94,6 +111,6 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
export const __test__ = { PREVIEW_MODELS, bareModel, COOKIE_KEY };
|
||||
export const __test__ = { ACCOUNT_MODELS, bareModel, COOKIE_KEY };
|
||||
|
||||
export default XiaomiMimoExecutor;
|
||||
@@ -57,6 +57,7 @@ export default {
|
||||
},
|
||||
},
|
||||
models: [
|
||||
{ id: "claude-opus-5-5", name: "Claude Opus 5.5" },
|
||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||
{ id: "claude-fable-5-1", name: "Claude Fable 5.1" },
|
||||
{ id: "claude-fable-5", name: "Claude Fable 5" },
|
||||
|
||||
@@ -2,9 +2,9 @@ import { CLAUDE_API_HEADERS } from "../shared.js";
|
||||
|
||||
// Dual auth (same pattern as kimi):
|
||||
// - API key (sk-...) → cloud API on api.xiaomimimo.com
|
||||
// - Desktop account/OAuth → same cloud host, plus the Desktop-exclusive Preview
|
||||
// models served by the account-service route on mimo-server-cn.xiaomimimo.com
|
||||
// (authorized by a Xiaomi account session cookie, not the key).
|
||||
// - Desktop account/OAuth → same cloud host, plus the dual-route v2.6 models
|
||||
// served by the account-service route (mimo-server-<cluster>.xiaomimimo.com),
|
||||
// authorized by a Xiaomi account session cookie, not the key.
|
||||
// Endpoint is picked per model in the executor, same as opencode-go's /responses split.
|
||||
export default {
|
||||
id: "xiaomi-mimo",
|
||||
@@ -30,6 +30,16 @@ export default {
|
||||
category: "oauth",
|
||||
authModes: ["oauth", "apikey"],
|
||||
hasOAuth: true,
|
||||
// Keys are cluster-specific. MiMo Desktop declares five regions
|
||||
// (CN/SGP/AMS/RU/IN) — host + sid follow mimo-server-<code> / mimo<code>.
|
||||
regions: [
|
||||
{ id: "cn", label: "China (中国大陆)" },
|
||||
{ id: "sgp", label: "Singapore (新加坡)" },
|
||||
{ id: "ams", label: "Europe · Amsterdam (欧洲)" },
|
||||
{ id: "ru", label: "Russia (俄罗斯)" },
|
||||
{ id: "in", label: "India (印度)" },
|
||||
],
|
||||
defaultRegion: "sgp",
|
||||
serviceKinds: ["llm", "tts"],
|
||||
transport: {
|
||||
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
||||
@@ -50,10 +60,10 @@ export default {
|
||||
},
|
||||
],
|
||||
models: [
|
||||
// Desktop-exclusive — served by the account-service route, which only accepts
|
||||
// OpenAI format, so supportedFormats pins them to the openai transport.
|
||||
{ id: "mimo-x-pro-preview", name: "MiMo-X-Pro-Preview", upstreamModelId: "xiaomi/mimo-x-pro-preview", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-x-flash-preview", name: "MiMo-X-Flash-Preview", upstreamModelId: "xiaomi/mimo-x-flash-preview", supportedFormats: ["openai"] },
|
||||
// Cloud API & Desktop dual-route models (prefers the desktop account quota when available)
|
||||
{ id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", upstreamModelId: "xiaomi/mimo-v2.6-pro", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", upstreamModelId: "xiaomi/mimo-v2.6-flash", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.6-pro-ultraspeed", name: "MiMo V2.6 Pro UltraSpeed", upstreamModelId: "xiaomi/mimo-v2.6-pro-ultraspeed", supportedFormats: ["openai"] },
|
||||
// Cloud API models (api.xiaomimimo.com/v1)
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
||||
|
||||
@@ -22,7 +22,7 @@ export function mapStainlessArch() {
|
||||
|
||||
// Anthropic API version (single source — reused across claude-format providers/executors)
|
||||
export const ANTHROPIC_API_VERSION = "2023-06-01";
|
||||
export const CLAUDE_CLI_VERSION = "2.1.258";
|
||||
export const CLAUDE_CLI_VERSION = "2.1.280";
|
||||
|
||||
// Shared Claude-compatible API headers (reused across claude-format providers)
|
||||
export const CLAUDE_API_HEADERS = {
|
||||
|
||||
@@ -41,6 +41,7 @@ const PATTERN_THINKING = [
|
||||
{ provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] },
|
||||
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
|
||||
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
|
||||
{ pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] },
|
||||
// DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max
|
||||
// all 200 via output_config.effort; "none" is a 400 on the anthropic route
|
||||
// (disable thinking instead). none kept for the picker = disable.
|
||||
|
||||
@@ -13,18 +13,19 @@ import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
||||
const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
|
||||
const HARD_CAPS = new Set(CAPABILITY_KEYS);
|
||||
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.6-flash-free";
|
||||
const upgradeLegacyModel = (m) => (m === "oc/mimo-v2.5-free" ? DEFAULT_FALLBACK_MODEL : m);
|
||||
|
||||
// Normalize a capability entry to { enabled, roundRobin, models }. Backward-compat:
|
||||
// accept the legacy array form [{model, enabled}] (treated as enabled, fallback).
|
||||
function normalizeCapEntry(entry) {
|
||||
if (Array.isArray(entry)) {
|
||||
return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) };
|
||||
return { enabled: true, roundRobin: false, models: entry.map((e) => upgradeLegacyModel(e?.model || e)).filter(Boolean) };
|
||||
}
|
||||
if (entry && typeof entry === "object") {
|
||||
return {
|
||||
enabled: entry.enabled !== false,
|
||||
roundRobin: !!entry.roundRobin,
|
||||
models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [],
|
||||
models: Array.isArray(entry.models) ? entry.models.map(upgradeLegacyModel).filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
return { enabled: false, roundRobin: false, models: [] };
|
||||
|
||||
+129
-62
@@ -8,20 +8,42 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
* Xiaomi MiMo account-session helpers (used for weekly quota).
|
||||
*
|
||||
* The weekly quota endpoint lives on the account service domain and is authorized
|
||||
* by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors
|
||||
* MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via
|
||||
* the passportapi SSO, then authorized for the `mimopc` service, and finally stamped
|
||||
* by the mimo-server /api/sts callback into a `serviceToken` cookie.
|
||||
* by an account session cookie, NOT the sk- API key. Acquiring that cookie is a
|
||||
* 1:1 port of MiMo Desktop's ServiceTokenManager (app.asar) — the GOLD STANDARD:
|
||||
*
|
||||
* Flow (verified against MiMo Desktop traffic):
|
||||
* 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc)
|
||||
* 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity
|
||||
* 3. GET {location}&clientSign=... -> account-level serviceToken
|
||||
* 4. GET account /pass/serviceLogin?sid=mimopc&callback=<sts>&_json=true
|
||||
* 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope)
|
||||
* getServiceToken(sid) / refreshServiceToken(sid):
|
||||
* PHASE 1: GET https://account.xiaomi.com/pass/serviceLogin
|
||||
* ?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
|
||||
* Cookie: {userId, passToken, cUserId}
|
||||
* -> {code, location, ssecurity, nonce, bSecondValidation, notificationUrl}
|
||||
* -> code !== 0 is an error (never silent)
|
||||
* PHASE 2: GET {location}&clientSign=sha1(nonce & ssecurity), follow the
|
||||
* redirect chain absorbing Set-Cookie -> serviceToken
|
||||
*
|
||||
* sid is per-cluster (SID_BY_REGION): CN = mimopc, SGP = mimosgp.
|
||||
*/
|
||||
|
||||
const API_BASE = "https://mimo-server-cn.xiaomimimo.com";
|
||||
// Account-service cluster hosts. MiMo Desktop declares five regions
|
||||
// (rn = {CN, SGP, RU, IN, EU}); the EU cluster is deployed in Amsterdam.
|
||||
// Host + sid naming is unified: mimo-server-<code> / sid = mimo<code>
|
||||
// (ams is the only non-country code). Verified live via /api/user/xiaomi/me.
|
||||
const API_BASE_BY_REGION = {
|
||||
cn: "https://mimo-server-cn.xiaomimimo.com",
|
||||
sgp: "https://mimo-server-sgp.xiaomimimo.com",
|
||||
ams: "https://mimo-server-ams.xiaomimimo.com",
|
||||
ru: "https://mimo-server-ru.xiaomimimo.com",
|
||||
in: "https://mimo-server-in.xiaomimimo.com",
|
||||
};
|
||||
const DEFAULT_API_BASE = API_BASE_BY_REGION.sgp;
|
||||
|
||||
// Cluster service sid — 1:1 with the host code: mimo<code>.
|
||||
// Unknown/absent region falls back to SGP (the international/open cluster).
|
||||
const SID_BY_REGION = { cn: "mimopc", sgp: "mimosgp", ams: "mimoams", ru: "mimoru", in: "mimoin" };
|
||||
function sidForRegion(region) {
|
||||
const r = String(region || "").toLowerCase();
|
||||
return SID_BY_REGION[r] || SID_BY_REGION.sgp;
|
||||
}
|
||||
const API_BASE = DEFAULT_API_BASE;
|
||||
const ACCOUNT_HOST = "account.xiaomi.com";
|
||||
const API_UA =
|
||||
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||
@@ -112,65 +134,106 @@ function cookieHeader(jar) {
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the account-service base URL for a connection.
|
||||
* @param {object|null} providerSpecificData - may carry `region` ("cn"|"sgp"|"ams"|"ru"|"in")
|
||||
*/
|
||||
export function resolveMimoServerBase(providerSpecificData = null) {
|
||||
const region = String(providerSpecificData?.region || "").toLowerCase();
|
||||
return API_BASE_BY_REGION[region] || DEFAULT_API_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a passToken for a mimo-server service session cookie.
|
||||
* Primary path mirrors the Desktop ServiceTokenManager (app.asar):
|
||||
* PHASE 1: GET /pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
|
||||
* Cookie {userId,passToken,cUserId} -> {code,location,ssecurity,nonce}
|
||||
* PHASE 2: GET {location}&clientSign=sha1(nonce&ssecurity), follow the chain
|
||||
* (manual, absorbing Set-Cookie) -> serviceToken
|
||||
* sid is per-cluster (SID_BY_REGION): cn=mimopc, sgp=mimosgp, ams=mimoams, ru=mimoru, in=mimoin.
|
||||
* @returns {Promise<string|null>} Cookie header value, or null on failure.
|
||||
*/
|
||||
async function acquireServiceCookie(passJar, proxyOptions) {
|
||||
async function acquireServiceCookie(passJar, proxyOptions, apiBase = DEFAULT_API_BASE, region = "sgp") {
|
||||
const r = String(region || "").toLowerCase();
|
||||
// Hard constraint: CN is ALWAYS direct (ignores proxy even if set)
|
||||
const effectiveProxy = r === "cn" ? null : proxyOptions;
|
||||
const sid = sidForRegion(r);
|
||||
const viaDesktop = await acquireViaDesktopPhases(passJar, effectiveProxy, apiBase, sid);
|
||||
if (viaDesktop) console.log(`[mimoAccount] desktop 2-phase OK (sid=${sid})`);
|
||||
return viaDesktop;
|
||||
}
|
||||
|
||||
async function acquireViaDesktopPhases(passJar, proxyOptions, apiBase, sid) {
|
||||
const failLog = (reason) => console.log(`[mimoAccount] desktopPhase fail: ${reason}`);
|
||||
const jar = { ...passJar };
|
||||
const ck = () => cookieHeader(jar);
|
||||
|
||||
// 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc)
|
||||
const r1 = await proxyAwareFetch(
|
||||
`${API_BASE}/api/user/xiaomi/me`,
|
||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
||||
// PHASE 1 — single serviceLogin call with the TARGET sid (no passportapi
|
||||
// prelude; ssecurity/nonce come straight from this response).
|
||||
// Desktop only sends: userId, passToken, cUserId (no extra cookies)
|
||||
const p1Jar = {};
|
||||
if (jar.userId) p1Jar.userId = jar.userId;
|
||||
if (jar.passToken) p1Jar.passToken = jar.passToken;
|
||||
if (jar.cUserId) p1Jar.cUserId = jar.cUserId;
|
||||
|
||||
const p1Url = `https://${ACCOUNT_HOST}/pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=${encodeURIComponent(sid)}&_json=true`;
|
||||
const p1 = await proxyAwareFetch(
|
||||
p1Url,
|
||||
{ headers: { Cookie: cookieHeader(p1Jar), "User-Agent": SSO_UA, Accept: "application/json" } },
|
||||
proxyOptions,
|
||||
);
|
||||
const redirect = r1.headers.get("location");
|
||||
if (!redirect) return null;
|
||||
const stsCallback = new URL(redirect).searchParams.get("callback");
|
||||
if (!stsCallback) return null;
|
||||
const raw = await p1.text();
|
||||
const clean = raw.replace(/^&&&START&&&/, "");
|
||||
// Nonce > 2^53 loses precision in JSON.parse — extract raw literal for signing
|
||||
const rawNonce = clean.match(/"nonce"\s*:\s*(\d+)/)?.[1];
|
||||
let j = null;
|
||||
try { j = JSON.parse(clean); } catch { /* handled below */ }
|
||||
if (rawNonce && j) j.nonce = rawNonce;
|
||||
|
||||
// 2. passportapi SSO phase 1 -> nonce + ssecurity
|
||||
const sso1 = await proxyAwareFetch(
|
||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`,
|
||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
||||
proxyOptions,
|
||||
);
|
||||
const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, ""));
|
||||
const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null);
|
||||
if (!nonce || !j1.location) return null;
|
||||
if (!j || typeof j.code !== "number" || j.code !== 0 || !j.location || !j.nonce || !j.ssecurity) {
|
||||
failLog(
|
||||
`phase1 sid=${sid} http=${p1.status} code=${j?.code ?? "?"} hasLoc=${!!j?.location}`
|
||||
+ ` secondValidation=${j?.bSecondValidation ?? "?"} notificationUrl=${j?.notificationUrl ? "present" : "no"}`
|
||||
+ ` body=${JSON.stringify(raw.slice(0, 200))}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
absorbSetCookie(jar, p1);
|
||||
|
||||
// 3. passportapi SSO phase 2 -> account-level serviceToken
|
||||
const sso2 = await proxyAwareFetch(
|
||||
`${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`,
|
||||
{ redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } },
|
||||
proxyOptions,
|
||||
);
|
||||
absorbSetCookie(jar, sso2);
|
||||
// PHASE 2 — clientSign the redirect, follow the redirect chain server-side.
|
||||
// ⚠️ CRITICAL DESKTOP SPEC (app.asar / SSO_curl.cpp line 728: cookies.clear()):
|
||||
// Phase 2 MUST NOT send ANY Cookie header! The server returns 200 OK with Set-Cookie: serviceToken!
|
||||
const sep = j.location.includes("?") ? "&" : "?";
|
||||
let current = `${j.location}${sep}clientSign=${signatureClientSign(rawNonce || j.nonce, j.ssecurity)}`;
|
||||
|
||||
// 4. mimopc SSO -> sts callback carrying a ticket
|
||||
const sso3 = await proxyAwareFetch(
|
||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`,
|
||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
||||
proxyOptions,
|
||||
);
|
||||
const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, ""));
|
||||
absorbSetCookie(jar, sso3);
|
||||
if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null;
|
||||
for (let hop = 0; hop < 8; hop++) {
|
||||
const res = await proxyAwareFetch(
|
||||
current,
|
||||
{ redirect: "manual", headers: { "User-Agent": SSO_UA } },
|
||||
proxyOptions,
|
||||
);
|
||||
absorbSetCookie(jar, res);
|
||||
const loc = res.headers.get("location");
|
||||
if (res.status >= 300 && res.status < 400 && loc) {
|
||||
current = new URL(loc, current).toString();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. sts callback -> Set-Cookie: serviceToken (mimopc scope)
|
||||
const sts = await proxyAwareFetch(
|
||||
j3.location,
|
||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
||||
proxyOptions,
|
||||
);
|
||||
absorbSetCookie(jar, sts);
|
||||
const sidKey = `${sid}_serviceToken`;
|
||||
if (!jar.serviceToken && jar[sidKey]) {
|
||||
jar.serviceToken = jar[sidKey];
|
||||
}
|
||||
|
||||
const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"];
|
||||
if (!jar.serviceToken) return null;
|
||||
if (!jar.serviceToken) {
|
||||
failLog(`phase2 no serviceToken sid=${sid} jar=[${Object.keys(jar).join(",")}]`);
|
||||
return null;
|
||||
}
|
||||
const out = {};
|
||||
for (const k of needed) if (jar[k]) out[k] = jar[k];
|
||||
for (const [k, v] of Object.entries(jar)) {
|
||||
if (!v) continue;
|
||||
if (k === "serviceToken" || k === "userId" || /_(ph|slh)$/.test(k)) out[k] = v;
|
||||
}
|
||||
return cookieHeader(out);
|
||||
}
|
||||
|
||||
@@ -179,13 +242,15 @@ async function acquireServiceCookie(passJar, proxyOptions) {
|
||||
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
|
||||
*/
|
||||
async function getServiceCookie(providerSpecificData, proxyOptions) {
|
||||
const apiBase = resolveMimoServerBase(providerSpecificData);
|
||||
const passJar = providerSpecificData?.mimoPassToken
|
||||
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
|
||||
: await readDesktopAccountCookies();
|
||||
if (!passJar) return { cookie: null, reason: "no-pass-token" };
|
||||
|
||||
// One cached session per passToken — accounts/connections rotate independently.
|
||||
const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex");
|
||||
// One cached session per passToken+cluster — accounts/connections rotate
|
||||
// independently, and the same passToken maps to different sessions per region.
|
||||
const key = crypto.createHash("sha256").update(`${apiBase}|${passJar.passToken}`).digest("hex");
|
||||
|
||||
const cached = _cache.get(key);
|
||||
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
|
||||
@@ -202,8 +267,9 @@ async function getServiceCookie(providerSpecificData, proxyOptions) {
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
return await acquireServiceCookie(passJar, proxyOptions);
|
||||
} catch {
|
||||
return await acquireServiceCookie(passJar, proxyOptions, apiBase, providerSpecificData?.region);
|
||||
} catch (e) {
|
||||
console.log(`[mimoAccount] acquire threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
|
||||
return null; // network/parse failure — callers degrade, never throw
|
||||
} finally {
|
||||
_inflight.delete(key);
|
||||
@@ -234,7 +300,8 @@ export async function getMimoAccountCookie(providerSpecificData = null, proxyOpt
|
||||
try {
|
||||
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
|
||||
return cookie;
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.log(`[mimoAccount] getMimoAccountCookie threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -250,7 +317,7 @@ export async function getMimoAccountUsage(providerSpecificData = null, proxyOpti
|
||||
}
|
||||
try {
|
||||
const res = await proxyAwareFetch(
|
||||
`${API_BASE}/api/user/usage`,
|
||||
`${resolveMimoServerBase(providerSpecificData)}/api/user/usage`,
|
||||
{ headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) },
|
||||
proxyOptions,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,10 @@ export function toOpenAIFinish(reason, format) {
|
||||
case CLAUDE_STOP.MAX_TOKENS: return OPENAI_FINISH.LENGTH;
|
||||
case CLAUDE_STOP.TOOL_USE: return OPENAI_FINISH.TOOL_CALLS;
|
||||
case CLAUDE_STOP.STOP_SEQUENCE: return OPENAI_FINISH.STOP;
|
||||
// A refusal is a blocked turn, not a clean stop: with the default mapping an
|
||||
// OpenAI client saw finish_reason "stop" and an empty message (9Router logged
|
||||
// "succeeded", OUT 0) and could not tell it from a real answer.
|
||||
case CLAUDE_STOP.REFUSAL: return OPENAI_FINISH.CONTENT_FILTER;
|
||||
default: return OPENAI_FINISH.STOP;
|
||||
}
|
||||
case "commandcode":
|
||||
@@ -55,6 +59,7 @@ export function fromOpenAIFinish(reason, format) {
|
||||
case OPENAI_FINISH.STOP: return CLAUDE_STOP.END_TURN;
|
||||
case OPENAI_FINISH.LENGTH: return CLAUDE_STOP.MAX_TOKENS;
|
||||
case OPENAI_FINISH.TOOL_CALLS: return CLAUDE_STOP.TOOL_USE;
|
||||
case OPENAI_FINISH.CONTENT_FILTER: return CLAUDE_STOP.REFUSAL;
|
||||
default: return CLAUDE_STOP.END_TURN;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -14,9 +14,6 @@ const STRIP_RULES = [
|
||||
{ provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] },
|
||||
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
|
||||
{ provider: "cloudflare-ai", flattenContent: true },
|
||||
// MiMo Desktop Preview models (account-service route): content must be plain string,
|
||||
// rejects OpenAI content-part array. Cloud models keep their parts (mimo-v2-omni is multi-modal).
|
||||
{ provider: "xiaomi-mimo", match: /preview/i, flattenContent: true },
|
||||
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
|
||||
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
|
||||
// advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144),
|
||||
|
||||
@@ -34,6 +34,8 @@ export function effortToThinkingLevel(effort) {
|
||||
|
||||
// Numeric budget → nearest discrete level (reverse map via thresholds).
|
||||
// Returns null when budget <= 0 (no reasoning).
|
||||
// Thresholds are midpoints between LEVEL_TO_BUDGET values: max (128000) is
|
||||
// reachable, with the xhigh/max boundary at the 32768/128000 midpoint (80384).
|
||||
export function budgetToLevel(budget) {
|
||||
const b = Number(budget);
|
||||
if (!b || b <= 0) return null;
|
||||
@@ -41,7 +43,8 @@ export function budgetToLevel(budget) {
|
||||
if (b <= 4096) return "low";
|
||||
if (b <= 16384) return "medium";
|
||||
if (b <= 28672) return "high";
|
||||
return "xhigh";
|
||||
if (b <= 80384) return "xhigh";
|
||||
return "max";
|
||||
}
|
||||
|
||||
// Gemini thinkingBudget (numeric) → OpenAI reasoning_effort (antigravity reverse map).
|
||||
|
||||
@@ -149,6 +149,13 @@ export function claudeToOpenAIResponse(chunk, state) {
|
||||
|
||||
if (chunk.delta?.stop_reason) {
|
||||
state.finishReason = convertStopReason(chunk.delta.stop_reason);
|
||||
// A refusal produces no content blocks at all. Surface Anthropic's own
|
||||
// explanation as the message text so the client shows *why* the turn is
|
||||
// empty instead of a blank reply.
|
||||
const refusalNote = chunk.delta.stop_reason === "refusal" && chunk.delta.stop_details?.explanation;
|
||||
if (refusalNote) {
|
||||
results.push(createChunk(state, { content: refusalNote }));
|
||||
}
|
||||
const finalChunk = createChunk(state, {}, state.finishReason);
|
||||
|
||||
if (state.usage) {
|
||||
|
||||
@@ -14,6 +14,9 @@ export const CLAUDE_STOP = {
|
||||
MAX_TOKENS: "max_tokens",
|
||||
TOOL_USE: "tool_use",
|
||||
STOP_SEQUENCE: "stop_sequence",
|
||||
// Anthropic's API-level refusal (streaming classifier / ToS). Arrives in
|
||||
// message_delta with zero output tokens; stop_details carries the reason.
|
||||
REFUSAL: "refusal",
|
||||
};
|
||||
|
||||
// Gemini finishReason values.
|
||||
|
||||
@@ -298,8 +298,11 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
||||
const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl);
|
||||
if (vercelRelayUrl) {
|
||||
const parsed = new URL(targetUrl);
|
||||
const baseHeaders = options.headers instanceof Headers
|
||||
? Object.fromEntries(options.headers.entries())
|
||||
: { ...(options.headers || {}) };
|
||||
const relayHeaders = {
|
||||
...options.headers,
|
||||
...baseHeaders,
|
||||
"x-relay-target": `${parsed.protocol}//${parsed.host}`,
|
||||
"x-relay-path": `${parsed.pathname}${parsed.search}`,
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.5.81",
|
||||
"version": "0.5.86",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1390,5 +1390,30 @@
|
||||
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。",
|
||||
"✓ Confirm Add": "✓ 确认添加",
|
||||
"📝 Configure providers in dashboard or use environment variables": "📝 在仪表盘中配置提供商或使用环境变量",
|
||||
"🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。"
|
||||
"🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。",
|
||||
"Reading local MiMo Desktop credentials...": "正在读取本地 MiMo 桌面版凭证...",
|
||||
"Desktop Plan · Local credentials": "Desktop Plan · 本地凭证",
|
||||
"This account is already connected (no need to import again)": "该账号已连接(无需重复导入)",
|
||||
"Untested": "未测试",
|
||||
"Re-sync local credentials": "重新同步本地凭证",
|
||||
"Connect with local credentials": "使用本地凭证连接",
|
||||
"or": "或",
|
||||
"Browser Login": "网页登录",
|
||||
"No Desktop required": "无需桌面客户端",
|
||||
"Weekly quota": "周额度",
|
||||
"Waiting for login...": "等待登录中...",
|
||||
"Reopen login window": "重新打开登录窗口",
|
||||
"Choose cluster & sign in": "选择集群并登录",
|
||||
"Login session expired — please retry.": "登录会话已过期,请重试。",
|
||||
"Failed to save credentials": "保存凭据失败",
|
||||
"Import failed": "导入失败",
|
||||
"No local Desktop credentials found": "未检测到本地桌面凭证",
|
||||
"You can still sign in via browser — no Desktop client needed.": "仍可通过网页登录,无需桌面客户端。",
|
||||
"Select account cluster": "选择小米账号集群",
|
||||
"Choose the region cluster of your Xiaomi account:": "请选择你的小米账号所在地区集群:",
|
||||
"China (Mainland)": "中国大陆",
|
||||
"Singapore": "新加坡",
|
||||
"Europe · Amsterdam": "欧洲 · 阿姆斯特丹",
|
||||
"Russia": "俄罗斯",
|
||||
"India": "印度"
|
||||
}
|
||||
@@ -17,9 +17,9 @@ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
|
||||
// A request needing a capability the target model/combo lacks switches straight
|
||||
// to the first enabled model here instead of erroring or dropping the data.
|
||||
const CAPACITY_ADAPTER_CAPS = [
|
||||
{ key: "vision", label: "Vision", icon: "visibility", desc: "Images" },
|
||||
{ key: "vision", label: "Vision", icon: "visibility", desc: "images (png, jpg, webp, …)" },
|
||||
// pdf, videoInput temporarily hidden — no translator support yet for those blocks.
|
||||
{ key: "audioInput", label: "Audio", icon: "graphic_eq", desc: "Audio input" },
|
||||
{ key: "audioInput", label: "Audio", icon: "graphic_eq", desc: "audio input" },
|
||||
];
|
||||
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.6-flash-free";
|
||||
const EMPTY_CAP_ENTRY = { enabled: true, roundRobin: false, models: [] };
|
||||
@@ -29,16 +29,18 @@ const EMPTY_CAPACITY_ADAPTER = {
|
||||
audioInput: { ...EMPTY_CAP_ENTRY },
|
||||
videoInput: { ...EMPTY_CAP_ENTRY },
|
||||
};
|
||||
const upgradeLegacyModel = (m) => (m === "oc/mimo-v2.5-free" ? DEFAULT_FALLBACK_MODEL : m);
|
||||
|
||||
// Backward-compat: legacy stored form was an array of {model, enabled}.
|
||||
function normalizeCapEntry(entry) {
|
||||
if (Array.isArray(entry)) {
|
||||
return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) };
|
||||
return { enabled: true, roundRobin: false, models: entry.map((e) => upgradeLegacyModel(e?.model || e)).filter(Boolean) };
|
||||
}
|
||||
if (entry && typeof entry === "object") {
|
||||
return {
|
||||
enabled: entry.enabled !== false,
|
||||
roundRobin: !!entry.roundRobin,
|
||||
models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [],
|
||||
models: Array.isArray(entry.models) ? entry.models.map(upgradeLegacyModel).filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
return { ...EMPTY_CAP_ENTRY };
|
||||
@@ -371,7 +373,7 @@ export default function CombosPage() {
|
||||
<li><span className="font-medium text-text-main">Round Robin</span> — rotates models across requests to spread load</li>
|
||||
<li><span className="font-medium text-text-main">Fusion</span> — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)</li>
|
||||
</ul>
|
||||
<p className="text-xs text-text-muted mt-3 max-w-2xl">
|
||||
<p className="hidden text-xs text-text-muted mt-3 max-w-2xl">
|
||||
<span className="font-medium text-text-main">Cursor / Claude Default</span> create combos named exactly like those clients' model IDs (e.g. <code className="font-mono">composer-2.5</code>, <code className="font-mono">opus</code>), seeded with the matching <code className="font-mono">cu/…</code> or <code className="font-mono">cc/…</code> route so traffic can hit 9router without the prefix.
|
||||
{" "}Note: Cursor IDE itself often blocks built-in Composer / Grok from Override OpenAI Base URL ("model does not support custom API"); add them via Cursor's <span className="font-medium text-text-main">Add Custom Model</span> using the combo name, or pick a model Cursor allows through the custom endpoint.
|
||||
</p>
|
||||
@@ -380,7 +382,7 @@ export default function CombosPage() {
|
||||
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
|
||||
Create Combo
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-col">
|
||||
<div className="hidden">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -707,10 +709,6 @@ function CapacityAdapterSection({ capacityAdapter, onChange, activeProviders, ge
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Your model can't read image/audio? Auto-switches to a model in the pool below.
|
||||
</p>
|
||||
<ul className="mt-1.5 text-[11px] text-text-muted flex flex-col gap-0.5">
|
||||
<li><span className="font-medium text-text-main">Vision</span> — images (png, jpg, webp, …)</li>
|
||||
<li><span className="font-medium text-text-main">Audio</span> — audio input</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -9,25 +9,25 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
||||
export default function OverviewCards({ stats }) {
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 sm:gap-4">
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
|
||||
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
|
||||
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Total Requests</span>
|
||||
<span className="w-full truncate text-lg font-bold xl:text-xl" title={fmt(stats.totalRequests)}>{fmt(stats.totalRequests)}</span>
|
||||
</Card>
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
|
||||
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
|
||||
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Total Input Tokens</span>
|
||||
<span className="w-full truncate text-lg font-bold text-primary xl:text-xl" title={fmt(stats.totalPromptTokens)}>{fmt(stats.totalPromptTokens)}</span>
|
||||
</Card>
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span>
|
||||
<span className="truncate text-2xl font-bold text-info">{fmt(stats.totalCachedTokens)}</span>
|
||||
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Cached Tokens</span>
|
||||
<span className="w-full truncate text-lg font-bold text-info xl:text-xl" title={fmt(stats.totalCachedTokens)}>{fmt(stats.totalCachedTokens)}</span>
|
||||
</Card>
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
|
||||
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
|
||||
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Output Tokens</span>
|
||||
<span className="w-full truncate text-lg font-bold text-success xl:text-xl" title={fmt(stats.totalCompletionTokens)}>{fmt(stats.totalCompletionTokens)}</span>
|
||||
</Card>
|
||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
||||
<span className="text-text-muted text-sm uppercase font-semibold">Est. Cost</span>
|
||||
<span className="truncate text-2xl font-bold text-warning">~{fmtCost(stats.totalCost)}</span>
|
||||
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Est. Cost</span>
|
||||
<span className="w-full truncate text-lg font-bold text-warning xl:text-xl" title={`~${fmtCost(stats.totalCost)}`}>~{fmtCost(stats.totalCost)}</span>
|
||||
<span className="text-[10px] text-text-muted">Estimated, not actual billing</span>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ const PERIODS = [
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "60d", label: "60D" },
|
||||
{ value: "all", label: "All" },
|
||||
];
|
||||
|
||||
export default function UsagePage() {
|
||||
|
||||
@@ -10,17 +10,19 @@ import { createProviderConnection } from "@/models";
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId } = await request.json();
|
||||
const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId, region } = await request.json();
|
||||
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
const key = typeof apiKey === "string" ? apiKey.trim() : "";
|
||||
const sessionOnly = !key && !!mimoPassToken;
|
||||
|
||||
if (!key && !mimoPassToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "API key is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const key = apiKey.trim();
|
||||
if (!key.startsWith("sk-")) {
|
||||
if (key && !key.startsWith("sk-")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid key format — expected sk- prefix" },
|
||||
{ status: 400 },
|
||||
@@ -29,47 +31,55 @@ export async function POST(request) {
|
||||
|
||||
const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, "");
|
||||
|
||||
// Validate the key against the models endpoint
|
||||
// Validate the key against the models endpoint (skipped for session-only)
|
||||
let validated = false;
|
||||
let modelCount = 0;
|
||||
try {
|
||||
const resp = await fetch(`${effectiveBaseUrl}/models`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"X-Mimo-Source": "mimocode-cli",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
modelCount = Array.isArray(data?.data) ? data.data.length : 0;
|
||||
validated = true;
|
||||
if (key) {
|
||||
try {
|
||||
const resp = await fetch(`${effectiveBaseUrl}/models`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
"X-Mimo-Source": "mimocode-cli",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
modelCount = Array.isArray(data?.data) ? data.data.length : 0;
|
||||
validated = true;
|
||||
}
|
||||
} catch {
|
||||
// Network error — still allow import (key may be valid but network blocked)
|
||||
}
|
||||
} catch {
|
||||
// Network error — still allow import (key may be valid but network blocked)
|
||||
}
|
||||
|
||||
if (!validated) {
|
||||
if (key && !validated) {
|
||||
// Soft-fail: store the key but mark as untested
|
||||
console.log("[xiaomi-mimo] key validation failed, storing as untested");
|
||||
}
|
||||
|
||||
// Dedup: if a connection with the same uid or same key already exists, update it
|
||||
// Dedup: same uid, same key, or same session identity+region
|
||||
const { getProviderConnections, updateProviderConnection } = await import("@/models");
|
||||
const normRegion = (typeof region === "string" && region) || undefined;
|
||||
const existing = (await getProviderConnections()).find(
|
||||
(c) => c.provider === "xiaomi-mimo" && (
|
||||
(uid && c.email === `${uid}@xiaomi`) ||
|
||||
c.accessToken === key
|
||||
(key && c.accessToken === key) ||
|
||||
(sessionOnly && mimoUserId &&
|
||||
c.providerSpecificData?.mimoUserId === mimoUserId &&
|
||||
(normRegion ? (c.providerSpecificData?.region || "cn") === normRegion : true))
|
||||
),
|
||||
);
|
||||
if (existing) {
|
||||
const updated = await updateProviderConnection(existing.id, {
|
||||
accessToken: key,
|
||||
accessToken: key || existing.accessToken,
|
||||
providerSpecificData: {
|
||||
...existing.providerSpecificData,
|
||||
uid: uid || existing.providerSpecificData?.uid || null,
|
||||
baseUrl: effectiveBaseUrl,
|
||||
baseUrl: key ? effectiveBaseUrl : (existing.providerSpecificData?.baseUrl || effectiveBaseUrl),
|
||||
region: normRegion || existing.providerSpecificData?.region || "cn",
|
||||
authMethod: sessionOnly ? "session" : (existing.providerSpecificData?.authMethod || "api_key"),
|
||||
// Per-account session credential — enables multi-account rotation.
|
||||
mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null,
|
||||
mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null,
|
||||
@@ -94,25 +104,29 @@ export async function POST(request) {
|
||||
|
||||
const connection = await createProviderConnection({
|
||||
provider: "xiaomi-mimo",
|
||||
authType: "api_key",
|
||||
accessToken: key,
|
||||
// "oauth" is the official authType for imported credential connections
|
||||
// ([action]/route.js) — the list card and filters key off it; never
|
||||
// invent new values ("session" hid the row from the provider card).
|
||||
authType: sessionOnly ? "oauth" : "api_key",
|
||||
accessToken: key || null,
|
||||
refreshToken: null,
|
||||
// API keys don't expire on a fixed schedule; use a long horizon
|
||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
email: uid ? `${uid}@xiaomi` : null,
|
||||
displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo",
|
||||
displayName: uid ? `Xiaomi ${uid}${sessionOnly ? " (Session)" : ""}` : "Xiaomi MiMo",
|
||||
providerSpecificData: {
|
||||
uid: uid || null,
|
||||
baseUrl: effectiveBaseUrl,
|
||||
authMethod: "api_key",
|
||||
provider: "API Key",
|
||||
authMethod: sessionOnly ? "session" : "api_key",
|
||||
provider: sessionOnly ? "Session Login" : "API Key",
|
||||
region: normRegion || "cn",
|
||||
modelCount,
|
||||
// Per-account session credential — enables multi-account rotation.
|
||||
mimoPassToken: mimoPassToken || null,
|
||||
mimoUserId: mimoUserId || null,
|
||||
mimoCUserId: mimoCUserId || null,
|
||||
},
|
||||
testStatus: validated ? "active" : "untested",
|
||||
testStatus: validated ? "active" : (sessionOnly ? "active" : "untested"),
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { beginSession, encodeSessionCookie, rewriteMimoBases, absorbSetCookies as absorbResponseCookies, originOf, loginUpstreamFetch, SESSION_COOKIE } from "@/lib/mimoLoginSession";
|
||||
|
||||
/**
|
||||
* POST /api/oauth/xiaomi-mimo/login/start
|
||||
* Body: { region: "cn" | "sgp" | "ams" | "ru" | "in" }
|
||||
*
|
||||
* Walks the first two hops of the Desktop login surface server-side
|
||||
* (me -> 302 account/pass/serviceLogin -> 302 /fe/service/login) and hands
|
||||
* the browser a same-origin pageUrl carrying the 9r_mimo_login session cookie.
|
||||
* All subsequent account.xiaomi.com traffic flows through src/proxy.js.
|
||||
*
|
||||
* Egress resolution: MIMO_LOGIN_PROXY env > (region=sgp: probe common LOCAL
|
||||
* HTTP proxy ports — v2rayN/clash defaults) > direct. The resolved URL rides
|
||||
* the session cookie so every hop/XHR uses the same exit.
|
||||
*/
|
||||
|
||||
const API_UA =
|
||||
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||
const SSO_UA = "MiClaw/1.0";
|
||||
const LOCAL_PROXY_PORTS = [10808, 10809, 7890, 7891, 1080, 1081, 8080, 8888];
|
||||
|
||||
/** First local port answering a CONNECT to account.xiaomi.com (or null). */
|
||||
function probeLocalHttpProxy(timeoutMs = 500) {
|
||||
const attempts = LOCAL_PROXY_PORTS.map(
|
||||
(port) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const done = (v) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
// Promise.any picks the first FULFILLED value — failures must reject,
|
||||
// otherwise an instant ECONNREFUSED from a closed candidate port would
|
||||
// "win" with null before the real proxy answers.
|
||||
if (v) resolve(v);
|
||||
else reject(new Error(`no-proxy-${port}`));
|
||||
};
|
||||
try {
|
||||
const req = httpRequest({
|
||||
host: "127.0.0.1",
|
||||
port,
|
||||
method: "CONNECT",
|
||||
path: "account.xiaomi.com:443",
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
req.on("connect", (res, socket) => {
|
||||
socket.destroy();
|
||||
done(res.statusCode === 200 || res.statusCode === 202 ? `http://127.0.0.1:${port}` : null);
|
||||
});
|
||||
req.on("timeout", () => { req.destroy(); done(null); });
|
||||
req.on("error", () => done(null));
|
||||
req.on("response", () => done(null));
|
||||
req.end();
|
||||
} catch {
|
||||
done(null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return Promise.any(attempts).catch(() => null);
|
||||
}
|
||||
|
||||
async function hop(sess, url, ua) {
|
||||
return loginUpstreamFetch(url, {
|
||||
redirect: "manual",
|
||||
headers: { "User-Agent": ua, Accept: "text/html,application/json,*/*" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
}, sess);
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
let region = "cn";
|
||||
try {
|
||||
const body = await request.json();
|
||||
const r = String(body?.region || "").toLowerCase();
|
||||
// Known MiMo Desktop clusters (cn/sgp/ams/ru/in) — default cn.
|
||||
if (r === "cn" || r === "sgp" || r === "ams" || r === "ru" || r === "in") region = r;
|
||||
} catch { /* empty body — default cn */ }
|
||||
|
||||
const sess = beginSession(region);
|
||||
|
||||
// Egress — non-CN clusters may need an overseas exit for the login page's
|
||||
// geo-decided features (e.g. Google sign-in); CN is always direct.
|
||||
let egress = null;
|
||||
let egressSource = "direct";
|
||||
if (region !== "cn") {
|
||||
const found = await probeLocalHttpProxy();
|
||||
if (found) {
|
||||
egress = found;
|
||||
egressSource = "local-probe";
|
||||
}
|
||||
}
|
||||
sess.proxyUrl = egress;
|
||||
|
||||
// Hop 1: me -> account SSO (callback carries the sts callback for THIS cluster)
|
||||
const meRes = await hop(sess, `${sess.upstreamBase}/api/user/xiaomi/me`, API_UA);
|
||||
absorbResponseCookies(sess, meRes, `${sess.upstreamBase}/api/user/xiaomi/me`);
|
||||
const ssoLoc = meRes.headers.get("location");
|
||||
if (!ssoLoc || !/account\.xiaomi\.com/.test(ssoLoc)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unexpected me response (${meRes.status}) — no account redirect` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// Hop 2: serviceLogin -> /fe/service/login SPA (also seeds deviceId cookies)
|
||||
const loginRes = await hop(sess, ssoLoc, SSO_UA);
|
||||
absorbResponseCookies(sess, loginRes, ssoLoc);
|
||||
const pageLoc = loginRes.headers.get("location");
|
||||
if (!pageLoc) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unexpected serviceLogin response (${loginRes.status})` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// Same-origin path for the SPA (middleware proxies native prefixes).
|
||||
const pageUrl = new URL(pageLoc, "https://account.xiaomi.com");
|
||||
const origin = originOf(request);
|
||||
// Session travels ONLY in the httpOnly cookie — never in the URL (history,
|
||||
// logs, Referer). /login/status re-arms the cookie on every poll, so a
|
||||
// dropped-cookie browser still recovers on the next poll cycle.
|
||||
const proxiedPath = rewriteMimoBases(pageUrl.pathname + pageUrl.search, "toProxy", origin);
|
||||
const egressLog = egress ? egress.replace(/\/\/[^@/]+@/, "//***@") : "";
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start region=${sess.region} origin=${origin} egress=${egressSource}${egressLog ? ` (${egressLog})` : ""} page=${pageUrl.pathname}`);
|
||||
|
||||
const res = NextResponse.json({ success: true, state: sess.state, pageUrl: proxiedPath, region });
|
||||
res.cookies.set(SESSION_COOKIE, encodeSessionCookie(sess), {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 15 * 60,
|
||||
});
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start error:`, error?.message || error);
|
||||
return NextResponse.json({ error: error?.message || "login start failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { sessionFromRequest, readSessionIdentity, attachSessionCookie } from "@/lib/mimoLoginSession";
|
||||
|
||||
/**
|
||||
* GET /api/oauth/xiaomi-mimo/login/status?state=...
|
||||
* Polls the server-side login session (state lives in the httpOnly session
|
||||
* cookie — route handlers and the proxy don't share module memory). When a
|
||||
* passToken is in the jar, probes /api/user/xiaomi/me once to confirm the
|
||||
* session works, then returns the identity for the client to persist.
|
||||
*/
|
||||
export async function GET(request) {
|
||||
const url = new URL(request.url);
|
||||
const state = url.searchParams.get("state") || "";
|
||||
const sess = sessionFromRequest(request);
|
||||
if (!sess || (state && sess.state !== state)) {
|
||||
return NextResponse.json({ status: "expired" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (sess.status !== "done") {
|
||||
// AUTHORIZATION = passToken in the jar (captured during the proxied login
|
||||
// XHRs). No serviceToken exchange — weekly-quota API moved; re-wire later.
|
||||
if (readSessionIdentity(sess)) sess.status = "done";
|
||||
}
|
||||
|
||||
if (sess.status !== "done") {
|
||||
// Re-arm the session cookie on every poll — the modal may sit on the login
|
||||
// form much longer than the 15min TTL, and only proxied responses used to
|
||||
// refresh it (browser silently drops an expired cookie before the POST).
|
||||
return attachSessionCookie(NextResponse.json({ status: "pending", region: sess.region }), sess);
|
||||
}
|
||||
|
||||
const id = readSessionIdentity(sess);
|
||||
if (!id) {
|
||||
return attachSessionCookie(
|
||||
NextResponse.json({ status: "error", error: "session captured but passToken missing" }),
|
||||
sess,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = { status: "done", region: sess.region, ...id };
|
||||
// One-shot: don't let the identity linger past the client reading it.
|
||||
const res = NextResponse.json(payload);
|
||||
res.cookies.set("9r_mimo_login", "", { path: "/", httpOnly: true, maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
@@ -20,14 +20,15 @@ export default async function handler(req) {
|
||||
|
||||
const targetUrl = target.replace(/\\/$/, "") + relayPath;
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("x-relay-target");
|
||||
headers.delete("x-relay-path");
|
||||
headers.delete("host");
|
||||
const rawHeaders = {};
|
||||
for (const [k, v] of req.headers.entries()) rawHeaders[k] = v;
|
||||
delete rawHeaders["x-relay-target"];
|
||||
delete rawHeaders["x-relay-path"];
|
||||
delete rawHeaders["host"];
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
headers: rawHeaders,
|
||||
body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
|
||||
duplex: "half",
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getChartData } from "@/lib/usageDb";
|
||||
|
||||
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d"]);
|
||||
const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d", "all"]);
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
|
||||
@@ -191,6 +191,9 @@ function isPublicApi(pathname) {
|
||||
return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
||||
}
|
||||
|
||||
// Shared with src/proxy.js — the mimo login branch must respect dashboard auth.
|
||||
export { isAuthenticated };
|
||||
|
||||
export const __test__ = {
|
||||
isLocalRequest,
|
||||
isPublicLlmApi,
|
||||
|
||||
+31
-16
@@ -56,12 +56,13 @@ export function onLocaleChange(callback) {
|
||||
|
||||
// Process text node
|
||||
function processTextNode(node) {
|
||||
if (!node.nodeValue || !node.nodeValue.trim()) return;
|
||||
|
||||
const current = node.nodeValue;
|
||||
if (!current || !current.trim()) return;
|
||||
|
||||
// Skip if parent is script, style, code, or structural elements
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
|
||||
// Skip if parent or any ancestor has data-i18n-skip attribute
|
||||
let element = parent;
|
||||
while (element) {
|
||||
@@ -70,27 +71,33 @@ function processTextNode(node) {
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
|
||||
const tagName = parent.tagName?.toLowerCase();
|
||||
|
||||
|
||||
// Skip elements that don't allow text nodes
|
||||
const skipTags = [
|
||||
"script", "style", "code", "pre",
|
||||
"colgroup", "table", "thead", "tbody", "tfoot", "tr",
|
||||
"select", "datalist", "optgroup"
|
||||
];
|
||||
|
||||
|
||||
if (skipTags.includes(tagName)) return;
|
||||
|
||||
// Store original text if not already stored
|
||||
if (!node._originalText) {
|
||||
node._originalText = node.nodeValue;
|
||||
|
||||
// React reuses text nodes and rewrites their value on re-render (a
|
||||
// characterData mutation, no childList event). When the current value is
|
||||
// neither our last translation nor the recorded original, it is fresh
|
||||
// source text — re-capture it as the new original before translating.
|
||||
const isOurTranslation = node._translated != null && current === node._translated;
|
||||
const isSameAsOriginal = current === node._originalText;
|
||||
if (!isOurTranslation && !isSameAsOriginal) {
|
||||
node._originalText = current;
|
||||
}
|
||||
|
||||
// Use original text for translation
|
||||
const original = node._originalText;
|
||||
const translated = translate(original);
|
||||
|
||||
if (node._originalText == null) node._originalText = current;
|
||||
|
||||
// Translate from the recorded original so locale switches stay idempotent
|
||||
const translated = translate(node._originalText);
|
||||
node._translated = translated;
|
||||
|
||||
// Only update if different to avoid unnecessary DOM mutations
|
||||
if (translated !== node.nodeValue) {
|
||||
node.nodeValue = translated;
|
||||
@@ -130,9 +137,16 @@ export async function initRuntimeI18n() {
|
||||
// Process existing DOM
|
||||
processElement(document.body);
|
||||
|
||||
// Watch for new nodes
|
||||
// Watch for new nodes AND in-place text rewrites. React reuses text nodes on
|
||||
// re-render (only nodeValue changes → a characterData mutation with no
|
||||
// childList event), so observing childList alone leaves later-updated labels
|
||||
// untranslated.
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "characterData") {
|
||||
processTextNode(mutation.target);
|
||||
return;
|
||||
}
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
processElement(node);
|
||||
@@ -146,6 +160,7 @@ export async function initRuntimeI18n() {
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,16 @@ export function mergeWithDefaults(raw) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (merged.capacityAdapter && typeof merged.capacityAdapter === "object") {
|
||||
for (const capKey of Object.keys(merged.capacityAdapter)) {
|
||||
const entry = merged.capacityAdapter[capKey];
|
||||
if (Array.isArray(entry?.models)) {
|
||||
entry.models = entry.models.map((m) =>
|
||||
m === "oc/mimo-v2.5-free" ? "oc/mimo-v2.6-flash-free" : m
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -335,12 +335,12 @@ export async function getUsageHistory(filter = {}) {
|
||||
|
||||
function loadDaysInRange(adapter, maxDays) {
|
||||
if (maxDays == null) {
|
||||
return adapter.all(`SELECT dateKey, data FROM usageDaily`);
|
||||
return adapter.all(`SELECT dateKey, data FROM usageDaily ORDER BY dateKey ASC`);
|
||||
}
|
||||
const today = new Date();
|
||||
const cutoff = new Date(today.getFullYear(), today.getMonth(), today.getDate() - maxDays + 1);
|
||||
const cutoffKey = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`;
|
||||
return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, [cutoffKey]);
|
||||
return adapter.all(`SELECT dateKey, data FROM usageDaily WHERE dateKey >= ? ORDER BY dateKey ASC`, [cutoffKey]);
|
||||
}
|
||||
|
||||
export async function getUsageStats(period = "all") {
|
||||
@@ -537,8 +537,15 @@ export async function getUsageStats(period = "all") {
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay precise lastUsed timestamps from history
|
||||
const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0;
|
||||
// Overlay precise lastUsed timestamps from history.
|
||||
// ponytail: overlay scans only a recent window; entries older than that keep
|
||||
// day-level lastUsed from usageDaily. Upgrade to a materialized per-key
|
||||
// MAX(timestamp) table if exact old timestamps ever matter.
|
||||
const OVERLAY_WINDOW_MS = 2 * 86400000;
|
||||
const overlayCutoff = Math.max(
|
||||
maxDays ? Date.now() - maxDays * 86400000 : 0,
|
||||
Date.now() - OVERLAY_WINDOW_MS
|
||||
);
|
||||
const histRows = db.all(
|
||||
`SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`,
|
||||
[new Date(overlayCutoff).toISOString()]
|
||||
@@ -711,9 +718,35 @@ export async function getChartData(period = "7d") {
|
||||
return buckets;
|
||||
}
|
||||
|
||||
const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
|
||||
if (period === "all") {
|
||||
const dayRows = loadDaysInRange(db, null);
|
||||
if (!dayRows.length) return [];
|
||||
const dayMap = {};
|
||||
for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {});
|
||||
|
||||
const earliest = new Date(dayRows[0].dateKey + "T00:00:00");
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.max(1, Math.round((today - earliest) / 86400000) + 1);
|
||||
|
||||
return Array.from({ length: diffDays }, (_, i) => {
|
||||
const d = new Date(earliest);
|
||||
d.setDate(d.getDate() + i);
|
||||
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const dayData = dayMap[dateKey];
|
||||
return {
|
||||
label: labelFn(d),
|
||||
tokens: dayData ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0,
|
||||
cost: dayData ? (dayData.cost || 0) : 0,
|
||||
requests: dayData ? (dayData.requests || 0) : 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60;
|
||||
const today = new Date();
|
||||
const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
|
||||
// Build map of dateKey → day data
|
||||
const dayRows = loadDaysInRange(db, bucketCount);
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* Server-side Xiaomi account session login (mimics MiMo Desktop's login surface).
|
||||
*
|
||||
* Flow (reverse-engineered from Desktop traffic / mimoAccount.js):
|
||||
* 1. GET {mimo-server}/api/user/xiaomi/me -> 302 account /pass/serviceLogin?sid=mimopc&callback={sts}
|
||||
* 2. GET account /pass/serviceLogin -> 302 /fe/service/login (the SPA)
|
||||
* 3. Browser (via the src/proxy.js reverse proxy) completes login on the REAL
|
||||
* page (password / whatever the page offers) — every account.xiaomi.com
|
||||
* request passes through the proxy; Set-Cookie lands in OUR jar (which
|
||||
* travels in the httpOnly 9r_mimo_login cookie between hops).
|
||||
* 4. SPA navigates to the sts callback -> rewritten to /__mimo_login/mimo/*,
|
||||
* middleware takes over and follows the chain server-side:
|
||||
* sts -> Set-Cookie serviceToken -> me (200 JSON = logged in).
|
||||
* 5. passToken/userId/cUserId read from the jar -> stored on the connection.
|
||||
*
|
||||
* Edge-safe: no Node-only APIs (used from both middleware and API routes).
|
||||
*/
|
||||
|
||||
const ACCOUNT_HOST = "account.xiaomi.com";
|
||||
export const SESSION_COOKIE = "9r_mimo_login";
|
||||
const SESSION_TTL_MS = 15 * 60 * 1000;
|
||||
const API_UA =
|
||||
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||
const SSO_UA = "MiClaw/1.0";
|
||||
const BROWSER_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
// Paths that belong to the 9router app itself — never proxy these upstream,
|
||||
// even while a login session is active. Everything else is fair game: the
|
||||
// login SPA hits evolving endpoints (/pass2/config, /v3/...), so a static
|
||||
// allowlist rots fast. (Edge-safe: plain strings only.)
|
||||
const APP_PREFIXES = [
|
||||
"/_next/", "/api/", "/dashboard", "/v1/", "/v1beta/",
|
||||
"/login", "/landing", "/__mimo_login/", "/i18n/", "/icons/", "/providers/",
|
||||
];
|
||||
const APP_FILES = new Set([
|
||||
"/favicon.svg", "/favicon.ico", "/file.svg", "/globe.svg", "/next.svg",
|
||||
"/vercel.svg", "/window.svg", "/sw.js", "/robots.txt", "/manifest.webmanifest",
|
||||
]);
|
||||
|
||||
const APP_PATH_MATCHES = (pathname) =>
|
||||
APP_FILES.has(pathname) ||
|
||||
APP_PREFIXES.some((p) => pathname === p || pathname.startsWith(p.endsWith("/") ? p : p + "/"));
|
||||
|
||||
const MIMO_BASES = {
|
||||
cn: "https://mimo-server-cn.xiaomimimo.com",
|
||||
sgp: "https://mimo-server-sgp.xiaomimimo.com",
|
||||
ams: "https://mimo-server-ams.xiaomimimo.com",
|
||||
ru: "https://mimo-server-ru.xiaomimimo.com",
|
||||
in: "https://mimo-server-in.xiaomimimo.com",
|
||||
};
|
||||
// Unknown/absent region falls back to SGP (the international/open cluster).
|
||||
const DEFAULT_REGION = "sgp";
|
||||
// passToken-prefix -> last failure ts (60s backoff for the service exchange)
|
||||
const _exchangeBackoff = new Map();
|
||||
|
||||
export function resolveMimoRegionBase(region) {
|
||||
const r = String(region || "").toLowerCase();
|
||||
return MIMO_BASES[r] || MIMO_BASES[DEFAULT_REGION];
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-facing origin for this request. Prefer the Host header — request.url
|
||||
* / nextUrl may carry the bind address (0.0.0.0), which must never leak into
|
||||
* rewritten callbacks (start and proxy must agree on the exact same origin,
|
||||
* and both see the same Host header).
|
||||
*/
|
||||
export function originOf(request) {
|
||||
const proto = request.nextUrl?.protocol
|
||||
|| (request.headers?.get?.("x-forwarded-proto") || "http");
|
||||
const host = request.headers?.get?.("host") || request.nextUrl?.host;
|
||||
return host ? `${proto}//${host}` : (request.nextUrl?.origin || "http://localhost:20131");
|
||||
}
|
||||
|
||||
// The session (incl. the accumulated cookie jar) travels in the SESSION_COOKIE
|
||||
// itself — Next runs route handlers and the proxy in separate bundles, so a
|
||||
// module-level Map is NOT shared between them. Cookie-carried state works
|
||||
// regardless of runtime topology. httpOnly + SameSite=Lax, TTL-bounded.
|
||||
|
||||
export function beginSession(region) {
|
||||
const normalizedRegion = String(region || "").toLowerCase();
|
||||
return {
|
||||
state: crypto.randomUUID(),
|
||||
region: normalizedRegion in MIMO_BASES ? normalizedRegion : DEFAULT_REGION,
|
||||
proxyUrl: null, // resolved by the start route (env | local-probe for sgp | null)
|
||||
jar: new Map(), // "name|domain|path" -> { name, value, domain, path }
|
||||
status: "pending",
|
||||
createdAt: Date.now(),
|
||||
upstreamBase: resolveMimoRegionBase(region),
|
||||
};
|
||||
}
|
||||
|
||||
const KEEP_ON_OVERFLOW = /^(passToken|userId|cUserId|serviceToken|.*_serviceToken|.*_ph|.*_slh|deviceId)$/;
|
||||
// Browser hard limit for one cookie value is 4096 bytes. base64url inflates
|
||||
// ~1.34x, so the JSON payload must stay under ~2800 to be safe.
|
||||
const COOKIE_JSON_BUDGET = 2800;
|
||||
|
||||
function b64urlEncode(str) {
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function b64urlDecode(s) {
|
||||
let t = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (t.length % 4) t += "=";
|
||||
const bin = atob(t);
|
||||
return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
export function encodeSessionCookie(sess) {
|
||||
const entries = [...sess.jar.values()].map((c) => [c.name, c.value, c.domain, c.path]);
|
||||
const base = { s: sess.state, r: sess.region, t: sess.createdAt, p: sess.proxyUrl || "" };
|
||||
let payload = JSON.stringify({ ...base, j: entries });
|
||||
if (payload.length > COOKIE_JSON_BUDGET) {
|
||||
// Cookie budget: drop everything but identity/session essentials.
|
||||
payload = JSON.stringify({ ...base, j: entries.filter(([name]) => KEEP_ON_OVERFLOW.test(name)) });
|
||||
}
|
||||
return `v2.${b64urlEncode(payload)}`;
|
||||
}
|
||||
|
||||
export function decodeSessionCookie(value) {
|
||||
if (!value) return null;
|
||||
let payload;
|
||||
try {
|
||||
if (value.startsWith("v2.")) {
|
||||
payload = JSON.parse(b64urlDecode(value.slice(3)));
|
||||
} else if (value.startsWith("v1.")) {
|
||||
payload = JSON.parse(decodeURIComponent(value.slice(3))); // legacy
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || typeof payload.t !== "number") return null;
|
||||
if (Date.now() - payload.t > SESSION_TTL_MS) return null;
|
||||
const jar = new Map();
|
||||
for (const raw of payload.j || []) {
|
||||
if (!Array.isArray(raw) || raw.length < 4) continue;
|
||||
const [name, val, domain, path] = raw;
|
||||
jar.set(`${name}|${domain}|${path}`, { name, value: val, domain, path });
|
||||
}
|
||||
return {
|
||||
state: payload.s,
|
||||
region: payload.r in MIMO_BASES ? payload.r : DEFAULT_REGION,
|
||||
proxyUrl: typeof payload.p === "string" && payload.p ? payload.p : null,
|
||||
jar,
|
||||
status: "pending",
|
||||
createdAt: payload.t,
|
||||
upstreamBase: resolveMimoRegionBase(payload.r),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the session straight from an incoming request (any runtime). */
|
||||
export function sessionFromRequest(request) {
|
||||
const raw = request.cookies?.get?.(SESSION_COOKIE)?.value
|
||||
?? parseCookieHeader(request.headers?.get?.("cookie"))?.[SESSION_COOKIE];
|
||||
return decodeSessionCookie(raw || null);
|
||||
}
|
||||
|
||||
function parseCookieHeader(header) {
|
||||
if (!header) return null;
|
||||
const out = {};
|
||||
for (const part of header.split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i < 0) continue;
|
||||
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Append the re-encoded session to any Response (proxy writes go through here). */
|
||||
export function attachSessionCookie(response, sess) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.append(
|
||||
"Set-Cookie",
|
||||
`${SESSION_COOKIE}=${encodeSessionCookie(sess)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
|
||||
);
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearedSessionCookie() {
|
||||
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
|
||||
// ---------- cookie jar (server-side) ----------
|
||||
|
||||
function jarKey(c) {
|
||||
return `${c.name}|${c.domain}|${c.path}`;
|
||||
}
|
||||
|
||||
function domainMatch(cookieDomain, host) {
|
||||
const d = String(cookieDomain || "").replace(/^\./, "").toLowerCase();
|
||||
const h = String(host || "").toLowerCase();
|
||||
return h === d || h.endsWith("." + d);
|
||||
}
|
||||
|
||||
/** Parse one Set-Cookie header value. Returns { cookie, expired } or null. */
|
||||
function parseSetCookie(raw, requestUrl) {
|
||||
if (!raw) return null;
|
||||
const parts = raw.split(";");
|
||||
const nv = /^([^=]+)=([\s\S]*)$/.exec(parts[0].trim());
|
||||
if (!nv) return null;
|
||||
const name = nv[1].trim();
|
||||
const value = (nv[2] || "").trim();
|
||||
const url = new URL(requestUrl);
|
||||
const cookie = {
|
||||
name,
|
||||
value,
|
||||
domain: url.hostname,
|
||||
path: (url.pathname || "/").replace(/[^/]*$/, "") || "/",
|
||||
};
|
||||
let expired = value === "EXPIRED";
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const f = parts[i].trim();
|
||||
const eq = f.indexOf("=");
|
||||
const k = (eq >= 0 ? f.slice(0, eq) : f).trim().toLowerCase();
|
||||
const v = eq >= 0 ? f.slice(eq + 1).trim() : "";
|
||||
if (k === "domain" && v) cookie.domain = v.replace(/^\./, "");
|
||||
else if (k === "path" && v) cookie.path = v;
|
||||
else if (k === "expires") {
|
||||
if (/expired/i.test(v)) expired = true;
|
||||
else {
|
||||
const t = Date.parse(v);
|
||||
if (!Number.isNaN(t) && t <= Date.now()) expired = true;
|
||||
}
|
||||
} else if (k === "max-age" && Number(v) <= 0) expired = true;
|
||||
}
|
||||
return { cookie, expired };
|
||||
}
|
||||
|
||||
export function absorbSetCookies(sess, res, requestUrl) {
|
||||
for (const raw of res.headers.getSetCookie?.() || []) {
|
||||
const parsed = parseSetCookie(raw, requestUrl);
|
||||
if (!parsed) continue;
|
||||
const key = jarKey(parsed.cookie);
|
||||
if (parsed.expired || !parsed.cookie.value) sess.jar.delete(key);
|
||||
else sess.jar.set(key, parsed.cookie);
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeaderFor(sess, targetUrl) {
|
||||
const u = new URL(targetUrl);
|
||||
const out = [];
|
||||
for (const c of sess.jar.values()) {
|
||||
if (!domainMatch(c.domain, u.hostname)) continue;
|
||||
if (!(u.pathname || "/").startsWith(c.path)) continue;
|
||||
out.push(`${c.name}=${c.value}`);
|
||||
}
|
||||
return out.join("; ");
|
||||
}
|
||||
|
||||
/** Extract identity cookies from the account host jar. */
|
||||
export function readSessionIdentity(sess) {
|
||||
const pick = (name) => {
|
||||
for (const c of sess.jar.values()) {
|
||||
if (c.name === name && domainMatch(c.domain, ACCOUNT_HOST)) return c.value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const passToken = pick("passToken");
|
||||
if (!passToken || passToken === "EXPIRED") return null;
|
||||
return { passToken, userId: pick("userId"), cUserId: pick("cUserId") };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem the captured passToken for a mimo-server service session using the
|
||||
* BATTLE-TESTED desktop handshake (serviceLogin sid=mimopc + clientSign) that
|
||||
* powers every existing CN desktop connection — instead of the interactive
|
||||
* /api/sts webview callback, which rejects server-side calls (401).
|
||||
* Merges the resulting serviceCookie into sess.jar, then confirms via me.
|
||||
* @returns {Promise<boolean>} true when the me probe answers 200 (logged in).
|
||||
*/
|
||||
export async function ensureServiceSession(sess) {
|
||||
const id = readSessionIdentity(sess);
|
||||
if (!id) return false;
|
||||
const T = () => new Date().toISOString().slice(11, 23);
|
||||
const log = (m) => console.log(`${T()} [mimo-login][exchange] ${m}`);
|
||||
// Backoff: a failed full 5-step chain must not re-run on every 2.5s poll.
|
||||
const bkKey = id.passToken.slice(0, 24);
|
||||
const lastFail = _exchangeBackoff.get(bkKey);
|
||||
if (lastFail && Date.now() - lastFail < 60_000) {
|
||||
log("exchange in backoff (60s), skip");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const mod = await import("../../open-sse/shared/mimoAccount.js");
|
||||
const proxyOptions = sess.proxyUrl ? { enabled: true, url: sess.proxyUrl } : null;
|
||||
log(`exchanging passToken (region=${sess.region}, egress=${sess.proxyUrl || "direct"}) ...`);
|
||||
const serviceCookie = await mod.getMimoAccountCookie(
|
||||
{
|
||||
region: sess.region,
|
||||
mimoPassToken: id.passToken,
|
||||
mimoUserId: id.userId,
|
||||
mimoCUserId: id.cUserId,
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
if (!serviceCookie) {
|
||||
log("exchange failed: no service cookie");
|
||||
_exchangeBackoff.set(bkKey, Date.now());
|
||||
return false;
|
||||
}
|
||||
// Flatten "a=b; c=d" into the jar under the mimo-server host.
|
||||
_exchangeBackoff.delete(bkKey);
|
||||
const host = new URL(sess.upstreamBase).hostname;
|
||||
for (const pair of String(serviceCookie).split(";")) {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
const value = pair.slice(eq + 1).trim();
|
||||
if (!name || !value) continue;
|
||||
sess.jar.set(`${name}|${host}|/`, { name, value, domain: host, path: "/" });
|
||||
}
|
||||
log(`serviceCookie merged, jar=[${[...sess.jar.keys()].map((k) => k.split("|")[0]).join(",").slice(0, 160)}]`);
|
||||
|
||||
const meUrl = `${sess.upstreamBase}/api/user/xiaomi/me`;
|
||||
const res = await fetchUpstream(
|
||||
sess,
|
||||
meUrl,
|
||||
{ method: "GET", headers: { "User-Agent": API_UA } },
|
||||
cookieHeaderFor(sess, meUrl),
|
||||
);
|
||||
absorbSetCookies(sess, res, meUrl);
|
||||
log(`me confirm http=${res.status}`);
|
||||
return res.status === 200;
|
||||
} catch (e) {
|
||||
log(`exchange error: ${e?.message || e}`);
|
||||
_exchangeBackoff.set(bkKey, Date.now());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- URL rewriting (mimo-server base <-> /__mimo_login/mimo) ----------
|
||||
|
||||
function encodingVariants(s) {
|
||||
// The callback/followup params appear raw, url-encoded once, twice...
|
||||
const out = [s];
|
||||
let cur = s;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
cur = encodeURIComponent(cur);
|
||||
out.push(cur);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite mimo-server base URLs (any encoding depth) in a string.
|
||||
* direction "toProxy": mimo-base -> `${origin}/__mimo_login/mimo`
|
||||
* direction "toUpstream": reverse.
|
||||
*/
|
||||
export function rewriteMimoBases(text, direction, origin, upstreamBase = null) {
|
||||
if (!text) return text;
|
||||
let out = String(text);
|
||||
const proxyBase = `${origin}/__mimo_login/mimo`;
|
||||
const bases = [...new Set(Object.values(MIMO_BASES))];
|
||||
const fromBases = direction === "toProxy" ? bases : [proxyBase];
|
||||
const toBase = direction === "toProxy" ? proxyBase : (upstreamBase || bases[0]);
|
||||
for (const fromBase of fromBases) {
|
||||
const variants = [
|
||||
fromBase,
|
||||
fromBase.replace("https://", "http://"),
|
||||
fromBase.replace(/^https?:/, ""),
|
||||
fromBase.replaceAll("/", "\\/"),
|
||||
fromBase.replace("https://", "http://").replaceAll("/", "\\/"),
|
||||
]; // + protocol-relative + json escaped slashes
|
||||
const toVariants = [
|
||||
toBase,
|
||||
toBase.replace("https://", "http://"),
|
||||
toBase,
|
||||
toBase.replaceAll("/", "\\/"),
|
||||
toBase.replaceAll("/", "\\/"),
|
||||
];
|
||||
for (let vIdx = 0; vIdx < variants.length; vIdx++) {
|
||||
const fromList = encodingVariants(variants[vIdx]);
|
||||
const toList = encodingVariants(toVariants[vIdx]);
|
||||
for (let i = 0; i < fromList.length; i++) {
|
||||
out = out.split(fromList[i]).join(toList[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// account.xiaomi.com absolute URLs: keep navigation (e.g. identity/authStart
|
||||
// 2FA prompts) on our origin — every path on that host is already proxied
|
||||
// natively. Reverse applies to request URLs/bodies before hitting upstream.
|
||||
const acctOrigins = ["https://account.xiaomi.com", "http://account.xiaomi.com", "//account.xiaomi.com"];
|
||||
if (direction === "toProxy") {
|
||||
const toL = encodingVariants(origin);
|
||||
for (const a of acctOrigins) {
|
||||
const fromL = encodingVariants(a);
|
||||
for (let i = 0; i < fromL.length; i++) out = out.split(fromL[i]).join(toL[i]);
|
||||
}
|
||||
} else {
|
||||
const toA = encodingVariants("https://account.xiaomi.com");
|
||||
const toAEscaped = encodingVariants("https:\\/\\/account.xiaomi.com");
|
||||
const fromL = encodingVariants(origin);
|
||||
const fromLProto = encodingVariants(origin.replace(/^https?:/, ""));
|
||||
const fromLEscaped = encodingVariants(origin.replaceAll("/", "\\/"));
|
||||
for (let i = 0; i < fromL.length; i++) {
|
||||
out = out.split(fromL[i]).join(toA[i]);
|
||||
out = out.split(fromLProto[i]).join(toA[i]);
|
||||
out = out.split(fromLEscaped[i]).join(toAEscaped[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reverse the proxy rewrite in an incoming URL before hitting upstream. */
|
||||
export function deRewriteUrl(rawUrl, origin, upstreamBase = null) {
|
||||
return rewriteMimoBases(rawUrl, "toUpstream", origin, upstreamBase);
|
||||
}
|
||||
|
||||
export function isAccountProxyPath(pathname) {
|
||||
// Inverted: proxy EVERYTHING except the app's own paths (only consulted
|
||||
// while a login session cookie/param is present).
|
||||
return !APP_PATH_MATCHES(pathname);
|
||||
}
|
||||
|
||||
export function isMimoTakeoverPath(pathname) {
|
||||
return pathname.startsWith("/__mimo_login/mimo/");
|
||||
}
|
||||
|
||||
/** Map /__mimo_login/mimo/* back to the upstream mimo-server path. */
|
||||
export function takeoverUpstreamPath(pathname) {
|
||||
return pathname.slice("/__mimo_login/mimo".length) || "/";
|
||||
}
|
||||
|
||||
// ---------- upstream fetch with optional egress proxy ----------
|
||||
//
|
||||
// The login page's feature set (Google sign-in etc.) is geo-decided by the
|
||||
// egress IP of THESE requests. The proxy applies ONLY when the user picked
|
||||
// region=sgp — the start route resolves it (via local-port probe) and rides it
|
||||
// on the session cookie as sess.proxyUrl. CN sessions never proxy (null -> direct).
|
||||
|
||||
let _pafPromise = null;
|
||||
let _socksPromise = null;
|
||||
|
||||
/** fetch-compatible wrapper over socks-proxy-agent (undici ProxyAgent has no socks support). */
|
||||
async function socksFetch(url, init, proxyUrl) {
|
||||
if (!_socksPromise) {
|
||||
_socksPromise = import("socks-proxy-agent")
|
||||
.then((m) => m.SocksProxyAgent || m.default?.SocksProxyAgent || m.default)
|
||||
.catch((e) => {
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] socks-proxy-agent unavailable:`, e?.message || e);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
const SocksProxyAgent = await _socksPromise;
|
||||
if (!SocksProxyAgent) throw new Error("socks agent unavailable");
|
||||
|
||||
const nodeUrl = new URL(url);
|
||||
// Literal specifiers on both branches — webpack forbids fully dynamic import().
|
||||
const protoMod = nodeUrl.protocol === "http:" ? await import("node:http") : await import("node:https");
|
||||
const lib = protoMod.default ?? protoMod;
|
||||
const { Readable } = await import("node:stream");
|
||||
|
||||
let headers = {};
|
||||
const raw = init?.headers;
|
||||
if (raw instanceof Headers) for (const [k, v] of raw) headers[k] = v;
|
||||
else if (raw) headers = { ...raw };
|
||||
|
||||
let body = init?.body;
|
||||
if (body && typeof body !== "string" && !Buffer.isBuffer(body)) body = Buffer.from(body);
|
||||
if (body) headers["content-length"] = String(Buffer.byteLength(body));
|
||||
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = lib.request(
|
||||
nodeUrl,
|
||||
{ method: init?.method || "GET", agent, headers, timeout: 20000 },
|
||||
(res) => {
|
||||
const outHeaders = new Headers();
|
||||
for (const [k, v] of Object.entries(res.headers || {})) {
|
||||
if (Array.isArray(v)) v.forEach((x) => outHeaders.append(k, String(x)));
|
||||
else if (v != null) outHeaders.set(k, String(v));
|
||||
}
|
||||
resolve(new Response(Readable.toWeb(res), { status: res.statusCode || 200, headers: outHeaders }));
|
||||
},
|
||||
);
|
||||
const signal = init?.signal;
|
||||
if (signal) {
|
||||
if (signal.aborted) req.destroy(new Error("aborted"));
|
||||
else signal.addEventListener("abort", () => req.destroy(new Error("aborted")), { once: true });
|
||||
}
|
||||
req.on("timeout", () => req.destroy(new Error("socks fetch timeout")));
|
||||
req.on("error", reject);
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function loginFetch(url, init, sessionProxyUrl = null) {
|
||||
if (!sessionProxyUrl) return fetch(url, init); // direct — no agent machinery needed
|
||||
const proxyOptions = { enabled: true, url: sessionProxyUrl };
|
||||
try {
|
||||
if (/^socks/i.test(sessionProxyUrl)) return await socksFetch(url, init, sessionProxyUrl);
|
||||
if (!_pafPromise) {
|
||||
_pafPromise = import("../../open-sse/utils/proxyFetch.js")
|
||||
.then((m) => m.proxyAwareFetch)
|
||||
.catch((e) => {
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxyAwareFetch unavailable (runtime?), direct only:`, e?.message || e);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
const paf = await _pafPromise;
|
||||
if (paf) return await paf(url, init, proxyOptions);
|
||||
} catch (e) {
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxied fetch failed, falling back to direct:`, e?.message || e);
|
||||
}
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
/** Public alias — start/status routes share the same egress path. */
|
||||
export const loginUpstreamFetch = (url, init, sess = null) => loginFetch(url, init, sess?.proxyUrl || null);
|
||||
|
||||
// ---------- upstream proxying ----------
|
||||
|
||||
async function fetchUpstream(sess, url, init, cookieValue) {
|
||||
const headers = new Headers(init.headers);
|
||||
if (cookieValue) headers.set("Cookie", cookieValue);
|
||||
return loginFetch(url, { ...init, headers, redirect: "manual", signal: AbortSignal.timeout(20000) }, sess?.proxyUrl || null);
|
||||
}
|
||||
|
||||
function browserCookieHeader(req) {
|
||||
return req.headers.get("cookie") || "";
|
||||
}
|
||||
|
||||
// Headers never forwarded to the upstream account host.
|
||||
const STRIP_UPSTREAM_HEADERS = new Set([
|
||||
"host", "cookie", "connection", "content-length", "transfer-encoding",
|
||||
"keep-alive", "upgrade", "expect", "proxy-connection",
|
||||
// Credentials for 9router itself — must never reach a third-party upstream.
|
||||
"authorization", "proxy-authorization",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Proxy one account.xiaomi.com request from the browser.
|
||||
* Captures Set-Cookie into the server jar, strips frame-blocking headers,
|
||||
* rewrites Location/body mimo-base references back into the proxy space.
|
||||
*/
|
||||
export async function proxyAccountRequest(sess, req, origin) {
|
||||
const u = new URL(req.url);
|
||||
u.searchParams.delete("__9r_sess"); // never forward our session to upstream
|
||||
const upstream = new URL(u.pathname + u.search, `https://${ACCOUNT_HOST}`);
|
||||
|
||||
// The SPA's callback params were rewritten to our proxy — undo before upstream
|
||||
// so signature (_sign) validation on the real callback still passes.
|
||||
const upstreamStr = deRewriteUrl(upstream.toString(), origin, sess.upstreamBase);
|
||||
|
||||
const headers = {};
|
||||
// Forward EVERYTHING the browser sent (minus hop-by-hop + host/cookie) so no
|
||||
// custom SDK header gets silently dropped. Then fix up cross-origin fields:
|
||||
// the SPA talks to account.xiaomi.com, so Origin/Referer must be rewritten
|
||||
// from our origin to the account origin — keeping the original path/query
|
||||
// (a wrong Referer path trips Xiaomi's login risk control, error 10025).
|
||||
for (const [k, v] of req.headers) {
|
||||
if (STRIP_UPSTREAM_HEADERS.has(k.toLowerCase())) continue;
|
||||
headers[k] = v;
|
||||
}
|
||||
const ourOrigin = origin;
|
||||
const fixOriginUrl = (val) => {
|
||||
if (!val) return null;
|
||||
try {
|
||||
const u = new URL(val);
|
||||
if (u.origin === ourOrigin) {
|
||||
u.protocol = "https:";
|
||||
u.host = ACCOUNT_HOST;
|
||||
return u.toString();
|
||||
}
|
||||
if (u.hostname === ACCOUNT_HOST) return u.toString();
|
||||
return null; // some other origin — let our forced account values win
|
||||
} catch { return null; }
|
||||
};
|
||||
const rawOrigin = headers.origin || headers.Origin || null;
|
||||
delete headers.origin;
|
||||
delete headers.Origin;
|
||||
// Real browsers only send Origin on XHR POSTs — preserve that shape, but
|
||||
// point it at the account host (never leak localhost upstream).
|
||||
if (rawOrigin) headers.Origin = `https://${ACCOUNT_HOST}`;
|
||||
const fixedReferer = fixOriginUrl(headers.referer || headers.Referer);
|
||||
headers.Referer = fixedReferer
|
||||
? deRewriteUrl(fixedReferer, origin, sess.upstreamBase)
|
||||
: `https://${ACCOUNT_HOST}/fe/service/login`;
|
||||
delete headers.referer;
|
||||
|
||||
const init = { method: req.method || "GET", headers };
|
||||
if (init.method !== "GET" && init.method !== "HEAD") {
|
||||
let buf = Buffer.from(await req.arrayBuffer());
|
||||
// The SPA reads callback params from location.search (which we rewrote to
|
||||
// our origin) and can echo them in the POST BODY — reverse that too, or
|
||||
// Xiaomi rejects with 10025 "Callback连接不合法".
|
||||
const ctBody = String(headers["Content-Type"] || headers["content-type"] || "");
|
||||
if (buf.length && /urlencoded|json|text/i.test(ctBody)) {
|
||||
const before = buf.toString("utf8");
|
||||
const after = rewriteMimoBases(before, "toUpstream", origin, sess.upstreamBase);
|
||||
if (after !== before) {
|
||||
buf = Buffer.from(after, "utf8");
|
||||
headers["Content-Length"] = String(buf.length);
|
||||
} else if (/__mimo_login|localhost/.test(before)) {
|
||||
// Anomaly: a local callback shape we cannot rewrite — must never reach Xiaomi.
|
||||
console.log(`${new Date().toISOString().slice(11, 23)} [mimo-login] body STILL local, not matchable: ${before.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
init.body = buf;
|
||||
}
|
||||
|
||||
// Follow same-host redirects server-side (they carry Set-Cookie we must keep).
|
||||
let current = upstreamStr;
|
||||
let res = null;
|
||||
const requestCookies = cookieHeaderFor(sess, current) || browserCookieHeader(req);
|
||||
for (let hop = 0; hop < 8; hop++) {
|
||||
res = await fetchUpstream(sess, current, hop === 0 ? init : { ...init, body: undefined }, requestCookies);
|
||||
absorbSetCookies(sess, res, current);
|
||||
const loc = res.headers.get("location");
|
||||
if (res.status >= 300 && res.status < 400 && loc) {
|
||||
const nextAbs = new URL(loc, current);
|
||||
if (nextAbs.hostname === ACCOUNT_HOST) {
|
||||
current = nextAbs.toString();
|
||||
continue;
|
||||
}
|
||||
// Cross-host redirect to mimo-server: rewrite into our takeover space so
|
||||
// the browser stays on our origin.
|
||||
if (/mimo-server-(cn|sgp)\.xiaomimimo\.com/.test(nextAbs.hostname)) {
|
||||
const proxiedLoc = rewriteMimoBases(nextAbs.toString(), "toProxy", origin);
|
||||
return new Response(null, { status: res.status, headers: { Location: proxiedLoc, "Cache-Control": "no-store" } });
|
||||
}
|
||||
// Any other host: pass through.
|
||||
return new Response(null, { status: res.status, headers: { Location: loc } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return buildBrowserResponse(sess, res, origin, u.pathname, current);
|
||||
}
|
||||
|
||||
function buildBrowserResponse(sess, res, origin, reqPath = "", upstreamUrl = "") {
|
||||
const outHeaders = new Headers();
|
||||
const pass = ["content-type", "cache-control", "etag", "last-modified", "date"];
|
||||
for (const h of pass) {
|
||||
const v = res.headers.get(h);
|
||||
if (v) outHeaders.set(h, v);
|
||||
}
|
||||
// Allow embedding in our modal (upstream often sends X-Frame-Options).
|
||||
outHeaders.delete("x-frame-options");
|
||||
outHeaders.delete("content-security-policy");
|
||||
outHeaders.delete("content-security-policy-report-only");
|
||||
outHeaders.set("Cache-Control", "no-store");
|
||||
// Deliberately NOT forwarding upstream Set-Cookie to the browser: every
|
||||
// proxied request already strips the browser Cookie header, so upstream
|
||||
// auth lives only in the server-side jar. Replayed cookies would land on
|
||||
// our origin's jar (some without HttpOnly → readable by any script here).
|
||||
|
||||
const ctType = res.headers.get("content-type") || "";
|
||||
const isText = /text\/|javascript|json/.test(ctType);
|
||||
if (!isText) return new Response(res.body, { status: res.status, headers: outHeaders });
|
||||
|
||||
return res.text().then((rawBody) => {
|
||||
// JSON allows \/ as an escaped slash — Xiaomi backends (PHP-style) emit
|
||||
// "https:\/\/mimo-server..." which defeats scheme-based string matching
|
||||
// AND the SPA JSON.parses it back to a real URL (this leaked the sts
|
||||
// callback straight to the browser -> cross-origin 401). Unescaping \/ to
|
||||
// / inside JSON string values is semantics-preserving and stays valid.
|
||||
const body = /json/.test(ctType) ? rawBody.replaceAll("\\/", "/") : rawBody;
|
||||
// Surface upstream API errors (Xiaomi wraps JSON as &&&START&&&{code:...}).
|
||||
if (/^\/(pass|sts)/.test(reqPath) || res.status >= 400) {
|
||||
const m = body.match(/"code"\s*:\s*(-?\d+)/);
|
||||
if ((m && m[1] !== "0") || res.status >= 400) {
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] upstream ${reqPath} http=${res.status} code=${m ? m[1] : "?"} | url=${upstreamUrl.slice(0, 180)} | ${body.replace(/\s+/g, " ").slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
const rewritten = rewriteMimoBases(body, "toProxy", origin);
|
||||
return new Response(rewritten, { status: res.status, headers: outHeaders });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Take over the mimo-server tail of the flow (sts -> me) server-side.
|
||||
* Runs the whole redirect chain, absorbs cookies, verifies me=logged-in.
|
||||
*/
|
||||
export async function runTakeover(sess, upstreamUrl, origin) {
|
||||
const T = () => new Date().toISOString().slice(11, 23);
|
||||
const log = (m) => console.log(`${T()} [mimo-login][takeover] ${m}`);
|
||||
const idSnap = () => {
|
||||
const id = readSessionIdentity(sess);
|
||||
const names = [...sess.jar.keys()].map((k) => k.split("|")[0]).join(",");
|
||||
return `passToken=${id ? "Y" : "N"} jar=[${names.slice(0, 160)}]`;
|
||||
};
|
||||
log(`sts-nav url=${upstreamUrl.slice(0, 160)} origin=${origin} | ${idSnap()}`);
|
||||
const id = readSessionIdentity(sess);
|
||||
// AUTHORIZATION COMPLETION = passToken captured (that IS the credential the
|
||||
// connection persists for the account route). The serviceToken exchange
|
||||
// (weekly quota) is intentionally NOT part of completion — its API moved;
|
||||
// ensureServiceSession stays exported for when that gets re-wired.
|
||||
if (id) {
|
||||
sess.status = "done";
|
||||
return donePage();
|
||||
}
|
||||
return pendingPage();
|
||||
}
|
||||
|
||||
function htmlPage(title, lines, autoClose = false) {
|
||||
const body = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title>
|
||||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#111;color:#eee}
|
||||
.card{padding:2rem 2.5rem;border-radius:12px;background:#1b1b1f;text-align:center;max-width:420px}
|
||||
h1{font-size:1.1rem;margin:.2rem 0 .6rem}p{opacity:.8;font-size:.9rem;line-height:1.6}</style></head>
|
||||
<body><div class="card"><h1>${title}</h1>${lines.map((l) => `<p>${l}</p>`).join("")}</div>
|
||||
<script>setTimeout(function(){try{window.opener&&window.opener.postMessage({mimoSession:"ok"},location.origin)}catch(e){}},300);${autoClose ? "setTimeout(function(){try{window.close()}catch(e){}},1600);" : ""}</script>
|
||||
</body></html>`;
|
||||
return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
||||
}
|
||||
|
||||
function donePage() {
|
||||
return htmlPage("登录成功 ✅", ["账号会话已捕获,可以关闭此窗口。", "回到 9router 弹窗继续。"], true);
|
||||
}
|
||||
|
||||
function pendingPage() {
|
||||
return htmlPage("登录未完成", ["未检测到有效会话,请重试。"]);
|
||||
}
|
||||
|
||||
export const __test__ = { STRIP_UPSTREAM_HEADERS, buildBrowserResponse };
|
||||
+65
-1
@@ -1,6 +1,70 @@
|
||||
import { proxy as dashboardProxy } from "./dashboardGuard";
|
||||
import { proxy as dashboardProxy, isAuthenticated } from "./dashboardGuard";
|
||||
import {
|
||||
sessionFromRequest,
|
||||
isAccountProxyPath,
|
||||
isMimoTakeoverPath,
|
||||
takeoverUpstreamPath,
|
||||
proxyAccountRequest,
|
||||
runTakeover,
|
||||
attachSessionCookie,
|
||||
originOf,
|
||||
} from "./lib/mimoLoginSession";
|
||||
|
||||
export default async function proxy(request) {
|
||||
// Xiaomi account session-login proxy (src/lib/mimoLoginSession.js).
|
||||
// Session state (region + accumulated cookie jar) travels in the httpOnly
|
||||
// 9r_mimo_login cookie — route handlers and this proxy run in separate
|
||||
// bundles, so module-level maps are NOT shared. The cookie is only set by
|
||||
// the auth-gated login/start route, and the branch below ALSO requires a
|
||||
// valid dashboard session: a forged 9r_mimo_login cookie (client-controlled
|
||||
// header, unsigned payload) must never turn the app into an unauthenticated
|
||||
// forwarder. No URL-carried session — it would leak the jar via history/logs/Referer.
|
||||
const cookies = request.headers.get("cookie") || "";
|
||||
const hasSessionCookie = cookies.includes("9r_mimo_login=");
|
||||
const { pathname } = request.nextUrl;
|
||||
if (hasSessionCookie && !(await isAuthenticated(request))) {
|
||||
// Forged or stale session cookie without dashboard auth — drop it early.
|
||||
const res = await dashboardProxy(request);
|
||||
const headers = new Headers(res.headers);
|
||||
headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
||||
}
|
||||
if (!hasSessionCookie && /^\/(fe\/|pass)/.test(pathname) && !pathname.startsWith("/_next")) {
|
||||
// Anomaly: a login-flow XHR arrived without the session — the classic
|
||||
// cause of silent SPA "Something went wrong" 404s. Narrow to login paths
|
||||
// so unrelated unknown routes don't spam this.
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] no-session ${pathname} (cookie header: ${cookies ? cookies.slice(0, 80) : "<none>"})`);
|
||||
}
|
||||
if (hasSessionCookie) {
|
||||
const sess = sessionFromRequest(request);
|
||||
if (sess) {
|
||||
const origin = originOf(request);
|
||||
try {
|
||||
if (isMimoTakeoverPath(pathname)) {
|
||||
const upstreamUrl = `${sess.upstreamBase}${takeoverUpstreamPath(pathname)}${request.nextUrl.search || ""}`;
|
||||
return attachSessionCookie(await runTakeover(sess, upstreamUrl, origin), sess);
|
||||
}
|
||||
if (isAccountProxyPath(pathname)) {
|
||||
return attachSessionCookie(await proxyAccountRequest(sess, request, origin), sess);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxy error:`, e?.message || e);
|
||||
return new Response("mimo login proxy error", { status: 502 });
|
||||
}
|
||||
} else {
|
||||
// Anomaly (should not happen in a healthy flow): cookie present but unparseable.
|
||||
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] session cookie undecodable — falling through (${pathname})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cookie present but expired/invalid — clear it on the way past.
|
||||
if (hasSessionCookie) {
|
||||
const res = await dashboardProxy(request);
|
||||
const headers = new Headers(res.headers);
|
||||
headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
|
||||
}
|
||||
|
||||
return dashboardProxy(request);
|
||||
}
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function Sidebar({ onClose }) {
|
||||
<span className="material-symbols-outlined text-[16px]">{kind.icon}</span>
|
||||
<span className="text-sm">{kind.label}</span>
|
||||
{kind.isNew && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-[3px] bg-green-500/15 text-green-400">NEW</span>
|
||||
<span className="ml-auto text-[10px] font-semibold px-1.5 py-0.5 rounded-[3px] bg-green-500/15 text-green-400">NEW</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
@@ -309,8 +309,8 @@ export default function Sidebar({ onClose }) {
|
||||
computer
|
||||
</span>
|
||||
<span className="text-[13px] font-medium">9Remote</span>
|
||||
<span className="ml-auto rounded-full bg-primary px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">
|
||||
New
|
||||
<span className="ml-auto text-[10px] font-semibold px-1.5 py-0.5 rounded-[3px] bg-green-500/15 text-green-400">
|
||||
NEW
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ const PERIODS = [
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "60d", label: "60D" },
|
||||
{ value: "all", label: "All" },
|
||||
];
|
||||
|
||||
export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) {
|
||||
@@ -448,7 +449,7 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro
|
||||
{/* Period selector (hidden when controlled by parent) */}
|
||||
{!hidePeriodSelector && (
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:self-end">
|
||||
<div className="grid flex-1 grid-cols-5 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex sm:flex-none">
|
||||
<div className="grid flex-1 grid-cols-6 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex sm:flex-none">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
|
||||
@@ -1,60 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Modal, Button } from "@/shared/components";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
|
||||
/**
|
||||
* Xiaomi MiMo Auth Modal
|
||||
* Xiaomi MiMo Desktop Plan auth modal.
|
||||
*
|
||||
* Auto-imports credentials from the local Xiaomi MiMo Desktop auth.json (~/.local/share/mimocode/auth.json).
|
||||
* If auto-import fails, offers a one-click browser OAuth fallback.
|
||||
* Reached only via the "Connect with OAuth" button — the API-key path uses the
|
||||
* standard Add API Key modal, since Xiaomi MiMo supports both auth modes.
|
||||
* Desktop Plan = Xiaomi account weekly quota (mimo-v2.6 family). Two ways in:
|
||||
* 1. Import local MiMo Desktop credentials (~/.local/share/mimocode/auth.json)
|
||||
* 2. Server-side login — no Desktop needed; picks a MiMo account cluster
|
||||
* (cn / sgp / ams / ru / in). The account session (passToken) is captured
|
||||
* server-side and stored per connection.
|
||||
*
|
||||
* Token Plan (cloud sk- API key) uses the standard "API Key" entry point.
|
||||
*/
|
||||
export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
||||
const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing | error
|
||||
const [detectResult, setDetectResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [oauthUrl, setOauthUrl] = useState(null);
|
||||
const [oauthState, setOauthState] = useState(null);
|
||||
|
||||
// Auto-detect local credentials when modal opens
|
||||
const CLUSTERS = [
|
||||
{ id: "cn", flag: "🇨🇳", name: "China (Mainland)", host: "mimo-server-cn" },
|
||||
{ id: "sgp", flag: "🇸🇬", name: "Singapore", host: "mimo-server-sgp" },
|
||||
{ id: "ams", flag: "🇪🇺", name: "Europe · Amsterdam", host: "mimo-server-ams" },
|
||||
{ id: "ru", flag: "🇷🇺", name: "Russia", host: "mimo-server-ru" },
|
||||
{ id: "in", flag: "🇮🇳", name: "India", host: "mimo-server-in" },
|
||||
];
|
||||
|
||||
export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
||||
const [phase, setPhase] = useState("detecting"); // detecting | found | not-found | importing
|
||||
const [detectResult, setDetectResult] = useState(null);
|
||||
const [existingConnection, setExistingConnection] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Server-side session login
|
||||
const [showClusterModal, setShowClusterModal] = useState(false);
|
||||
const [sessBusy, setSessBusy] = useState(false);
|
||||
const [sessPolling, setSessPolling] = useState(false);
|
||||
const [sessError, setSessError] = useState(null);
|
||||
const [sessPageUrl, setSessPageUrl] = useState(null);
|
||||
const [sessRegion, setSessRegion] = useState("cn");
|
||||
const sessTimerRef = useRef(null);
|
||||
|
||||
const stopSessionPoll = () => {
|
||||
if (sessTimerRef.current) {
|
||||
clearInterval(sessTimerRef.current);
|
||||
sessTimerRef.current = null;
|
||||
}
|
||||
setSessPolling(false);
|
||||
};
|
||||
|
||||
useEffect(() => () => stopSessionPoll(), []);
|
||||
|
||||
// Detect local credentials when the modal opens (non-blocking: never trap the spinner)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setPhase("detecting");
|
||||
setError(null);
|
||||
setDetectResult(null);
|
||||
setOauthUrl(null);
|
||||
setPhase("detecting");
|
||||
setError(null);
|
||||
setDetectResult(null);
|
||||
setExistingConnection(null);
|
||||
setShowClusterModal(false);
|
||||
setSessError(null);
|
||||
|
||||
const runDetect = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/oauth/xiaomi-mimo/auto-import");
|
||||
const res = await fetch("/api/oauth/xiaomi-mimo/auto-import", {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
|
||||
if (data.found && data.apiKey) {
|
||||
setDetectResult(data);
|
||||
setPhase("found");
|
||||
|
||||
// Non-blocking: flag an already-imported account
|
||||
fetch("/api/providers", { signal: AbortSignal.timeout(3000) })
|
||||
.then((r) => r.json())
|
||||
.then((provData) => {
|
||||
const foundConn = (provData.connections || []).find(
|
||||
(c) =>
|
||||
c.provider === "xiaomi-mimo" &&
|
||||
data.uid &&
|
||||
(c.email === `${data.uid}@xiaomi` ||
|
||||
c.providerSpecificData?.uid === data.uid ||
|
||||
c.providerSpecificData?.mimoUserId === data.uid),
|
||||
);
|
||||
if (foundConn) setExistingConnection(foundConn);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
setPhase("not-found");
|
||||
setError(data.error || "Xiaomi MiMo Desktop credentials not found on this machine.");
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPhase("not-found");
|
||||
setError("Failed to read local Xiaomi MiMo Desktop credentials.");
|
||||
}
|
||||
setPhase("not-found");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return () => { cancelled = true; };
|
||||
runDetect();
|
||||
}, [isOpen]);
|
||||
|
||||
// Import the auto-detected key
|
||||
const handleImport = async () => {
|
||||
// Import the auto-detected local desktop credentials
|
||||
const handleImportLocal = async () => {
|
||||
if (!detectResult?.apiKey) return;
|
||||
setPhase("importing");
|
||||
setError(null);
|
||||
@@ -73,11 +118,7 @@ export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.error || "Import failed");
|
||||
}
|
||||
|
||||
if (!res.ok || !data.success) throw new Error(data.error || "Import failed");
|
||||
onSuccess?.(data.connection);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -86,183 +127,265 @@ export default function XiaomiMimoAuthModal({ isOpen, onSuccess, onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Start browser OAuth fallback
|
||||
const handleStartOAuth = async () => {
|
||||
setError(null);
|
||||
// Server-side login (account.xiaomi.com) for the chosen cluster
|
||||
const startSessionLogin = async (region) => {
|
||||
setSessBusy(true);
|
||||
setSessError(null);
|
||||
setShowClusterModal(false);
|
||||
setSessRegion(region);
|
||||
try {
|
||||
const state = crypto.randomUUID();
|
||||
const res = await fetch(`/api/oauth/xiaomi-mimo/authorize?state=${state}`);
|
||||
const res = await fetch("/api/oauth/xiaomi-mimo/login/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ region }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.authorizeUrl) {
|
||||
setOauthUrl(data.authorizeUrl);
|
||||
setOauthState(data.state);
|
||||
window.open(data.authorizeUrl, "_blank", "width=600,height=700");
|
||||
} else {
|
||||
throw new Error(data.error || "Failed to start OAuth");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll OAuth result
|
||||
const handlePollOAuth = async () => {
|
||||
if (!oauthState) return;
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/oauth/xiaomi-mimo/poll-status?state=${oauthState}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.status === "done" && data.result) {
|
||||
// Exchange to create the connection
|
||||
const exRes = await fetch("/api/oauth/xiaomi-mimo/exchange", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state: oauthState }),
|
||||
});
|
||||
const exData = await exRes.json();
|
||||
if (exData.success) {
|
||||
onSuccess?.(exData.connection);
|
||||
onClose();
|
||||
} else {
|
||||
throw new Error(exData.error || "Exchange failed");
|
||||
if (!res.ok || !data.pageUrl) throw new Error(data.error || "Failed to start login");
|
||||
setSessPageUrl(data.pageUrl);
|
||||
window.open(data.pageUrl, "mimo-session-login", "width=500,height=760");
|
||||
setSessPolling(true);
|
||||
const startedAt = Date.now();
|
||||
sessTimerRef.current = setInterval(async () => {
|
||||
if (Date.now() - startedAt > 14 * 60 * 1000) {
|
||||
stopSessionPoll();
|
||||
setSessError("Login timed out. Please retry.");
|
||||
return;
|
||||
}
|
||||
} else if (data.status === "error") {
|
||||
throw new Error(data.error || "OAuth failed");
|
||||
} else {
|
||||
setError("Authorization not completed yet. Finish in the browser, then click Check Again.");
|
||||
}
|
||||
try {
|
||||
const sres = await fetch(`/api/oauth/xiaomi-mimo/login/status?state=${data.state}`);
|
||||
const sd = await sres.json();
|
||||
if (sd.status === "pending") return;
|
||||
stopSessionPoll();
|
||||
if (sd.status !== "done") {
|
||||
setSessError(sd.error || "Login session expired — please retry.");
|
||||
return;
|
||||
}
|
||||
const save = await fetch("/api/oauth/xiaomi-mimo/api-key", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apiKey: "",
|
||||
uid: sd.userId || null,
|
||||
mimoPassToken: sd.passToken,
|
||||
mimoUserId: sd.userId || null,
|
||||
mimoCUserId: sd.cUserId || null,
|
||||
region: sd.region || sessRegion,
|
||||
}),
|
||||
});
|
||||
const saved = await save.json();
|
||||
if (!save.ok || !saved.success) throw new Error(saved.error || "Failed to save credentials");
|
||||
onSuccess?.(saved.connection);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
stopSessionPoll();
|
||||
setSessError(err.message);
|
||||
}
|
||||
}, 2500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setSessError(err.message);
|
||||
} finally {
|
||||
setSessBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// The server-login card, shown in both the found and not-found states
|
||||
const renderServerLogin = () => (
|
||||
<div className="bg-card p-3 rounded-lg border border-border flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-primary text-base">login</span>
|
||||
<span className="text-xs sm:text-sm font-semibold">Browser Login</span>
|
||||
<span className="text-[11px] text-text-muted">No Desktop required</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-text-muted">Weekly quota</span>
|
||||
</div>
|
||||
{sessPolling ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button variant="outline" size="sm" fullWidth disabled>
|
||||
Waiting for login...
|
||||
</Button>
|
||||
{sessPageUrl && (
|
||||
<Button
|
||||
onClick={() => window.open(sessPageUrl, "mimo-session-login", "width=500,height=760")}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
fullWidth
|
||||
>
|
||||
Reopen login window
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setShowClusterModal(true)} variant="outline" size="sm" fullWidth disabled={sessBusy}>
|
||||
{sessBusy ? "Starting..." : "Choose cluster & sign in"}
|
||||
</Button>
|
||||
)}
|
||||
{sessError && <p className="text-[11px] text-red-500">{translate(sessError)}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title="Connect Xiaomi MiMo" onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Modal isOpen={isOpen} title={translate("Connect Xiaomi MiMo")} onClose={onClose} size="lg">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{/* Detecting */}
|
||||
{phase === "detecting" && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
<div className="size-12 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-2xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Reading local credentials...</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Checking ~/.local/share/mimocode/auth.json
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">Reading local MiMo Desktop credentials...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Found — one-click import */}
|
||||
{phase === "found" && detectResult && (
|
||||
<>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex gap-2">
|
||||
<span className="material-symbols-outlined text-green-600 dark:text-green-400">check_circle</span>
|
||||
<div className="text-sm text-green-800 dark:text-green-200">
|
||||
<p className="font-medium">Xiaomi MiMo Desktop credentials found!</p>
|
||||
<p className="mt-1 opacity-80">
|
||||
UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleImport} fullWidth>
|
||||
Connect with Local Credentials
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Importing */}
|
||||
{phase === "importing" && (
|
||||
<div className="text-center py-6">
|
||||
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
|
||||
<div className="size-12 mx-auto mb-3 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-2xl text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Connecting...</h3>
|
||||
<p className="text-sm font-medium">Connecting...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Not found — offer OAuth fallback */}
|
||||
{/* Found local desktop credentials */}
|
||||
{phase === "found" && detectResult && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-muted px-0.5">
|
||||
<span className="material-symbols-outlined text-primary text-sm">desktop_windows</span>
|
||||
<span>Desktop Plan · Local credentials</span>
|
||||
</div>
|
||||
|
||||
{existingConnection ? (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex gap-2.5 items-start">
|
||||
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400 text-lg mt-0.5">
|
||||
check_circle
|
||||
</span>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium">This account is already connected (no need to import again)</p>
|
||||
<p className="text-xs mt-0.5 opacity-80">
|
||||
UID: {detectResult.uid || "—"} · Status: {existingConnection.testStatus === "active" ? "Active" : "Untested"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex gap-2.5 items-start">
|
||||
<span className="material-symbols-outlined text-green-600 dark:text-green-400 text-lg mt-0.5">
|
||||
check_circle
|
||||
</span>
|
||||
<div className="text-sm text-green-800 dark:text-green-200">
|
||||
<p className="font-medium">Xiaomi MiMo Desktop credentials found!</p>
|
||||
<p className="text-xs mt-0.5 opacity-80">
|
||||
UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 p-2.5 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{translate(error)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleImportLocal} variant={existingConnection ? "outline" : "default"} fullWidth>
|
||||
{existingConnection ? "Re-sync local credentials" : "Connect with local credentials"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{existingConnection ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative my-1">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs text-text-muted">
|
||||
<span className="bg-card px-2">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderServerLogin()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No local desktop credentials */}
|
||||
{phase === "not-found" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-2 items-start">
|
||||
<span className="material-symbols-outlined text-amber-600 dark:text-amber-400">info</span>
|
||||
<div className="flex gap-2.5 items-start">
|
||||
<span className="material-symbols-outlined text-amber-600 dark:text-amber-400 text-lg mt-0.5">info</span>
|
||||
<div className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<p className="font-medium">Local credentials not found</p>
|
||||
<p className="mt-1 opacity-80">{error}</p>
|
||||
<p className="mt-2 opacity-80">
|
||||
Make sure Xiaomi MiMo Desktop is installed and you are signed in, then retry.
|
||||
Or sign in via browser below.
|
||||
<p className="font-medium">No local Desktop credentials found</p>
|
||||
<p className="text-xs mt-0.5 opacity-80">
|
||||
You can still sign in via browser — no Desktop client needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!oauthUrl ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPhase("detecting");
|
||||
// Re-trigger detect
|
||||
fetch("/api/oauth/xiaomi-mimo/auto-import")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.found && data.apiKey) {
|
||||
setDetectResult(data);
|
||||
setPhase("found");
|
||||
} else {
|
||||
setPhase("not-found");
|
||||
setError(data.error || "Still not found.");
|
||||
}
|
||||
})
|
||||
.catch(() => setPhase("not-found"));
|
||||
}}
|
||||
variant="outline"
|
||||
fullWidth
|
||||
{renderServerLogin()}
|
||||
|
||||
<div className="mt-1">
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cluster selection sub-modal */}
|
||||
{showClusterModal && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
|
||||
<div className="relative w-full max-w-sm bg-surface border border-border-subtle rounded-[14px] shadow-2xl p-5 flex flex-col gap-3.5 animate-in zoom-in-95 duration-150">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-lg">public</span>
|
||||
<h3 className="text-sm font-semibold">Select account cluster</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClusterModal(false)}
|
||||
className="text-text-muted hover:text-text-primary p-1 rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
Retry Local Detect
|
||||
</Button>
|
||||
<Button onClick={handleStartOAuth} fullWidth>
|
||||
Sign in via Browser
|
||||
</Button>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
<p className="text-xs text-text-muted">Choose the region cluster of your Xiaomi account:</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Browser opened. Complete the Xiaomi sign-in, then click{" "}
|
||||
<strong>Check Again</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handlePollOAuth} fullWidth>
|
||||
Check Again
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{CLUSTERS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => startSessionLogin(c.id)}
|
||||
className="p-3 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-all text-left flex items-start gap-3 group cursor-pointer"
|
||||
>
|
||||
<span className="text-2xl mt-0.5">{c.flag}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold group-hover:text-primary transition-colors">
|
||||
{c.name}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{c.host}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<Button onClick={() => setShowClusterModal(false)} variant="ghost" size="sm" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// A refusal from the Anthropic API (stop_reason "refusal", zero output tokens, no
|
||||
// content blocks) must reach an OpenAI-format client as finish_reason
|
||||
// "content_filter" carrying Anthropic's explanation — not as a clean, empty "stop".
|
||||
// Captured live on 2026-09-20 against claude-opus-5 via a Claude Code OAuth
|
||||
// connection: 9Router logged "Model succeeded · OUT 0" and the client saw nothing.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { claudeToOpenAIResponse } from "../../open-sse/translator/response/claude-to-openai.js";
|
||||
|
||||
const EXPLANATION =
|
||||
"This request was blocked as it seems to violate Anthropic's Terms of Service restrictions on reverse engineering or duplicating model outputs.";
|
||||
|
||||
function runStream(events) {
|
||||
const state = {};
|
||||
const out = [];
|
||||
for (const ev of events) {
|
||||
const r = claudeToOpenAIResponse(ev, state);
|
||||
if (Array.isArray(r)) out.push(...r);
|
||||
else if (r) out.push(r);
|
||||
}
|
||||
return { state, out };
|
||||
}
|
||||
|
||||
const refusalStream = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_refusal", model: "claude-opus-5", role: "assistant", content: [],
|
||||
usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: {
|
||||
stop_reason: "refusal",
|
||||
stop_sequence: null,
|
||||
stop_details: { type: "refusal", category: "reasoning_extraction", explanation: EXPLANATION }
|
||||
},
|
||||
usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 }
|
||||
},
|
||||
{ type: "message_stop" }
|
||||
];
|
||||
|
||||
describe("claude-to-openai: refusal stop_reason", () => {
|
||||
it("finishes with content_filter, not stop", () => {
|
||||
const { out } = runStream(refusalStream);
|
||||
const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean);
|
||||
expect(finishes).toEqual(["content_filter"]);
|
||||
});
|
||||
|
||||
it("surfaces Anthropic's explanation as message content", () => {
|
||||
const { out } = runStream(refusalStream);
|
||||
const text = out.map(c => c.choices?.[0]?.delta?.content || "").join("");
|
||||
expect(text).toBe(EXPLANATION);
|
||||
});
|
||||
|
||||
it("keeps usage on the final chunk (prompt tokens were billed)", () => {
|
||||
const { out } = runStream(refusalStream);
|
||||
const final = out.find(c => c.choices?.[0]?.finish_reason === "content_filter");
|
||||
expect(final.usage.prompt_tokens).toBe(637 + 206779);
|
||||
expect(final.usage.completion_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a normal end_turn untouched", () => {
|
||||
const { out } = runStream([
|
||||
{ type: "message_start", message: { id: "m", model: "claude-opus-5", role: "assistant", content: [], usage: { input_tokens: 5, output_tokens: 0 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null, stop_details: null }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" }
|
||||
]);
|
||||
const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean);
|
||||
expect(finishes).toEqual(["stop"]);
|
||||
expect(out.map(c => c.choices?.[0]?.delta?.content || "").join("")).toBe("ok");
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,9 @@ describe("toOpenAIFinish - claude", () => {
|
||||
["end_turn", "stop"],
|
||||
["max_tokens", "length"],
|
||||
["tool_use", "tool_calls"],
|
||||
["stop_sequence", "stop"],
|
||||
["refusal", "content_filter"],
|
||||
["unknown_xyz", "stop"],
|
||||
])("%s -> %s", (input, expected) => {
|
||||
expect(toOpenAIFinish(input, "claude")).toBe(expected);
|
||||
});
|
||||
@@ -58,6 +61,9 @@ describe("fromOpenAIFinish round-trip - claude", () => {
|
||||
it("tool_calls -> tool_use", () => {
|
||||
expect(fromOpenAIFinish("tool_calls", "claude")).toBe("tool_use");
|
||||
});
|
||||
it("content_filter -> refusal", () => {
|
||||
expect(fromOpenAIFinish("content_filter", "claude")).toBe("refusal");
|
||||
});
|
||||
it("length -> max_tokens", () => {
|
||||
expect(fromOpenAIFinish("length", "claude")).toBe("max_tokens");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { budgetToLevel } from "../../open-sse/translator/concerns/thinking.js";
|
||||
import { applyThinking } from "../../open-sse/translator/concerns/thinkingUnified.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
// Reverse map must be able to reach "max": LEVEL_TO_BUDGET.max = 128000 and
|
||||
// xhigh = 32768, so the xhigh/max threshold is their midpoint (80384).
|
||||
// Previously any budget > 28672 collapsed to "xhigh", making "max"
|
||||
// unreachable from Claude Code budget_tokens — its default thinking budget
|
||||
// (MAX_THINKING_TOKENS) could never produce effort "max".
|
||||
describe("budgetToLevel reaches max tier", () => {
|
||||
it("budget 98304 → \"max\"", () => {
|
||||
expect(budgetToLevel(98304)).toBe("max");
|
||||
});
|
||||
|
||||
it("budget 128000 → \"max\"", () => {
|
||||
expect(budgetToLevel(128000)).toBe("max");
|
||||
});
|
||||
|
||||
it("budget 80385 → \"max\" (just above midpoint)", () => {
|
||||
expect(budgetToLevel(80385)).toBe("max");
|
||||
});
|
||||
|
||||
it("budget 80384 → \"xhigh\" (midpoint still xhigh)", () => {
|
||||
expect(budgetToLevel(80384)).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("budget 31999 stays \"xhigh\"", () => {
|
||||
expect(budgetToLevel(31999)).toBe("xhigh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyThinking (openai-responses): large budgets map to max effort", () => {
|
||||
it("budget 98304 → reasoning_effort \"max\" for gpt-5.6-sol (openai wire)", () => {
|
||||
const body = { thinking: { type: "enabled", budget_tokens: 98304 } };
|
||||
const out = applyThinking(FORMATS.OPENAI_RESPONSES, "gpt-5.6-sol", body, "codex");
|
||||
expect(out?.reasoning_effort).toBe("max");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js";
|
||||
import { getExecutor } from "../../open-sse/executors/index.js";
|
||||
import * as mimoAccount from "../../open-sse/shared/mimoAccount.js";
|
||||
|
||||
const { bareModel, COOKIE_KEY } = __test__;
|
||||
|
||||
@@ -17,22 +18,52 @@ describe("xiaomi-mimo executor", () => {
|
||||
expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor);
|
||||
});
|
||||
|
||||
it("routes Preview models to the account-service route regardless of transport", () => {
|
||||
const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions";
|
||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected);
|
||||
expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected);
|
||||
// body.model arrives as `xiaomi/<id>` via upstreamModelId
|
||||
expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps the sourceFormat-matched endpoint for cloud models", () => {
|
||||
// Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions.
|
||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
||||
expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
||||
});
|
||||
|
||||
it("authenticates Preview calls with the account cookie", () => {
|
||||
const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview");
|
||||
it("routes v2.6 models to account route when desktop credentials are present", () => {
|
||||
// No region → SGP default
|
||||
const expected = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions";
|
||||
const credsWithToken = { providerSpecificData: { mimoPassToken: "token123" } };
|
||||
const credsWithCookie = { [COOKIE_KEY]: "serviceToken=abc" };
|
||||
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, credsWithCookie)).toBe(expected);
|
||||
expect(ex.buildUrl("xiaomi/mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected);
|
||||
});
|
||||
|
||||
it("routes v2.6 models to cloud API when no desktop credentials are present", () => {
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl);
|
||||
});
|
||||
|
||||
it("resolves the account-service cluster per connection region", () => {
|
||||
const cn = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions";
|
||||
const sgp = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions";
|
||||
const ams = "https://mimo-server-ams.xiaomimimo.com/api/route/chat/completions";
|
||||
const ru = "https://mimo-server-ru.xiaomimimo.com/api/route/chat/completions";
|
||||
const inRegion = "https://mimo-server-in.xiaomimimo.com/api/route/chat/completions";
|
||||
// default (no region) falls back to SGP (the international cluster)
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { mimoPassToken: "t" } })).toBe(sgp);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "cn", mimoPassToken: "t" } })).toBe(cn);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "sgp", mimoPassToken: "t" } })).toBe(sgp);
|
||||
expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { region: "SGP", mimoPassToken: "t" } })).toBe(sgp);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ams", mimoPassToken: "t" } })).toBe(ams);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ru", mimoPassToken: "t" } })).toBe(ru);
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "in", mimoPassToken: "t" } })).toBe(inRegion);
|
||||
// unknown region falls back to SGP
|
||||
expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "eu", mimoPassToken: "t" } })).toBe(sgp);
|
||||
});
|
||||
|
||||
it("authenticates v2.6 calls with account cookie when on account route", () => {
|
||||
const headers = ex.buildHeaders(
|
||||
{ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" },
|
||||
true,
|
||||
"u",
|
||||
"mimo-v2.6-flash",
|
||||
);
|
||||
expect(headers.Cookie).toBe("serviceToken=abc");
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
@@ -43,38 +74,55 @@ describe("xiaomi-mimo executor", () => {
|
||||
expect(headers.Cookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails fast when a Preview call has no account session", async () => {
|
||||
await expect(
|
||||
ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }),
|
||||
).rejects.toThrow(/account session unavailable/);
|
||||
});
|
||||
|
||||
it("flattens content-part arrays to plain strings", () => {
|
||||
it("preserves content-part arrays for multimodal inputs", () => {
|
||||
const parts = [{ type: "image_url", image_url: { url: "data:image/png;base64,xyz" } }, { type: "text", text: "hi" }];
|
||||
const out = ex.transformRequest(
|
||||
"mimo-x-pro-preview",
|
||||
{ messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] },
|
||||
"mimo-v2.6-pro",
|
||||
{ messages: [{ role: "user", content: parts }] },
|
||||
true,
|
||||
{},
|
||||
{ providerSpecificData: { mimoPassToken: "token" } },
|
||||
);
|
||||
expect(out.messages[0].content).toBe("ab");
|
||||
expect(out.messages[0].content).toEqual(parts);
|
||||
});
|
||||
|
||||
it("applies Preview defaults without overriding explicit values", () => {
|
||||
it("bridges reasoning_effort to official output_config.effort", () => {
|
||||
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "solve" }],
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||
expect(out.reasoning_effort).toBeUndefined();
|
||||
expect(out.output_config).toEqual({ effort: "high" });
|
||||
});
|
||||
|
||||
it("normalizes xhigh reasoning_effort to high in output_config.effort", () => {
|
||||
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||
const body = {
|
||||
messages: [{ role: "user", content: "complex" }],
|
||||
reasoning_effort: "xhigh",
|
||||
};
|
||||
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||
expect(out.reasoning_effort).toBeUndefined();
|
||||
expect(out.output_config).toEqual({ effort: "high" });
|
||||
});
|
||||
|
||||
it("applies defaults without overriding explicit values", () => {
|
||||
const creds = { providerSpecificData: { mimoPassToken: "token" } };
|
||||
const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 };
|
||||
const out = ex.transformRequest("mimo-x-pro-preview", body, true, {});
|
||||
expect(out.temperature).toBe(0.2); // caller's value kept
|
||||
expect(out.top_p).toBe(0.95); // default filled in
|
||||
expect(out.max_tokens).toBe(4096);
|
||||
const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds);
|
||||
expect(out.temperature).toBe(0.2);
|
||||
expect(out.top_p).toBe(0.95);
|
||||
});
|
||||
|
||||
it("leaves cloud bodies free of Preview defaults", () => {
|
||||
it("leaves cloud bodies free of account defaults", () => {
|
||||
const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {});
|
||||
expect(out.thinking).toBeUndefined();
|
||||
expect(out.max_tokens).toBeUndefined();
|
||||
expect(out.output_config).toBeUndefined();
|
||||
expect(out.temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips a provider/model prefix when testing preview ids", () => {
|
||||
expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
||||
expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview");
|
||||
it("strips a provider/model prefix when testing model ids", () => {
|
||||
expect(bareModel("xiaomi/mimo-v2.6-pro")).toBe("mimo-v2.6-pro");
|
||||
expect(bareModel("mimo-v2.6-flash")).toBe("mimo-v2.6-flash");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Security invariants of the server-assisted MiMo login proxy
|
||||
* (src/lib/mimoLoginSession.js):
|
||||
* - credentials bound to 9router's own origin are never forwarded upstream
|
||||
* - upstream Set-Cookie is never replayed onto the app's own cookie jar
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { __test__ } from "../../src/lib/mimoLoginSession.js";
|
||||
|
||||
const { STRIP_UPSTREAM_HEADERS, buildBrowserResponse } = __test__;
|
||||
|
||||
describe("mimo login proxy security", () => {
|
||||
it("strips auth credentials and session cookies before forwarding upstream", () => {
|
||||
for (const h of ["authorization", "proxy-authorization", "cookie", "host"]) {
|
||||
expect(STRIP_UPSTREAM_HEADERS.has(h)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not replay upstream Set-Cookie onto the app origin", async () => {
|
||||
const upstream = new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
"set-cookie": "userId=123; Path=/", // plain object header: visible via getSetCookie
|
||||
},
|
||||
});
|
||||
const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/pass/");
|
||||
expect(out.headers.getSetCookie()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps ordinary response headers intact", async () => {
|
||||
const upstream = new Response("<html></html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/fe/");
|
||||
expect(out.status).toBe(200);
|
||||
expect(out.headers.get("content-type")).toBe("text/html");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user