merge: sync upstream v0.5.81 into MIBP fork

# Conflicts:
#	.gitignore
#	Dockerfile
#	open-sse/handlers/chatCore.js
#	open-sse/providers/registry/cline.js
#	open-sse/providers/registry/index.js
#	open-sse/services/usage.js
#	open-sse/utils/streamHandler.js
#	package.json
#	src/app/(dashboard)/dashboard/profile/page.js
#	src/app/(dashboard)/dashboard/providers/[id]/page.js
This commit is contained in:
MUH. IQRAM BAHRING
2026-09-19 11:35:34 +08:00
208 changed files with 13062 additions and 977 deletions
@@ -8,7 +8,7 @@ import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/
import {
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
JcodeToolCard, GrokBuildToolCard,
} from "../components";
@@ -156,8 +156,6 @@ export default function ToolDetailClient({ toolId, machineId }) {
return <OpenClawToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "hermes":
return <HermesToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "copilot":
return <CopilotToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "cline":
return <ClineToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
case "kilo":
@@ -7,19 +7,25 @@ import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
import { stripModelContextMarker } from "open-sse/utils/modelMarkers.js";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
// Context window presets. UI shows the round number; the value written is nudged
// down 2K to stay safely under the upstream hard cap.
// Auto-compact window presets (CLAUDE_CODE_AUTO_COMPACT_WINDOW, valid 100K–1M).
// UI shows the round number; the value written is nudged down 2K to stay safely
// under the upstream hard cap.
const CONTEXT_OPTIONS = [
{ label: "Default", value: "" },
{ label: "200K", value: "198000" },
{ label: "300K", value: "298000" },
{ label: "500K", value: "498000" },
{ label: "1M", value: "998000" },
{ label: "700K", value: "698000" },
];
// Claude Code assumes a model's window is 200K unless the name carries the `[1m]`
// marker, which is why the 1M auto-compact preset only takes effect once the
// marker is applied.
export default function ClaudeToolCard({
tool,
isExpanded,
@@ -51,9 +57,28 @@ export default function ClaudeToolCard({
const [customBaseUrl, setCustomBaseUrl] = useState("");
const [ccFilterNaming, setCcFilterNaming] = useState(false);
const [exaMcpEnabled, setExaMcpEnabled] = useState(false);
const [maxContextTokens, setMaxContextTokens] = useState("");
const [autoCompactWindow, setAutoCompactWindow] = useState("");
const [oneMContext, setOneMContext] = useState(false);
const hasInitializedModels = useRef(false);
// Claude Code only string-matches the marker against the model name, so it
// applies to any id — the user decides which models are worth declaring as 1M.
// Stripping first keeps repeated toggles from stacking `[1m][1m]`.
const withContextMarker = (value, enabled) => {
const { model } = stripModelContextMarker(value);
return enabled ? `${model}[1m]` : model;
};
// Rewrite the mappings in place on toggle, so the inputs show what will be
// written without waiting for Apply.
const handleOneMContextToggle = (enabled) => {
setOneMContext(enabled);
tool.defaultModels.forEach((model) => {
const current = modelMappings[model.alias];
if (current) onModelMappingChange(model.alias, withContextMarker(current, enabled));
});
};
const currentBaseUrl = claudeStatus?.settings?.env?.ANTHROPIC_BASE_URL || "";
const getConfigStatus = () => {
@@ -80,9 +105,15 @@ export default function ClaudeToolCard({
}, [initialStatus]);
useEffect(() => {
const v = claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
setMaxContextTokens(v || "");
}, [claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS]);
const v = claudeStatus?.settings?.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
setAutoCompactWindow(v || "");
}, [claudeStatus?.settings?.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW]);
useEffect(() => {
const env = claudeStatus?.settings?.env;
if (!env) return;
setOneMContext(tool.defaultModels.some((model) => env[model.envKey]?.endsWith("[1m]")));
}, [claudeStatus?.settings?.env, tool.defaultModels]);
useEffect(() => {
if (isExpanded) {
@@ -124,6 +155,8 @@ export default function ClaudeToolCard({
tool.defaultModels.forEach((model) => {
if (model.envKey) {
// Kept verbatim (marker included) so the input matches what is on disk;
// withContextMarker strips before appending, so re-applying cannot double it.
const value = env[model.envKey] || model.defaultValue || "";
// Only sync initial values from file once
if (value) {
@@ -180,15 +213,17 @@ export default function ClaudeToolCard({
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias];
// Written verbatim — the input may hold a marker typed by hand, and the
// toggle already decided the marker when it was flipped.
if (targetModel && model.envKey) env[model.envKey] = targetModel;
});
if (maxContextTokens) {
env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens;
if (autoCompactWindow) {
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = autoCompactWindow;
}
const res = await fetch("/api/cli-tools/claude-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env, exaMcpEnabled, maxContextTokens }),
body: JSON.stringify({ env, exaMcpEnabled, autoCompactWindow }),
});
const data = await res.json();
if (res.ok) {
@@ -217,7 +252,8 @@ export default function ClaudeToolCard({
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
setSelectedApiKey("");
setExaMcpEnabled(false);
setMaxContextTokens("");
setAutoCompactWindow("");
setOneMContext(false);
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
@@ -247,8 +283,8 @@ export default function ClaudeToolCard({
const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel;
});
if (maxContextTokens) {
env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens;
if (autoCompactWindow) {
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = autoCompactWindow;
}
return [
@@ -374,17 +410,30 @@ export default function ClaudeToolCard({
</div>
))}
{/* Context Window */}
{/* Auto-compact window */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Context window</span>
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Auto-compact</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<select value={maxContextTokens} onChange={(e) => setMaxContextTokens(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
<select value={autoCompactWindow} onChange={(e) => setAutoCompactWindow(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5">
{CONTEXT_OPTIONS.map((opt) => (
<option key={opt.label} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* 1M context */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">1M context</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input type="checkbox" checked={oneMContext} onChange={(e) => handleOneMContextToggle(e.target.checked)} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
<span className="text-xs text-text-muted">Append [1m] to the model name</span>
<Tooltip text="Claude Code otherwise assumes a 200K window, which clamps the auto-compact window above. Applied to every mapped model — only enable it for models that really accept 1M.">
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
</Tooltip>
</label>
</div>
{/* CC Filter Naming */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Filter naming</span>
@@ -5,7 +5,8 @@ import Image from "next/image";
import { Card } from "@/shared/components";
// Derive simple connected/configured/not-installed status from API payload
function getStatus(status) {
function getStatus(status, tool) {
if (tool?.configType === "guide") return { label: "Guide", cls: "bg-blue-500/10 text-blue-600 dark:text-blue-400" };
if (!status) return { label: "Unknown", cls: "bg-gray-500/10 text-gray-500" };
if (!status.installed) return { label: "Not installed", cls: "bg-gray-500/10 text-gray-500" };
if (status.has9Router) return { label: "Connected", cls: "bg-green-500/10 text-green-600 dark:text-green-400" };
@@ -13,7 +14,7 @@ function getStatus(status) {
}
export default function ToolSummaryCard({ toolId, tool, status }) {
const s = getStatus(status);
const s = getStatus(status, tool);
return (
<Link href={`/dashboard/cli-tools/${toolId}`} className="block">
<Card padding="sm" className="h-full overflow-hidden hover:border-primary/50 transition-colors cursor-pointer">
@@ -93,6 +93,12 @@ export default function ProfilePage() {
const [proxyLoading, setProxyLoading] = useState(false);
const [proxyTestLoading, setProxyTestLoading] = useState(false);
const [isRemoteHost, setIsRemoteHost] = useState(false);
useEffect(() => {
if (typeof window !== "undefined")
setIsRemoteHost(!["localhost", "127.0.0.1", "::1"].includes(window.location.hostname));
}, []);
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
@@ -1680,7 +1686,7 @@ export default function ProfilePage() {
{/* App Info */}
<div className="text-center text-xs sm:text-sm text-text-muted py-4">
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>
<p className="mt-1">Local Mode - All data stored on your machine</p>
<p className="mt-1">{isRemoteHost ? "Remote Mode" : "Local Mode - All data stored on your machine"}</p>
<a
href="https://github.com/mhiqrambg/9router-mibp-version"
target="_blank"
@@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, XiaomiMimoAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
@@ -45,6 +45,7 @@ export default function ProviderDetailPage() {
const [providerNode, setProviderNode] = useState(null);
const [proxyPools, setProxyPools] = useState([]);
const [showOAuthModal, setShowOAuthModal] = useState(false);
const [showXiaomiMimoModal, setShowXiaomiMimoModal] = useState(false);
const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [addConnectionError, setAddConnectionError] = useState("");
@@ -71,6 +72,8 @@ export default function ProviderDetailPage() {
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
const [suggestedModels, setSuggestedModels] = useState([]);
const [liveModels, setLiveModels] = useState([]);
// Live-catalog fetch warning/error (surfaced for zed only; cursor behavior unchanged).
const [liveModelsError, setLiveModelsError] = useState(null);
const [kiloFreeModels, setKiloFreeModels] = useState([]);
const [disabledModelIds, setDisabledModelIds] = useState([]);
const [confirmState, setConfirmState] = useState(null);
@@ -82,6 +85,7 @@ export default function ProviderDetailPage() {
const [oneByOneSummary, setOneByOneSummary] = useState(null);
const stopOneByOneRef = useRef(false);
const [importingQoderModels, setImportingQoderModels] = useState(false);
const [importingClineModels, setImportingClineModels] = useState(false);
const { copied, copy } = useCopyToClipboard();
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
@@ -98,6 +102,11 @@ export default function ProviderDetailPage() {
return;
}
}
// Xiaomi Desktop: auto-import local credentials first, OAuth as fallback
if (providerId === "xiaomi-mimo") {
setShowXiaomiMimoModal(true);
return;
}
if (isOAuth) {
openOAuthConnection();
return;
@@ -147,7 +156,7 @@ export default function ProviderDetailPage() {
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
const staticModels = getModelsByProviderId(providerId);
const models = providerId === "cursor" && liveModels.length > 0
const models = (providerId === "cursor" || providerId === "zed") && liveModels.length > 0
? liveModels
: staticModels;
const providerAlias = getProviderAlias(providerId);
@@ -521,11 +530,13 @@ export default function ProviderDetailPage() {
});
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
// Cursor's model availability is account-specific and changes frequently.
// Load the active account's live catalog for the dashboard; the static
// registry remains the fallback while the request is pending or unavailable.
// Live per-connection catalogs (cursor, zed): the static registry carries
// no usable list, so resolve from the active connection. Fires only when
// the provider id or connection list changes — no polling, no loop.
// Cursor path is statement-identical to before; zed adds error surfacing.
useEffect(() => {
if (providerId !== "cursor") {
const isLiveCatalog = providerId === "cursor" || providerId === "zed";
if (!isLiveCatalog) {
queueMicrotask(() => setLiveModels([]));
return;
}
@@ -533,18 +544,32 @@ export default function ProviderDetailPage() {
const connection = connections.find((item) => item.isActive !== false);
if (!connection?.id) {
queueMicrotask(() => setLiveModels([]));
if (providerId === "zed") setLiveModelsError(null);
return;
}
let cancelled = false;
if (providerId === "zed") setLiveModelsError(null);
fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" })
.then(async (res) => ({ ok: res.ok, data: await res.json() }))
.then(async (res) => ({ ok: res.ok, data: await res.json().catch(() => null) }))
.then(({ ok, data }) => {
if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) {
if (cancelled) return;
if (ok && Array.isArray(data?.models) && data.models.length > 0) {
setLiveModels(data.models);
if (providerId === "zed" && data?.warning) setLiveModelsError(data.warning);
return;
}
if (providerId === "zed") {
setLiveModels([]);
setLiveModelsError(data?.warning || data?.error || "Zed returned no live models.");
}
})
.catch(() => {});
.catch(() => {
if (!cancelled && providerId === "zed") {
setLiveModels([]);
setLiveModelsError("Failed to reach the Zed model catalog.");
}
});
return () => { cancelled = true; };
}, [providerId, connections]);
@@ -673,6 +698,53 @@ export default function ProviderDetailPage() {
setImportingQoderModels(false);
}
};
// Fetch the live Cline /models catalog and add every model not yet present.
// Cline and ClinePass share the same catalog endpoint (api.cline.bot/api/v1/models).
const handleImportClineModels = async () => {
if (importingClineModels) return;
const activeConnection = connections.find((conn) => conn.isActive !== false);
if (!activeConnection) {
alert(translate("Please add an active Cline connection first"));
return;
}
setImportingClineModels(true);
try {
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
const data = await res.json();
if (!res.ok) {
alert(data.error || translate("Failed to fetch models"));
return;
}
const models = data.models || [];
if (models.length === 0) {
alert(translate("No models returned"));
return;
}
let importedCount = 0;
for (const model of models) {
const modelId = model.id || model.name;
if (!modelId) continue;
const alreadyExists = customModels.some(
(entry) => entry.providerAlias === providerStorageAlias && entry.id === modelId && (entry.kind || entry.type || "llm") === "llm"
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${modelId}`);
if (alreadyExists) {
continue;
}
await handleAddCustomModel(modelId, "llm", providerStorageAlias);
importedCount += 1;
}
if (importedCount === 0) {
alert(translate("All models already exist, no new models added"));
} else {
alert(translate("Successfully added") + ` ${importedCount} ` + translate("models"));
}
} catch (error) {
console.log("Error importing Cline models:", error);
alert(translate("Error fetching models") + ": " + error.message);
} finally {
setImportingClineModels(false);
}
};
const handleRunOneByOneTest = async () => {
if (oneByOneRunning || connections.length === 0) return;
@@ -1275,6 +1347,20 @@ export default function ProviderDetailPage() {
</button>
)}
{/* Import Cline /models catalog button — only show for cline and clinepass providers */}
{(providerId === "cline" || providerId === "clinepass") && connections.some((conn) => conn.isActive !== false) && (
<button
onClick={handleImportClineModels}
disabled={importingClineModels}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-blue-500/40 px-3 py-2 text-xs text-blue-600 dark:text-blue-400 transition-colors hover:border-blue-500 hover:bg-blue-500/5 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-sm" style={importingClineModels ? { animation: "spin 1s linear infinite" } : undefined}>
{importingClineModels ? "progress_activity" : "download"}
</span>
{importingClineModels ? translate("Fetching...") : translate("Import from /models")}
</button>
)}
{/* Suggested models from provider API — show only models not yet added */}
{suggestedModels.length > 0 && (() => {
const addedFullModels = new Set([
@@ -1792,7 +1878,36 @@ export default function ProviderDetailPage() {
})()}
</div>
{!!modelsTestError && (
<p className="text-xs text-red-500 mb-3 break-words">{modelsTestError}</p>
<div className="mb-3">
<p className="text-xs text-red-500 break-words">{modelsTestError}</p>
{/RegionError|hosted in China|regionNotAllowed/i.test(modelsTestError) && (() => {
const str = typeof modelsTestError === "string" ? modelsTestError : JSON.stringify(modelsTestError);
const linkMatch = str.match(/https:\/\/opencode\.ai\/workspace\/[^\s"')]+/);
const wrkMatch = str.match(/wrk_[0-9A-Za-z]+/);
const targetUrl = linkMatch
? (linkMatch[0].endsWith("/go") ? linkMatch[0] : `${linkMatch[0]}/go`)
: wrkMatch
? `https://opencode.ai/workspace/${wrkMatch[0]}/go`
: "https://opencode.ai";
return (
<div className="mt-1.5">
<a
href={targetUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-md bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-600 hover:bg-amber-500/20 dark:text-amber-400 transition-colors"
>
<span>Allow China-hosted models</span>
<span className="material-symbols-outlined text-[13px]">open_in_new</span>
</a>
</div>
);
})()}
</div>
)}
{providerId === "zed" && !!liveModelsError && (
<p className="text-xs text-red-500 mb-3 break-words">{liveModelsError}</p>
)}
{renderModelsSection()}
</Card>
@@ -1829,6 +1944,13 @@ export default function ProviderDetailPage() {
onClose={() => setShowOAuthModal(false)}
/>
)}
{/* Xiaomi Desktop: auto-import local credentials modal */}
<XiaomiMimoAuthModal
isOpen={showXiaomiMimoModal}
onSuccess={handleOAuthSuccess}
onClose={() => setShowXiaomiMimoModal(false)}
/>
{providerId === "iflow" && (
<IFlowCookieModal
isOpen={showIFlowCookieModal}
@@ -151,7 +151,10 @@ export default function QuotaTable({
<div className="space-y-px">
{currentPageRows.map((quota) => {
const isUnlimited = quota.unlimited === true;
const colors = getColorClasses(quota.remaining);
const isCreditBalance = quota.isCreditBalance === true;
const colors = isCreditBalance
? { text: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500", bgLight: "bg-blue-500/10", emoji: "💰" }
: getColorClasses(quota.remaining);
const countdown = formatResetTime(quota.resetAt);
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
// recurring defaults true: a missing flag means the quota
@@ -186,7 +189,7 @@ export default function QuotaTable({
{/* Progress + used/total */}
<div className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-1.5"}`}>
{!isUnlimited && (
{!isUnlimited && !isCreditBalance && (
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
}`}>
@@ -203,15 +206,19 @@ export default function QuotaTable({
title={
isUnlimited
? `${quota.used.toLocaleString()} used · Unlimited`
: isCreditBalance
? `Credit balance: ${quota.total.toFixed(2)} ${quota.currency || ""}`
: `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`
}
>
{isUnlimited
? `${quota.used.toLocaleString()} used · Unlimited`
: isCreditBalance
? `Credit: ${quota.total.toFixed(2)} ${quota.currency || ""}`
: `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`}
</span>
<span className={`font-medium ${isUnlimited ? "text-green-600 dark:text-green-400" : colors.text} shrink-0`}>
{isUnlimited ? "Unlimited" : `${quota.remaining}%`}
<span className={`font-medium ${isUnlimited ? "text-green-600 dark:text-green-400" : isCreditBalance ? "text-blue-600 dark:text-blue-400" : colors.text} shrink-0`}>
{isUnlimited ? "Unlimited" : isCreditBalance ? "" : `${quota.remaining}%`}
</span>
</div>
</div>
@@ -41,7 +41,7 @@ import {
} from "./utils";
import Card from "@/shared/components/Card";
import { ConfirmModal, EditConnectionModal } from "@/shared/components";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { USAGE_SUPPORTED_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
// Maps the stored providerSpecificData.authMethod to a human label for Kiro.
@@ -101,6 +101,10 @@ function getCodexResetCreditCount(quota) {
return Number.isFinite(count) ? Math.max(0, count) : 0;
}
function providerLabel(providerId) {
return AI_PROVIDERS[providerId]?.name || providerId;
}
function formatCreditDate(value) {
if (!value) return "N/A";
const date = new Date(value);
@@ -597,6 +601,17 @@ export default function ProviderLimits() {
const providerVisibility = previous[provider] || {};
const hidden = new Set(providerVisibility.hidden || []);
hidden.add(key);
if (provider === "antigravity") {
if (key === "gemini") {
for (const k of hidden) {
if (k.startsWith("gemini-") && !k.includes("image")) hidden.delete(k);
}
} else if (key === "claude") {
for (const k of hidden) {
if (k.startsWith("claude-")) hidden.delete(k);
}
}
}
const next = {
...previous,
[provider]: {
@@ -615,6 +630,17 @@ export default function ProviderLimits() {
const providerVisibility = previous[provider] || {};
const hidden = new Set(providerVisibility.hidden || []);
hidden.delete(key);
if (provider === "antigravity") {
if (key === "gemini") {
for (const k of hidden) {
if (k.startsWith("gemini-") && !k.includes("image")) hidden.delete(k);
}
} else if (key === "claude") {
for (const k of hidden) {
if (k.startsWith("claude-")) hidden.delete(k);
}
}
}
const next = {
...previous,
[provider]: {
@@ -746,7 +772,7 @@ export default function ProviderLimits() {
};
const selectedProviderLabel =
providerFilter === "all" ? "All providers" : providerFilter;
providerFilter === "all" ? "All providers" : providerLabel(providerFilter);
const hasEligibleConnections = totals.eligibleConnections > 0;
const hasVisibleConnections = sortedConnections.length > 0;
const emptyState = getConnectionsEmptyMessage(
@@ -823,7 +849,7 @@ export default function ProviderLimits() {
fallbackText={providerFilter.slice(0, 2).toUpperCase()}
/>
)}
<span className="truncate capitalize hidden lg:inline">
<span className="truncate hidden lg:inline">
{selectedProviderLabel}
</span>
</span>
@@ -884,8 +910,8 @@ export default function ProviderLimits() {
className="size-6 rounded-md object-contain"
fallbackText={provider.slice(0, 2).toUpperCase()}
/>
<span className="font-medium capitalize">
{provider}
<span className="font-medium">
{providerLabel(provider)}
</span>
{providerFilter === provider && (
<span className="material-symbols-outlined ml-auto text-[20px]">
@@ -1058,8 +1084,8 @@ export default function ProviderLimits() {
/>
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary capitalize truncate">
{conn.provider}
<h3 className="text-sm font-semibold text-text-primary truncate">
{providerLabel(conn.provider)}
</h3>
{getConnectionLabel(conn) ? (
<p className="text-xs text-text-muted truncate">
@@ -359,21 +359,33 @@ export function getQuotaVisibilityKey(quota) {
return String(quota.modelKey || quota.name || "").trim();
}
function getProviderHiddenQuotaSet(provider, quotaVisibility) {
/**
* Trim hidden quota keys to only those matching currently valid quotas.
* Stale or obsolete model keys are dropped.
*/
export function trimHiddenQuotaKeys(hidden = [], quotas = []) {
if (!Array.isArray(hidden) || hidden.length === 0) return [];
const validKeys = new Set(quotas.map(getQuotaVisibilityKey).filter(Boolean));
return [...new Set(hidden.map((k) => String(k).trim()).filter((k) => validKeys.has(k)))];
}
function getProviderHiddenQuotaSet(provider, quotaVisibility, quotas = []) {
const hidden = quotaVisibility?.[provider]?.hidden;
return new Set(Array.isArray(hidden) ? hidden.map(String) : []);
if (!Array.isArray(hidden) || hidden.length === 0) return new Set();
const trimmed = quotas.length > 0 ? trimHiddenQuotaKeys(hidden, quotas) : hidden;
return new Set(trimmed.map(String));
}
export function filterQuotasByVisibility(provider, quotas = [], quotaVisibility = {}) {
if (!Array.isArray(quotas) || quotas.length === 0) return [];
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility, quotas);
if (hidden.size === 0) return quotas;
return quotas.filter((quota) => !hidden.has(getQuotaVisibilityKey(quota)));
}
export function getHiddenQuotaRows(provider, quotas = [], quotaVisibility = {}) {
if (!Array.isArray(quotas) || quotas.length === 0) return [];
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility, quotas);
if (hidden.size === 0) return [];
return quotas.filter((quota) => hidden.has(getQuotaVisibilityKey(quota)));
}
@@ -406,10 +418,68 @@ export function parseQuotaData(provider, data) {
case "antigravity":
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelKey, quota]) => {
const entries = Object.entries(data.quotas);
const weeklyKeys = new Set(["gemini_weekly", "claude_gpt_weekly"]);
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
const imageModels = entries.filter(([k]) => k.includes("image"));
const weeklyModels = entries.filter(([k]) => weeklyKeys.has(k));
const otherModels = entries.filter(([k]) => !k.startsWith("gemini-") && !k.startsWith("claude-") && !k.includes("image") && !weeklyKeys.has(k));
if (geminiModels.length > 0) {
const rep = geminiModels.reduce((min, cur) =>
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
)[1];
normalizedQuotas.push({
name: "Gemini (Flash / Pro)",
modelKey: "gemini",
used: rep.used || 0,
total: rep.total || 0,
resetAt: rep.resetAt || null,
remainingPercentage: rep.remainingPercentage,
});
}
if (claudeModels.length > 0) {
const rep = claudeModels.reduce((min, cur) =>
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
)[1];
normalizedQuotas.push({
name: "Claude (Sonnet / Opus)",
modelKey: "claude",
used: rep.used || 0,
total: rep.total || 0,
resetAt: rep.resetAt || null,
remainingPercentage: rep.remainingPercentage,
});
}
weeklyModels.forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey: modelKey, // Keep modelKey for sorting
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
imageModels.forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
otherModels.forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
@@ -496,6 +566,8 @@ export function parseQuotaData(provider, data) {
name,
used: quota.used || 0,
total: quota.total || 0,
remaining: quota.remaining !== undefined ? quota.remaining : Math.max(0, (quota.total || 100) - (quota.used || 0)),
remainingPercentage: quota.remainingPercentage !== undefined ? quota.remainingPercentage : calculatePercentage(quota.used, quota.total),
resetAt: quota.resetAt || null,
});
});
@@ -580,6 +652,8 @@ export function parseQuotaData(provider, data) {
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
isCreditBalance: quota.isCreditBalance ?? true,
currency: quota.currency || (name.includes("(") ? name.slice(name.indexOf("(") + 1, name.indexOf(")")) : "USD"),
});
});
}
@@ -671,15 +745,31 @@ export function parseQuotaData(provider, data) {
return [];
}
if (provider?.toLowerCase() === "claude") {
const CLAUDE_QUOTA_ORDER = {
"session (5h)": 0,
"weekly (7d)": 1,
"weekly fable (7d)": 2,
"weekly opus (7d)": 3,
"weekly sonnet (7d)": 4,
};
normalizedQuotas.sort((a, b) => (CLAUDE_QUOTA_ORDER[a.name] ?? 99) - (CLAUDE_QUOTA_ORDER[b.name] ?? 99));
return normalizedQuotas;
}
// Sort quotas according to PROVIDER_MODELS order
const modelOrder = getModelsByProviderId(provider);
if (modelOrder.length > 0) {
const orderMap = new Map(modelOrder.map((m, i) => [m.id, i]));
normalizedQuotas.sort((a, b) => {
// Use modelKey for antigravity, otherwise use name
const keyA = a.modelKey || a.name;
const keyB = b.modelKey || b.name;
// Use modelKey for antigravity (mapped to family anchor), otherwise use name
let keyA = a.modelKey || a.name;
let keyB = b.modelKey || b.name;
if (keyA === "gemini") keyA = "gemini-3.8-flash-high";
if (keyA === "claude") keyA = "claude-sonnet-4-6";
if (keyB === "gemini") keyB = "gemini-3.8-flash-high";
if (keyB === "claude") keyB = "claude-sonnet-4-6";
const orderA = orderMap.get(keyA) ?? 999;
const orderB = orderMap.get(keyB) ?? 999;
return orderA - orderB;
@@ -8,7 +8,6 @@ import { GET as droidGet } from "../droid-settings/route";
import { GET as openclawGet } from "../openclaw-settings/route";
import { GET as hermesGet } from "../hermes-settings/route";
import { GET as coworkGet } from "../cowork-settings/route";
import { GET as copilotGet } from "../copilot-settings/route";
import { GET as clineGet } from "../cline-settings/route";
import { GET as kiloGet } from "../kilo-settings/route";
import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route";
@@ -24,7 +23,6 @@ const STATUS_GETTERS = {
openclaw: openclawGet,
hermes: hermesGet,
cowork: coworkGet,
copilot: copilotGet,
cline: clineGet,
kilo: kiloGet,
"deepseek-tui": deepseekTuiGet,
@@ -123,7 +123,7 @@ export async function GET() {
// POST - Backup old fields and write new settings
export async function POST(request) {
try {
const { env, exaMcpEnabled, maxContextTokens } = await request.json();
const { env, exaMcpEnabled, autoCompactWindow } = await request.json();
if (!env || typeof env !== "object") {
return NextResponse.json(
@@ -166,12 +166,13 @@ export async function POST(request) {
},
};
// CLAUDE_CODE_MAX_CONTEXT_TOKENS — only set when a concrete value is chosen;
// "Default" removes the key so Claude Code falls back to the model's window.
if (maxContextTokens) {
newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(maxContextTokens);
// CLAUDE_CODE_AUTO_COMPACT_WINDOW — the token threshold that triggers
// auto-compact. Only set when a concrete value is chosen; "Default" removes
// the key so Claude Code derives the window from the model.
if (autoCompactWindow) {
newSettings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(autoCompactWindow);
} else {
delete newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
delete newSettings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
}
// Write new settings
@@ -203,7 +204,7 @@ const RESET_ENV_KEYS = [
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"API_TIMEOUT_MS",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS",
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
];
// DELETE - Reset settings (remove env fields)
@@ -1,6 +1,8 @@
"use server";
import { NextResponse } from "next/server";
import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js";
import { isLocalRequest } from "@/dashboardGuard";
const TIMEOUT_MS = 8000;
@@ -87,6 +89,14 @@ export async function POST(request) {
if (!url || typeof url !== "string") {
return NextResponse.json({ error: "url required" }, { status: 400 });
}
// SSRF guard for remote callers; local host keeps self-hosted MCP servers.
if (!isLocalRequest(request)) {
try {
assertPublicUrl(url);
} catch {
return NextResponse.json({ error: "URL not allowed" }, { status: 400 });
}
}
const result = await probeMcp(url);
return NextResponse.json(result);
} catch (e) {
+8 -1
View File
@@ -1,4 +1,6 @@
import { getApiKeys } from "@/lib/localDb";
import { resolveProviderId } from "@/shared/constants/providers.js";
import { unwrapClineEnvelope } from "open-sse/shared/clineEnvelope.js";
import { UPDATER_CONFIG } from "@/shared/constants/config";
import { getConsistentMachineId } from "@/shared/utils/machineId";
@@ -151,9 +153,14 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$
let parsed = null;
try { parsed = rawText ? JSON.parse(rawText) : null; } catch {}
// Unwrap before the choices checks below. No-op for providers that do not
// opt in via transport.quirks.clineEnvelope.
const providerId = resolveProviderId(String(model).split("/")[0]);
parsed = unwrapClineEnvelope(parsed, providerId);
if (!res.ok) {
const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText;
return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status };
return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 500)}` : ""}`, status: res.status };
}
const providerStatus = parsed?.status;
+122 -6
View File
@@ -1,3 +1,4 @@
import crypto from "crypto";
import { NextResponse } from "next/server";
import {
getProvider,
@@ -7,6 +8,7 @@ import {
pollForToken
} from "@/lib/oauth/providers";
import { createProviderConnection } from "@/models";
import { readDesktopPassToken } from "open-sse/shared/mimoAccount.js";
import {
startCodexProxy,
stopCodexProxy,
@@ -33,6 +35,11 @@ import {
registerZedSession,
getZedSessionStatus,
clearZedSession,
startXiaomiMimoProxy,
stopXiaomiMimoProxy,
registerXiaomiMimoSession,
getXiaomiMimoSessionStatus,
clearXiaomiMimoSession,
} from "@/lib/oauth/utils/server";
import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect";
import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth";
@@ -89,6 +96,32 @@ export async function GET(request, { params }) {
const { searchParams } = new URL(request.url);
if (action === "authorize") {
// Xiaomi Desktop: custom ECDH flow — generate keypair, start proxy, return authorize URL
if (provider === "xiaomi-mimo") {
const { generateKeyPair, buildAuthorizeUrl, getKeyName } = await import("@/lib/oauth/providers/xiaomi-mimo");
const { publicKey, privateKeyDer } = generateKeyPair();
const state = searchParams.get("state") || crypto.randomUUID();
// Start the callback proxy (or reuse if already running)
const proxyResult = await startXiaomiMimoProxy();
if (!proxyResult.success) {
return NextResponse.json({ error: `Failed to start callback server: ${proxyResult.reason}` }, { status: 500 });
}
// Register the session with the private key for decryption
registerXiaomiMimoSession({ state, privateKeyDer });
const redirectUri = proxyResult.callbackUrl;
const authorizeUrl = buildAuthorizeUrl(publicKey, redirectUri, getKeyName());
return NextResponse.json({
state,
authorizeUrl,
redirectUri,
port: proxyResult.port,
});
}
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
// Collect provider-specific meta params (e.g. gitlab passes baseUrl, clientId, clientSecret)
const reservedParams = new Set(["redirect_uri"]);
@@ -120,6 +153,10 @@ export async function GET(request, { params }) {
const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort);
return NextResponse.json(result);
}
if (provider === "xiaomi-mimo") {
const result = await startXiaomiMimoProxy();
return NextResponse.json(result);
}
if (!["codex", "xai"].includes(provider)) {
return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
}
@@ -153,10 +190,21 @@ export async function GET(request, { params }) {
else if (provider === "zed") session = getZedSessionStatus(state);
else if (provider === "xai") session = getXaiSessionStatus(state);
else if (provider === "codex") session = getCodexSessionStatus(state);
else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
else if (provider === "xiaomi-mimo") session = getXiaomiMimoSessionStatus(state);
else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed/xiaomi-mimo" }, { status: 400 });
if (!session) return NextResponse.json({ status: "unknown" });
if (session.status === "done" || session.status === "error") {
const payload = { ...session };
if (provider === "xiaomi-mimo") {
// Unlike the others this does not auto-exchange server-side, so a
// finished session must survive until the client POSTs /exchange —
// that call clears it. A failed one is cleared here instead.
if (session.status === "error") {
clearXiaomiMimoSession(state);
stopXiaomiMimoProxy();
}
return NextResponse.json(payload);
}
if (provider === "trae") clearTraeSession(state);
else if (provider === "windsurf") clearWindsurfSession(state);
else if (provider === "zed") clearZedSession(state);
@@ -173,7 +221,8 @@ export async function GET(request, { params }) {
else if (provider === "zed") stopZedProxy();
else if (provider === "xai") stopXaiProxy();
else if (provider === "codex") stopCodexProxy();
else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
else if (provider === "xiaomi-mimo") stopXiaomiMimoProxy();
else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed/xiaomi-mimo" }, { status: 400 });
return NextResponse.json({ success: true });
}
@@ -261,13 +310,75 @@ export async function POST(request, { params }) {
let ok = false;
if (provider === "trae") ok = registerTraeSession({ state });
else if (provider === "windsurf") ok = registerWindsurfSession({ state });
else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier });
else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier, systemId: body?.systemId });
else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 });
return NextResponse.json({ success: ok });
}
if (action === "exchange") {
const { code, redirectUri, codeVerifier, state, meta } = body;
const { code, redirectUri, codeVerifier, state, meta, systemId } = body;
// Xiaomi MiMo: no token exchange needed — the callback already decrypted the sk.
// Just read the session result and create the connection.
if (provider === "xiaomi-mimo") {
if (!state) {
return NextResponse.json({ error: "Missing state" }, { status: 400 });
}
const session = getXiaomiMimoSessionStatus(state);
if (!session || session.status !== "done" || !session.result) {
return NextResponse.json(
{ error: session?.error || "OAuth session not completed. Please restart the login flow." },
{ status: 400 },
);
}
const { uid, accessToken, baseUrl } = session.result;
// Desktop-exclusive Preview models authenticate with the account-session
// passToken, which only lives in MiMo Desktop's cookie store — attach it
// to the connection so those models work right after OAuth.
let passToken = null;
try {
passToken = await readDesktopPassToken();
} catch {
// Desktop not installed / cookie DB locked — preview models stay unavailable.
}
try {
const connection = await createProviderConnection({
provider: "xiaomi-mimo",
authType: "oauth",
accessToken,
refreshToken: null,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
email: uid ? `${uid}@xiaomi` : null,
displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo",
providerSpecificData: {
uid: uid || null,
baseUrl: baseUrl || "https://api.xiaomimimo.com/v1",
authMethod: "oauth",
mimoPassToken: passToken?.passToken || null,
mimoUserId: passToken?.userId || null,
mimoCUserId: passToken?.cUserId || null,
},
testStatus: "active",
});
clearXiaomiMimoSession(state);
stopXiaomiMimoProxy();
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
},
});
} catch (err) {
clearXiaomiMimoSession(state);
stopXiaomiMimoProxy();
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// Trae/Windsurf: code is either a raw callback URL or a pasted token.
// exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction.
@@ -349,8 +460,13 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl)
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, meta);
// Exchange code for tokens (meta carries provider-specific params, e.g. gitlab clientId/baseUrl).
// systemId (Zed) is merged into meta so the login attempt's own id is
// used instead of a freshly prepared one. Ignored by other providers.
const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state, {
...(meta || {}),
...(systemId ? { systemId } : {}),
});
// Save to database
const connection = await createProviderConnection({
@@ -0,0 +1,136 @@
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
/**
* POST /api/oauth/xiaomi-mimo/api-key
* Import a Xiaomi MiMo API key manually (or from auto-import).
* The key is validated against the models endpoint, then stored.
*
* Body: { apiKey, uid?, baseUrl? }
*/
export async function POST(request) {
try {
const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId } = await request.json();
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
return NextResponse.json(
{ error: "API key is required" },
{ status: 400 },
);
}
const key = apiKey.trim();
if (!key.startsWith("sk-")) {
return NextResponse.json(
{ error: "Invalid key format — expected sk- prefix" },
{ status: 400 },
);
}
const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, "");
// Validate the key against the models endpoint
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;
}
} catch {
// Network error — still allow import (key may be valid but network blocked)
}
if (!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
const { getProviderConnections, updateProviderConnection } = await import("@/models");
const existing = (await getProviderConnections()).find(
(c) => c.provider === "xiaomi-mimo" && (
(uid && c.email === `${uid}@xiaomi`) ||
c.accessToken === key
),
);
if (existing) {
const updated = await updateProviderConnection(existing.id, {
accessToken: key,
providerSpecificData: {
...existing.providerSpecificData,
uid: uid || existing.providerSpecificData?.uid || null,
baseUrl: effectiveBaseUrl,
// Per-account session credential — enables multi-account rotation.
mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null,
mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null,
mimoCUserId: mimoCUserId || existing.providerSpecificData?.mimoCUserId || null,
modelCount,
},
testStatus: validated ? "active" : existing.testStatus,
});
return NextResponse.json({
success: true,
validated,
modelCount,
updated: true,
connection: {
id: existing.id,
provider: existing.provider,
email: existing.email,
displayName: existing.displayName,
},
});
}
const connection = await createProviderConnection({
provider: "xiaomi-mimo",
authType: "api_key",
accessToken: key,
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",
providerSpecificData: {
uid: uid || null,
baseUrl: effectiveBaseUrl,
authMethod: "api_key",
provider: "API Key",
modelCount,
// Per-account session credential — enables multi-account rotation.
mimoPassToken: mimoPassToken || null,
mimoUserId: mimoUserId || null,
mimoCUserId: mimoCUserId || null,
},
testStatus: validated ? "active" : "untested",
});
return NextResponse.json({
success: true,
validated,
modelCount,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
},
});
} catch (error) {
console.log("Xiaomi MiMo API key import error:", error);
return NextResponse.json(
{ error: "API key import failed" },
{ status: 500 },
);
}
}
@@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import { readFile, access, constants } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { readDesktopPassToken } from "open-sse/shared/mimoAccount.js";
/**
* GET /api/oauth/xiaomi-mimo/auto-import
* Auto-detect Xiaomi MiMo credentials from local auth.json.
*
* Sources (in priority order):
* 1. ~/.local/share/mimocode/auth.json → xiaomi field
* 2. %APPDATA%/Xiaomi MiMo/... → (future: Desktop keychain)
*
* auth.json shape:
* {
* "xiaomi": {
* "type": "api",
* "key": "sk-xxxx",
* "metadata": { "uid": "...", "base_url": "https://api.xiaomimimo.com/v1" }
* }
* }
*/
function getCandidatePaths() {
const home = homedir();
const paths = [];
// MiMoCode / MiMo Desktop shared data dir (cross-platform XDG)
paths.push(join(home, ".local", "share", "mimocode", "auth.json"));
// Windows: also check USERPROFILE-based XDG
if (process.platform === "win32") {
const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
// Desktop's own storage (may have separate credentials in the future)
paths.push(join(appData, "Xiaomi MiMo", "auth.json"));
}
// macOS
if (process.platform === "darwin") {
paths.push(
join(home, "Library", "Application Support", "mimocode", "auth.json"),
);
}
return paths;
}
/**
* GET /api/oauth/xiaomi-mimo/auto-import
*/
export async function GET() {
try {
const candidates = getCandidatePaths();
let authPath = null;
for (const candidate of candidates) {
try {
await access(candidate, constants.R_OK);
authPath = candidate;
break;
} catch {
// Try next candidate
}
}
if (!authPath) {
return NextResponse.json({
found: false,
error: `Xiaomi MiMo Desktop auth file not found. Checked:\n${candidates.join("\n")}\n\nMake sure Xiaomi MiMo Desktop is installed and you are signed in.`,
});
}
const raw = await readFile(authPath, "utf-8");
let auth;
try {
auth = JSON.parse(raw);
} catch {
return NextResponse.json({
found: false,
error: "auth.json is not valid JSON. Please sign in to Xiaomi MiMo Desktop again.",
});
}
const xiaomi = auth?.xiaomi;
if (!xiaomi || !xiaomi.key) {
return NextResponse.json({
found: false,
error: "No Xiaomi credentials found in auth.json. Please sign in to Xiaomi MiMo Desktop.",
});
}
// Validate key format
const key = String(xiaomi.key).trim();
if (!key.startsWith("sk-")) {
return NextResponse.json({
found: false,
error: "Xiaomi key does not appear to be a valid API key (expected sk- prefix).",
});
}
const metadata = xiaomi.metadata || {};
const uid = metadata.uid || null;
const baseUrl = metadata.base_url || "https://api.xiaomimimo.com/v1";
// Account-session passToken from Desktop's cookie store. Persisting it per
// connection is what lets multiple Xiaomi accounts rotate independently.
// (null while Desktop is running — its cookie DB is exclusively locked.)
let mimoPassToken = null;
let mimoUserId = null;
let mimoCUserId = null;
try {
const pt = await readDesktopPassToken();
if (pt) {
mimoPassToken = pt.passToken;
mimoUserId = pt.userId;
mimoCUserId = pt.cUserId;
}
} catch (e) {
console.log("[xiaomi-mimo] passToken read failed (non-fatal):", e.message);
}
return NextResponse.json({
found: true,
apiKey: key,
uid,
baseUrl,
source: authPath,
mimoPassToken,
mimoUserId,
mimoCUserId,
});
} catch (error) {
console.log("Xiaomi MiMo auto-import error:", error);
return NextResponse.json(
{ found: false, error: error.message },
{ status: 500 },
);
}
}
+72 -1
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
import { GEMINI_CONFIG, ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth";
import { refreshGoogleToken, refreshCodexToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
@@ -11,6 +11,8 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
import { resolveZedModels } from "open-sse/shared/zedAuth.js";
import { resolveClineModels, resolveClinepassModels } from "open-sse/services/clinepassModels.js";
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
@@ -286,6 +288,75 @@ const PROVIDER_MODELS_CONFIG = {
};
},
},
// Zed has no static catalog by design (live /models only) — same cursor
// direct pattern: resolve with the connection's own credentials (never
// exposed to the browser), return rich metadata, drop disabled entries.
// Empty/failure yields an explicit warning, never a silent zero list.
zed: {
customResolver: async (connection) => {
try {
const result = await resolveZedModels({
accessToken: connection.accessToken,
providerSpecificData: connection.providerSpecificData || {},
}, { config: ZED_HOSTED_CONFIG, forceRefresh: true });
const models = (result?.models || [])
.filter((m) => m && !m.isDisabled)
.map((m) => ({
id: m.id,
name: m.name || m.id,
provider: m.provider,
contextLength: m.contextLength,
contextLengthInMaxMode: m.contextLengthInMaxMode,
maxOutputTokens: m.maxOutputTokens,
supportsTools: m.supportsTools,
supportsImages: m.supportsImages,
supportsThinking: m.supportsThinking,
supportsDisablingThinking: m.supportsDisablingThinking,
supportsFastMode: m.supportsFastMode,
supportsServerSideCompaction: m.supportsServerSideCompaction,
supportedEffortLevels: m.supportedEffortLevels || [],
supportsStreamingTools: m.supportsStreamingTools,
supportsParallelToolCalls: m.supportsParallelToolCalls,
}));
if (models.length > 0) return { models };
return { models: [], warning: "Zed returned no live models." };
} catch (error) {
console.log("Failed to fetch Zed models dynamically:", error.message);
return { models: [], warning: `Failed to fetch Zed models: ${error.message}` };
}
},
},
// Cline/ClinePass share api.cline.bot/api/v1/models. The service layer already
// handles Bearer-vs-`workos:` auth and swallows failures into null, so these follow
// the cursor direct pattern (no refreshFn) and only differ in filtering:
// cline returns the whole catalog verbatim, clinepass keeps cline-pass/* only.
cline: {
customResolver: async (connection) => {
const result = await resolveClineModels({
accessToken: connection.accessToken,
apiKey: connection.apiKey,
});
if (result?.models?.length) return { models: result.models };
return {
models: getStaticProviderModels("cline"),
warning: "Cline returned no live models; falling back to static catalog.",
};
},
},
clinepass: {
customResolver: async (connection) => {
const result = await resolveClinepassModels({
accessToken: connection.accessToken,
apiKey: connection.apiKey,
});
if (result?.models?.length) return { models: result.models };
return {
models: getStaticProviderModels("clinepass"),
warning: "ClinePass returned no live models; falling back to static catalog.",
};
},
},
// Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows)
kiro: {
+8 -1
View File
@@ -4,6 +4,7 @@ import { testProxyUrl } from "@/lib/network/proxyTest";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { getDefaultModel } from "open-sse/config/providerModels.js";
import { resolveOllamaLocalHost, PROVIDERS } from "open-sse/config/providers.js";
import { CODEX_CLI_VERSION } from "open-sse/config/appConstants.js";
import {
refreshProviderCredentials,
shouldRefreshCredentials,
@@ -27,7 +28,7 @@ const OAUTH_TEST_CONFIG = {
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "Content-Type": "application/json", "originator": "codex_cli_rs", "User-Agent": "codex_cli_rs/0.136.0" },
extraHeaders: { "Content-Type": "application/json", "originator": "codex_cli_rs", "User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}` },
// Minimal invalid body — triggers fast 400 without consuming quota
body: JSON.stringify({ model: "gpt-5.3-codex", input: [], stream: false, store: false }),
// 400 (bad request) means auth succeeded; only 401/403 means token is bad
@@ -765,6 +766,12 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
const valid = !!(data && data.user);
return { valid, error: valid ? null : "Session expired — re-paste cookie" };
}
case "opencode": {
const res = await fetchWithConnectionProxy("https://opencode.ai/zen/v1/models", {
headers: { Authorization: "Bearer public", "User-Agent": "opencode/1.18.31" },
}, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "OpenCode free tier unavailable" };
}
case "opencode-go": {
const res = await fetchWithConnectionProxy("https://opencode.ai/zen/go/v1/chat/completions", {
method: "POST",
@@ -26,4 +26,10 @@ export const FILTERS = {
(Array.isArray(models) ? models : [])
.filter((m) => m.id?.startsWith("mimo") || m.name?.toLowerCase().includes("mimo"))
.map((m) => ({ id: m.id, name: m.name || m.id })),
"airforce-free": (models) =>
(Array.isArray(models) ? models : [])
.filter((m) => (m.tier === "free" || m.id?.endsWith(":free")) && m.supports_chat === true && (!m.media_type || m.media_type === "chat" || m.media_type === "text"))
.map((m) => ({ id: m.id, name: m.name || m.id, contextLength: m.context_length }))
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
};
+16 -6
View File
@@ -9,9 +9,9 @@ import { getProviderConnections, getCombos, getCustomModels, getModelAliases } f
import { getDisabledModels } from "@/lib/disabledModelsDb";
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
import { resolveQoderModels, routableQoderModels } from "open-sse/services/qoderModels.js";
import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
import { resolveClinepassModels, resolveClineModels } from "open-sse/services/clinepassModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
import { resolveZedModels } from "open-sse/shared/zedAuth.js";
@@ -34,15 +34,18 @@ const LIVE_MODEL_RESOLVERS = {
qoder: async (conn) => {
const result = await resolveQoderModels({
accessToken: conn.accessToken,
// PAT (pt-...) connections keep the token in apiKey; without it the live
// catalog silently fails and /v1/models falls back to the static list.
apiKey: conn.apiKey,
refreshToken: conn.refreshToken,
email: conn.email,
displayName: conn.displayName,
providerSpecificData: conn.providerSpecificData || {}
});
if (!result?.models?.length) return null;
return {
models: result.models.map((m) => ({ id: m.id, name: m.name })),
};
// Visible + hidden (enable:false) catalog keys — chat routes all of them.
const models = routableQoderModels(result);
if (!models.length) return null;
return { models: models.map((m) => ({ id: m.id, name: m.name })) };
},
kimchi: async (conn) => {
const result = await resolveKimchiModels({
@@ -76,6 +79,13 @@ const LIVE_MODEL_RESOLVERS = {
});
return result?.models?.length ? { models: result.models } : null;
},
cline: async (conn) => {
const result = await resolveClineModels({
accessToken: conn.accessToken,
apiKey: conn.apiKey,
});
return result?.models?.length ? { models: result.models } : null;
},
"grok-cli": async (conn) => {
const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {});
const result = await resolveGrokCliModels({
+2
View File
@@ -7,6 +7,7 @@ import { DATA_DIR } from "@/lib/dataDir";
import { getSettings } from "@/lib/localDb";
const DEFAULT_PASSWORD = "123456";
const SESSION_MAX_AGE_SEC = 24 * 60 * 60;
function loadJwtSecret() {
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
@@ -64,6 +65,7 @@ export async function setDashboardAuthCookie(cookieStore, request, claims = {})
secure: shouldUseSecureCookie(request),
sameSite: "lax",
path: "/",
maxAge: SESSION_MAX_AGE_SEC,
});
}
+4
View File
@@ -19,6 +19,10 @@ async function tryBunSqlite() {
async function tryBetterSqlite() {
// Skip on Bun — better-sqlite3 native bindings unsupported
if (process.versions.bun) return null;
// Skip on Node >= 24: the native addon SIGSEGVs on load there, which is a
// process-level crash the try/catch below cannot recover from. node:sqlite covers it.
const [nodeMajor] = process.versions.node.split(".").map(Number);
if (nodeMajor >= 24) return null;
try {
const { createBetterSqliteAdapter } = await import("./adapters/betterSqliteAdapter.js");
return createBetterSqliteAdapter(DATA_FILE);
+26 -2
View File
@@ -11,6 +11,28 @@ const OPTIONAL_FIELDS = [
"proxyRotationStrategy", "proxyPoolIds",
];
const MODEL_LOCK_PREFIX = "modelLock_";
function resetHealthStateOnActivation(existing, patch) {
if (patch?.testStatus !== "active") return patch;
const normalized = {
...patch,
testStatus: "active",
lastError: Object.hasOwn(patch, "lastError") ? patch.lastError : null,
lastErrorAt: Object.hasOwn(patch, "lastErrorAt") ? patch.lastErrorAt : null,
errorCode: null,
rateLimitedUntil: null,
backoffLevel: 0,
};
for (const key of Object.keys(existing || {})) {
if (key.startsWith(MODEL_LOCK_PREFIX)) normalized[key] = null;
}
return normalized;
}
function rowToConn(row) {
if (!row) return null;
const extra = parseJson(row.data, {});
@@ -148,7 +170,8 @@ export async function createProviderConnection(data) {
// access_token: never dedup — user manages duplicates manually
if (existing) {
const merged = { ...existing, ...data, updatedAt: now };
const normalized = resetHealthStateOnActivation(existing, data);
const merged = { ...existing, ...normalized, updatedAt: now };
upsert(db, merged);
result = merged;
return;
@@ -197,7 +220,8 @@ export async function updateProviderConnection(id, data) {
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
if (!row) { result = null; return; }
const existing = rowToConn(row);
const merged = { ...existing, ...data, updatedAt: new Date().toISOString() };
const normalized = resetHealthStateOnActivation(existing, data);
const merged = { ...existing, ...normalized, updatedAt: new Date().toISOString() };
upsert(db, merged);
if (data.priority !== undefined) reorderInTx(db, existing.provider);
result = merged;
+63 -42
View File
@@ -6,7 +6,7 @@
import fs from "node:fs";
import path from "node:path";
import { CATALOG_FILE, CATALOG_RAW_FILE, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js";
import { CATALOG_FILE, CATALOG_RAW_FILE, CATALOG_VERSION, invalidateCatalog, installCatalogSource } from "open-sse/providers/catalogOverride.js";
const CATALOG_URL = "https://models.dev/api.json";
const FETCH_TIMEOUT_MS = 60000;
@@ -16,16 +16,14 @@ const STARTUP_DELAY_MS = 60 * 1000; // let the server boot and serve first req
const RETRY_DELAY_MS = 30 * 60 * 1000;
const MODALITY_BY_INPUT = { image: "vision", pdf: "pdf", audio: "audioInput", video: "videoInput" };
// Gateways disagree about the same model, so a modality needs a majority of
// them to declare it — one reseller mislabelling a text model must not win.
const MIN_SHARE = 0.5;
// Ignore limit differences below this: gateways round 200000 vs 202752.
const LIMIT_TOLERANCE = 0.1;
// 9router provider id -> models.dev provider id, for context/maxOutput only.
// Providers absent here keep whatever the local pattern table resolves; names
// that already match are resolved automatically.
const PROVIDER_ALIASES = {
// 9router provider id -> models.dev provider id: the same gateway under another
// name. Both halves of the catalog are stored against the local id, so this runs
// while building rather than on every lookup. Providers absent here keep whatever
// the local pattern table resolves; names that already match need no entry.
export const PROVIDER_ALIASES = {
"glm": "zai",
"glm-cn": "zhipuai",
"claude": "anthropic",
@@ -40,7 +38,7 @@ const PROVIDER_ALIASES = {
"cloudflare-ai": "cloudflare-workers-ai",
};
let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null };
let state = { running: false, lastSync: null, lastError: null, lastResult: null, etag: null, fileVersion: null };
let timer = null;
export function getSyncState() {
@@ -78,40 +76,57 @@ function slim(catalog) {
return out;
}
function build(catalog, entries) {
// Index once: per provider for limits, and tallied across all of them for
// modalities.
const byProvider = {};
const tally = {};
for (const [providerId, provider] of Object.entries(catalog)) {
const models = {};
const counted = new Set();
for (const [modelId, model] of Object.entries(provider?.models || {})) {
const id = baseId(modelId);
models[id] = model;
// One vote per provider: several ids can normalize to the same model
// (claude-opus-4-thinking:1024, :8192, :32768 …) and must not stack.
if (counted.has(id)) continue;
counted.add(id);
const counts = tally[id] || (tally[id] = { total: 0 });
counts.total++;
for (const input of model?.modalities?.input || []) {
const key = MODALITY_BY_INPUT[input];
if (key) counts[key] = (counts[key] || 0) + 1;
}
}
byProvider[providerId] = models;
export function build(catalog, entries) {
// Upstream provider id -> the local ids it belongs to, taken from the registry
// snapshot so a gateway listed upstream under another name is still filed
// under the name requests arrive with. One upstream name can back more than one
// local id (glm-cn and zhipu are both zhipuai) and each has to resolve; the
// snapshot only covers the built-in registry, so an upstream provider it does
// not mention keeps its own name.
const localIds = new Map();
for (const { provider } of entries) {
const upstreamId = PROVIDER_ALIASES[provider] || provider;
let locals = localIds.get(upstreamId);
if (!locals) localIds.set(upstreamId, (locals = []));
if (!locals.includes(provider)) locals.push(provider);
}
// Modalities belong to the model — every gateway serving it has the same
// weights — so they are keyed by model id and shared across providers.
// Index once: the raw upstream record per provider+model for limits, and the
// modalities each gateway declares for it.
const byProvider = {};
// Modalities are recorded per gateway upstream and gateways disagree about the
// same weights — some do not proxy images at all — so the key is provider +
// model. Keying by model id alone let short ids collide across vendors: "auto",
// "free" and "efficient" are router modes in one catalog and model names in
// another, so a router mode inherited a stranger's vision.
const models = {};
for (const [id, counts] of Object.entries(tally)) {
const declared = {};
for (const key of Object.values(MODALITY_BY_INPUT)) {
if ((counts[key] || 0) / counts.total >= MIN_SHARE) declared[key] = true;
for (const [providerId, provider] of Object.entries(catalog)) {
const locals = localIds.get(providerId) || [providerId];
const modelsById = {};
const seen = new Set();
for (const [modelId, model] of Object.entries(provider?.models || {})) {
const id = baseId(modelId);
modelsById[id] = model;
// One entry per provider+model: several upstream ids can normalize to the
// same model (claude-opus-4-thinking:1024, :8192, :32768 …) and must not
// stack their modalities.
if (seen.has(id)) continue;
seen.add(id);
const declared = {};
for (const input of model?.modalities?.input || []) {
const key = MODALITY_BY_INPUT[input];
if (key) declared[key] = true;
}
if (Object.keys(declared).length) {
// Filed under every local id requests arrive with, and under the upstream
// id too: a custom provider node can carry the upstream name without
// appearing in the registry snapshot, and nothing else would resolve for
// it. The reader takes whichever key it is handed.
for (const local of locals) models[`${local}:${id}`] = declared;
if (!locals.includes(providerId)) models[`${providerId}:${id}`] = declared;
}
}
if (Object.keys(declared).length) models[id] = declared;
byProvider[providerId] = modelsById;
}
// Limits belong to the gateway — each truncates differently — so only the
@@ -172,7 +187,9 @@ export async function syncModelCatalog() {
state.running = true;
try {
const headers = { accept: "application/json" };
if (state.etag) headers["if-none-match"] = state.etag;
// A file written by an older schema has to be rebuilt even when upstream is
// unchanged, so only ask upstream for a 304 when the file is current.
if (state.etag && state.fileVersion === CATALOG_VERSION) headers["if-none-match"] = state.etag;
const response = await fetch(CATALOG_URL, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
let result;
@@ -187,12 +204,13 @@ export async function syncModelCatalog() {
const etag = response.headers.get("etag") || null;
const entries = await collectEntries();
const { models, providers } = build(catalog, entries);
const serialized = JSON.stringify({ v: 1, etag, syncedAt: Date.now(), models, providers });
const serialized = JSON.stringify({ v: CATALOG_VERSION, etag, syncedAt: Date.now(), models, providers });
writeAtomic(CATALOG_FILE, serialized);
writeAtomic(CATALOG_RAW_FILE, JSON.stringify(slim(catalog)));
state.etag = etag;
state.fileVersion = CATALOG_VERSION;
invalidateCatalog();
result = {
status: "updated",
@@ -223,10 +241,13 @@ export async function syncModelCatalog() {
// of re-downloading 4.3MB to be told nothing changed.
function restoreEtag() {
try {
state.etag = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8")).etag || null;
const parsed = JSON.parse(fs.readFileSync(CATALOG_FILE, "utf8"));
state.etag = parsed.etag || null;
state.fileVersion = parsed.v || 1;
state.lastSync = fs.statSync(CATALOG_FILE).mtimeMs;
} catch {
state.etag = null;
state.fileVersion = null;
}
}
+15
View File
@@ -133,6 +133,21 @@ export const FREEBUFF_CONFIG = { ...PROVIDER_OAUTH["freebuff"] };
// 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true
// 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt}
// 5) POST GetUserInfo (x-cloudide-token) → email/name
// Xiaomi MiMo Desktop OAuth — custom ECDH encrypted-callback flow (NOT standard OAuth2).
// 1) Client generates X25519 keypair
// 2) Browser opens ${platformUrl}/authorize?pk=<pubkey>&redirect_uri=http://localhost:<port>/&kn=mimocode&key_name=...
// 3) Redirect → http://localhost:<port>/?u=<base64 encrypted payload>
// 4) Decrypt: ECDH(shared) → SHA256 → AES-256-GCM
// Layout: [12-byte nonce][32-byte ephemeral pubkey][ciphertext][16-byte GCM tag]
// 5) Result JSON: { uid, sk, url }
export const XIAOMI_MIMO_CONFIG = {
platformUrl: process.env.MIMO_PLATFORM_URL || "https://platform.xiaomimimo.com",
defaultBaseUrl: "https://api.xiaomimimo.com/v1",
kn: "mimocode",
callbackPath: "/",
timeoutMs: 300000, // 5 minutes
};
export const TRAE_CONFIG = {
clientId: "ono9krqynydwx5",
clientSecret: "-",
+5
View File
@@ -114,6 +114,11 @@ export async function generateAuthData(providerName, redirectUri, meta) {
flowType: provider.flowType,
fixedPort: provider.fixedPort,
callbackPath: provider.callbackPath || "/callback",
// Zed: surface the system_id embedded in the sign-in URL so the frontend
// can thread it through register-session → exchange → stored connection
// (exchangeTokens re-runs prepareConfig, which would otherwise mint a
// different one). Absent for every other provider — purely additive.
...(config.systemId ? { systemId: config.systemId } : {}),
};
}
+123
View File
@@ -0,0 +1,123 @@
import crypto from "crypto";
import { XIAOMI_MIMO_CONFIG } from "../constants/oauth.js";
// ───────────────────────────────────────────────────────────────────────────
// Xiaomi MiMo OAuth helpers
// Custom ECDH + AES-256-GCM encrypted-callback flow (NOT standard OAuth2).
// ───────────────────────────────────────────────────────────────────────────
/**
* Generate an X25519 keypair for the OAuth handshake.
* @returns {{ publicKey: string, privateKeyDer: Buffer }}
* publicKey — base64 SPKI (for the `pk` URL param)
* privateKeyDer — PKCS8 DER Buffer (for ECDH later)
*/
export function generateKeyPair() {
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
const publicKeyDer = publicKey.export({ format: "der", type: "spki" });
// SPKI for X25519 is 44 bytes; the raw 32-byte key is the last 32 bytes.
// But the platform expects the full base64 SPKI — pass as-is.
const publicKeyB64 = publicKeyDer.toString("base64");
const privateKeyDer = privateKey.export({ format: "der", type: "pkcs8" });
return { publicKey: publicKeyB64, privateKeyDer };
}
/**
* Decrypt the `u` query parameter from the Xiaomi OAuth callback.
*
* Wire format (base64-decoded):
* bytes 0..11 — 12-byte AES-GCM nonce
* bytes 12..43 — 32-byte ephemeral public key (raw X25519)
* bytes 44..n-16 — ciphertext
* last 16 bytes — GCM auth tag
*
* Key derivation: SHA256(ECDH(clientPrivateKey, ephemeralPublicKey))
*
* @param {Buffer} privateKeyDer — PKCS8 DER private key from generateKeyPair()
* @param {string} encryptedB64 — the `u` query param value (base64)
* @returns {{ uid: string, sk: string, url?: string }}
*/
export function decryptCallback(privateKeyDer, encryptedB64) {
const raw = Buffer.from(encryptedB64, "base64");
if (raw.length < 12 + 32 + 16 + 1) {
throw new Error(`Encrypted payload too short: ${raw.length} bytes`);
}
const nonce = raw.subarray(0, 12);
const ephemeralPubRaw = raw.subarray(12, 44);
const ciphertextAndTag = raw.subarray(44);
const tag = ciphertextAndTag.subarray(ciphertextAndTag.length - 16);
const ciphertext = ciphertextAndTag.subarray(0, ciphertextAndTag.length - 16);
// Reconstruct the ephemeral public key as SPKI DER for Node crypto.
// X25519 SPKI prefix: 302a300506032b656e032100
const ephemeralPub = crypto.createPublicKey({
key: Buffer.concat([
Buffer.from("302a300506032b656e032100", "hex"),
ephemeralPubRaw,
]),
format: "der",
type: "spki",
});
const privateKey = crypto.createPrivateKey({
key: privateKeyDer,
format: "der",
type: "pkcs8",
});
const sharedSecret = crypto.diffieHellman({ privateKey, publicKey: ephemeralPub });
const derivedKey = crypto.createHash("sha256").update(sharedSecret).digest();
const decipher = crypto.createDecipheriv("aes-256-gcm", derivedKey, nonce);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const parsed = JSON.parse(decrypted.toString("utf-8"));
if (!parsed || typeof parsed !== "object") {
throw new Error("Decrypted payload is not a valid object");
}
return {
uid: parsed.uid || null,
sk: parsed.sk || null,
url: parsed.url || XIAOMI_MIMO_CONFIG.defaultBaseUrl,
};
}
/**
* Build the browser authorization URL.
* @param {string} publicKey — base64 SPKI from generateKeyPair()
* @param {string} redirectUri — e.g. http://localhost:12345/
* @param {string} [keyName] — optional stable key name
* @returns {string}
*/
export function buildAuthorizeUrl(publicKey, redirectUri, keyName) {
const params = new URLSearchParams({
pk: publicKey,
redirect_uri: redirectUri,
kn: XIAOMI_MIMO_CONFIG.kn,
});
if (keyName) params.set("key_name", keyName);
return `${XIAOMI_MIMO_CONFIG.platformUrl}/authorize?${params.toString()}`;
}
/**
* Get or create a stable key name for this installation.
* Stored in the 9Router data dir so re-auth reuses the same name.
*/
export function getKeyName() {
// Use a deterministic name based on machine — avoids needing filesystem writes
// in the OAuth provider layer. The platform treats key_name as a label only.
const machineId = crypto
.createHash("sha256")
.update(`${process.platform}-${process.env.COMPUTERNAME || process.env.HOSTNAME || "unknown"}`)
.digest("hex")
.slice(0, 8);
return `9router-xmd-${machineId}`;
}
+6 -2
View File
@@ -21,11 +21,15 @@ const zed = {
return { ...config, ...auth };
},
buildAuthUrl: (config, redirectUri, state) => config.authUrl,
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta) => {
// code = raw callback URL/query; codeVerifier = encoded private key verifier.
const { userId, encryptedAccessToken } = parseZedCallbackPayload(code);
const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier);
return { accessToken, userId, systemId: config.systemId };
// Prefer the system_id registered for this login attempt (threaded via
// meta from register-session); fall back to the prepared config. Never
// mint a fresh one here — exchangeTokens re-runs prepareConfig, which
// would otherwise store a system_id unrelated to the zed.dev login.
return { accessToken, userId, systemId: meta?.systemId || config.systemId };
},
postExchange: async (tokens) => {
const credentials = {
+221 -5
View File
@@ -648,9 +648,15 @@ let zedProxyTimeout = null;
let zedProxyPort = null;
let zedSession = null;
export function registerZedSession({ state, codeVerifier }) {
export function registerZedSession({ state, codeVerifier, systemId }) {
if (!state || !codeVerifier) return false;
zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() };
zedSession = {
state,
codeVerifier,
systemId: systemId || null,
status: "pending",
createdAt: Date.now(),
};
return true;
}
export function getZedSessionStatus(state) {
@@ -665,6 +671,10 @@ export function clearZedSession(state) {
export function startZedProxy(preferredPort = 0) {
return new Promise((resolve) => {
if (zedProxyServer) {
// Reuse the live listener, but renew its idle timeout so a previous
// flow's deadline can never kill the flow that just adopted the port.
if (zedProxyTimeout) clearTimeout(zedProxyTimeout);
zedProxyTimeout = setTimeout(() => { console.log("[Zed proxy] timeout, stopping"); stopZedProxy(); }, ZED_HOSTED_CONFIG.oauthTimeoutMs);
resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` });
return;
}
@@ -694,13 +704,34 @@ export function startZedProxy(preferredPort = 0) {
res.end(renderCodexResultPage(false, "Cross-origin callback rejected"));
return;
}
// A genuine Zed redirect always carries user_id + access_token. Anything
// else (probe, prefetch, stray navigation, favicon-style miss) is NOT
// the callback: answer without touching the session and WITHOUT
// stopping the server, so the real redirect can still land afterwards.
const qp = url.searchParams;
const hasZedParams =
qp.has("user_id") || qp.has("userId") ||
qp.has("access_token") || qp.has("accessToken") || qp.has("token");
if (!hasZedParams) {
console.log(`[Zed proxy] ignoring non-callback ${req.method} ${url.pathname} (session kept, server kept)`);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, "Waiting for Zed sign-in — this request carried no login data."));
return;
}
// Pass raw callback path+query to exchangeTokens → parseZedCallbackPayload.
// codeVerifier carries the encoded RSA private key for decryption.
const rawCallback = url.search ? `${url.pathname}?${url.searchParams.toString()}` : url.pathname;
try {
const { exchangeTokens } = await import("../providers.js");
const { createProviderConnection } = await import("@/models");
const tokenData = await exchangeTokens("zed", rawCallback, null, session.codeVerifier, session.state);
const tokenData = await exchangeTokens(
"zed",
rawCallback,
null,
session.codeVerifier,
session.state,
session.systemId ? { systemId: session.systemId } : undefined,
);
const connection = await createProviderConnection({
provider: "zed",
authType: "oauth",
@@ -712,13 +743,16 @@ export function startZedProxy(preferredPort = 0) {
session.email = connection.email;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(true, "You can close this window."));
stopZedProxy();
} catch (err) {
session.status = "error";
session.error = err.message;
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderCodexResultPage(false, err.message));
} finally {
stopZedProxy();
// Intentionally NOT stopping here: the failure may belong to a
// superseded attempt (e.g. an older popup landing after "Try Again"
// registered a new keypair). The live attempt's genuine callback must
// still land. The idle timeout + modal close bound the listener.
}
});
const tryPort = Number(preferredPort) || 0;
@@ -755,3 +789,185 @@ export function stopZedProxy() {
zedProxyPort = null;
}
// ───────────────────────────────────────────────────────────────────────────
// Xiaomi MiMo Desktop OAuth callback proxy
// Receives the ECDH-encrypted `u` param, decrypts it, stores the session.
// ───────────────────────────────────────────────────────────────────────────
let xiaomiMimoProxyServer = null;
let xiaomiMimoProxyPort = null;
let xiaomiMimoProxyTimeout = null;
const xiaomiMimoSessions = new Map();
export function registerXiaomiMimoSession({ state, privateKeyDer }) {
if (!state || !privateKeyDer) return false;
xiaomiMimoSessions.set(state, {
privateKeyDer,
status: "pending",
createdAt: Date.now(),
});
return true;
}
export function getXiaomiMimoSessionStatus(state) {
const s = xiaomiMimoSessions.get(state);
if (!s) return null;
// Don't leak the private key to the client
return { status: s.status, result: s.result || null, error: s.error || null };
}
export function clearXiaomiMimoSession(state) {
xiaomiMimoSessions.delete(state);
}
function renderXiaomiMimoResultPage(success, message) {
const color = success ? "#22c55e" : "#ef4444";
const icon = success ? "&#10003;" : "&#10007;";
const title = success ? "Authentication Successful" : "Authentication Failed";
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>${title}</title>
<style>
body { font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.icon { color: ${color}; font-size: 3rem; }
h1 { margin: 1rem 0; font-size: 1.25rem; }
p { color: #666; font-size: 0.875rem; }
</style>
</head>
<body>
<div class="container">
<div class="icon">${icon}</div>
<h1>${title}</h1>
<p>${message || (success ? "You can close this tab and return to 9Router." : "Please try again.")}</p>
${success ? "<script>setTimeout(() => window.close(), 3000);</script>" : ""}
</div>
</body>
</html>`;
}
/**
* Start the Xiaomi Desktop OAuth callback proxy.
* @returns {Promise<{success: boolean, port?: number, callbackUrl?: string, reason?: string}>}
*/
export function startXiaomiMimoProxy() {
return new Promise((resolve) => {
if (xiaomiMimoProxyServer) {
resolve({
success: true,
port: xiaomiMimoProxyPort,
callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`,
});
return;
}
const server = http.createServer(async (req, res) => {
// Origin guard
if (!isLoopbackOrigin(req.headers.origin)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
const url = new URL(req.url, "http://127.0.0.1");
const u = url.searchParams.get("u");
if (!u) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(false, "Missing encrypted payload (u parameter)."));
return;
}
// Try each pending session's private key — the callback URL carries no
// state param, so we attempt decryption with every pending key.
const pendingSessions = [...xiaomiMimoSessions.entries()]
.filter(([, s]) => s.status === "pending");
if (pendingSessions.length === 0) {
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(false, "No active OAuth session. Please restart the login flow."));
return;
}
try {
const { decryptCallback } = await import("../providers/xiaomi-mimo.js");
let result = null;
let matchedState = null;
for (const [state, session] of pendingSessions) {
try {
result = decryptCallback(session.privateKeyDer, u);
matchedState = state;
break;
} catch {
// Wrong key for this session — try next
}
}
if (!result || !matchedState) {
throw new Error("Could not decrypt with any pending session key");
}
if (!result.sk) {
throw new Error("Decrypted payload missing sk (API key)");
}
// Store result only in the matched session
const session = xiaomiMimoSessions.get(matchedState);
if (session) {
session.status = "done";
session.result = {
uid: result.uid,
accessToken: result.sk,
baseUrl: result.url || "https://api.xiaomimimo.com/v1",
};
}
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(true, "Xiaomi account linked. You can close this tab."));
console.log("[xiaomi-mimo oauth] callback decrypted, uid:", result.uid);
} catch (err) {
console.error("[xiaomi-mimo oauth] decrypt failed:", err.message);
for (const [, session] of pendingSessions) {
session.status = "error";
session.error = err.message;
}
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderXiaomiMimoResultPage(false, `Decryption failed: ${err.message}`));
}
});
server.on("error", (err) => {
console.log("[xiaomi-mimo oauth] listen error:", err.message);
resolve({ success: false, reason: err.message });
});
server.listen(0, "127.0.0.1", () => {
xiaomiMimoProxyServer = server;
xiaomiMimoProxyPort = server.address().port;
xiaomiMimoProxyTimeout = setTimeout(() => {
console.log("[xiaomi-mimo oauth] timeout, stopping");
stopXiaomiMimoProxy();
}, 300000);
console.log(`[xiaomi-mimo oauth] listening on port ${xiaomiMimoProxyPort}`);
resolve({
success: true,
port: xiaomiMimoProxyPort,
callbackUrl: `http://127.0.0.1:${xiaomiMimoProxyPort}/`,
});
});
});
}
export function stopXiaomiMimoProxy() {
console.log(`[xiaomi-mimo oauth] stopping (port ${xiaomiMimoProxyPort || "-"})`);
if (xiaomiMimoProxyTimeout) { clearTimeout(xiaomiMimoProxyTimeout); xiaomiMimoProxyTimeout = null; }
if (xiaomiMimoProxyServer) { xiaomiMimoProxyServer.close(); xiaomiMimoProxyServer = null; }
xiaomiMimoProxyPort = null;
// No callback can arrive once the listener is down, so drop every pending
// session — each holds an X25519 private key and they would otherwise
// accumulate for the process lifetime (one per /authorize call).
xiaomiMimoSessions.clear();
}
+69 -43
View File
@@ -20,6 +20,54 @@ const PROVIDER_ORDER = [
// Providers that need no auth — always show in model selector
const NO_AUTH_PROVIDER_IDS = Object.keys(FREE_PROVIDERS).filter(id => FREE_PROVIDERS[id].noAuth);
// Providers with per-account live catalogs via /api/providers/[id]/models.
// Static registry stays as fallback when live fetch fails or is empty.
const LIVE_CATALOG_PROVIDERS = ["cursor", "cline", "clinepass"];
// Fetch a provider's account-scoped catalog for every active connection and merge
// the results. Entries collapse by model id on purpose: two connections of the
// same provider produce the same picker value (`alias/id`), so keeping the first
// avoids duplicate rows. There is no per-connection metadata to preserve beyond
// {id,name}. Empty array means "nothing live" so callers keep the static fallback.
function useLiveProviderModels(isOpen, connectionIds, label) {
const [models, setModels] = useState([]);
const idsKey = (connectionIds ?? []).join("|");
useEffect(() => {
const ids = idsKey ? idsKey.split("|") : [];
if (!isOpen || ids.length === 0) {
setModels([]);
return undefined;
}
let cancelled = false;
Promise.all(ids.map(async (connectionId) => {
const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" });
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data.models) ? data.models : [];
}))
.then((modelLists) => {
if (cancelled) return;
const seen = new Set();
setModels(modelLists.flat().filter((model) => {
if (!model?.id || seen.has(model.id)) return false;
seen.add(model.id);
return true;
}));
})
.catch((error) => {
// Do not hide the static fallback when the account catalog is unavailable.
console.warn(`Unable to load ${label} models for selector:`, error);
if (!cancelled) setModels([]);
});
return () => { cancelled = true; };
}, [isOpen, idsKey, label]);
return models;
}
export default function ModelSelectModal({
isOpen,
onClose,
@@ -49,48 +97,25 @@ export default function ModelSelectModal({
const [providerNodes, setProviderNodes] = useState([]);
const [customModels, setCustomModels] = useState([]);
const [disabledModels, setDisabledModels] = useState({});
const [cursorModels, setCursorModels] = useState([]);
// Cursor exposes the usable catalog per account. Keep the static catalog only
// as a fallback, since it quickly becomes stale and different accounts can
// have different model entitlements.
const cursorConnectionIds = useMemo(
() => activeProviders
.filter((provider) => provider.provider === "cursor" && provider.id)
.map((provider) => provider.id),
[activeProviders],
);
useEffect(() => {
if (!isOpen || cursorConnectionIds.length === 0) {
setCursorModels([]);
return undefined;
// Cursor and Cline expose the usable catalog per account, so the static catalog is
// kept only as a fallback: it goes stale quickly and entitlements differ per account.
// Single map driven by LIVE_CATALOG_PROVIDERS so the constant cannot drift
// from the memos below; per-provider arrays stay referentially stable unless
// activeProviders itself changes.
const liveConnectionIdsByProvider = useMemo(() => {
const map = Object.fromEntries(LIVE_CATALOG_PROVIDERS.map((id) => [id, []]));
for (const p of activeProviders) {
if (p?.id && Object.prototype.hasOwnProperty.call(map, p.provider)) map[p.provider].push(p.id);
}
return map;
}, [activeProviders]);
const cursorConnectionIds = liveConnectionIdsByProvider.cursor;
const clineConnectionIds = liveConnectionIdsByProvider.cline;
const clinepassConnectionIds = liveConnectionIdsByProvider.clinepass;
let cancelled = false;
Promise.all(cursorConnectionIds.map(async (connectionId) => {
const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" });
if (!response.ok) return [];
const data = await response.json();
return Array.isArray(data.models) ? data.models : [];
}))
.then((modelLists) => {
if (cancelled) return;
const seen = new Set();
setCursorModels(modelLists.flat().filter((model) => {
if (!model?.id || seen.has(model.id)) return false;
seen.add(model.id);
return true;
}));
})
.catch((error) => {
// Do not hide the static fallback when the account catalog is unavailable.
console.warn("Unable to load Cursor models for selector:", error);
if (!cancelled) setCursorModels([]);
});
return () => { cancelled = true; };
}, [isOpen, cursorConnectionIds]);
const cursorModels = useLiveProviderModels(isOpen, cursorConnectionIds, "Cursor");
const clineModels = useLiveProviderModels(isOpen, clineConnectionIds, "Cline");
const clinepassModels = useLiveProviderModels(isOpen, clinepassConnectionIds, "ClinePass");
const fetchCombos = async () => {
try {
@@ -323,8 +348,9 @@ export default function ModelSelectModal({
hasModels: mergedModels.length > 0,
};
} else {
const hardcodedModels = providerId === "cursor" && cursorModels.length > 0
? cursorModels
const liveModels = providerId === "cursor" ? cursorModels : providerId === "cline" ? clineModels : providerId === "clinepass" ? clinepassModels : [];
const hardcodedModels = liveModels.length > 0
? liveModels
: getModelsByProviderId(providerId);
const hardcodedIds = new Set(hardcodedModels.map((m) => m.id));
@@ -394,7 +420,7 @@ export default function ModelSelectModal({
});
return groups;
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels]);
}, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders, cursorModels, clineModels, clinepassModels]);
// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
const filteredCombos = useMemo(() => {
+144 -74
View File
@@ -50,6 +50,19 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const popupRef = useRef(null);
const pollingAbortRef = useRef(false);
const openedRef = useRef(false);
// Proxy-flow session ledger: which provider's proxy THIS modal session
// started, and whether its stop was already sent. Every stop-proxy call is
// gated on this — parent re-renders can never spam it, and a close stops
// the owned proxy exactly once.
const flowRef = useRef({ proxyStarted: false, proxyProvider: null, stopSent: false });
// Parent callbacks are stored in refs so effect/callback identities stay
// stable across parent re-renders (the page passes fresh inline closures).
// Synced by the ref-sync effect below (placed after all callbacks are
// defined); the open effect then depends only on stable primitives.
const onSuccessRef = useRef(onSuccess);
const onCloseRef = useRef(onClose);
const isOpenRef = useRef(isOpen);
const startOAuthFlowRef = useRef(null);
const { copied, copy } = useCopyToClipboard();
// State for client-only values to avoid hydration mismatch
@@ -81,6 +94,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
redirectUri: authData.redirectUri,
codeVerifier: authData.codeVerifier,
state,
// Zed: thread the login attempt's system_id so the stored
// connection keeps the id sent to zed.dev (see register-session).
...(authData.systemId ? { systemId: authData.systemId } : {}),
...(oauthMeta ? { meta: oauthMeta } : {}),
}),
});
@@ -89,12 +105,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
} catch (err) {
setError(err.message);
setStep("error");
}
}, [authData, provider, onSuccess, oauthMeta]);
}, [authData, provider, oauthMeta]);
const completeXaiManualCode = useCallback(async (code) => {
if (!authData?.state) return;
@@ -108,12 +124,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
} catch (err) {
setError(err.message);
setStep("error");
}
}, [authData, onSuccess]);
}, [authData]);
// Poll for device code token
const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData, deadlineMs) => {
@@ -155,7 +171,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
pollingAbortRef.current = true; // Stop polling immediately
setStep("success");
setPolling(false);
onSuccess?.();
onSuccessRef.current?.();
return;
}
@@ -177,9 +193,19 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError("Authorization timeout");
setStep("error");
setPolling(false);
}, [provider, onSuccess]);
}, [provider]);
// Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange.
// Stop the proxy owned by THIS modal session, at most once. Re-renders,
// repeated closes, and post-completion calls are all no-ops by construction.
const stopOwnedProxy = useCallback(() => {
const flow = flowRef.current;
if (flow.proxyStarted && !flow.stopSent && flow.proxyProvider) {
flow.stopSent = true;
fetch(`/api/oauth/${flow.proxyProvider}/stop-proxy`).catch(() => {});
}
}, []);
// Trae/Windsurf/Zed proxy OAuth flow: dynamic-port local callback → auto exchange.
const startProxyFlow = useCallback(async (providerId) => {
// 1. Start the local callback server (returns a dynamic port + callback URL).
const startRes = await fetch(`/api/oauth/${providerId}/start-proxy`);
@@ -187,31 +213,61 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!startRes.ok || !startData.success || !startData.callbackUrl) {
throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`);
}
// Take ownership immediately so a close during the remaining flight still
// cleans this proxy up (via the close effect or the abort below).
flowRef.current.proxyStarted = true;
flowRef.current.proxyProvider = providerId;
flowRef.current.stopSent = false;
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
// 2. Build the authorize URL with redirect_uri = proxy callback URL.
const authorizeUrl = new URL(`/api/oauth/${providerId}/authorize`, window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl);
const authRes = await fetch(authorizeUrl);
const authData = await authRes.json();
if (!authRes.ok) throw new Error(authData.error);
if (!authRes.ok) {
stopOwnedProxy();
throw new Error(authData.error);
}
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
// 3. Register the session so the proxy can match the incoming callback.
// Zed also passes code_verifier (encodes the RSA private key for decrypt);
// sent via POST body so the private key never lands in URL/query logs.
// Zed also passes code_verifier (encodes the RSA private key for decrypt)
// + systemId; sent via POST body so secrets never land in URL/query logs.
const regBody = { state: authData.state };
if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier;
await fetch(`/api/oauth/${providerId}/register-session`, {
if (authData.systemId) regBody.systemId = authData.systemId;
const regRes = await fetch(`/api/oauth/${providerId}/register-session`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(regBody),
});
let regData = null;
try {
regData = await regRes.json();
} catch {
regData = null;
}
if (!regRes.ok || regData?.success === false) {
stopOwnedProxy();
throw new Error(regData?.error || "Failed to register login session; please retry");
}
if (!isOpenRef.current) return; // closed mid-flight: close effect owns cleanup now
// 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status.
setAuthData({ ...authData, proxyProvider: providerId });
setStep("waiting");
popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700");
if (!popupRef.current) setStep("input"); // popup blocked → fall back to manual paste
}, []);
}, [stopOwnedProxy]);
// Start OAuth flow
const startOAuthFlow = useCallback(async () => {
// Start OAuth flow (plain function by design: it is only invoked from the
// open effect via ref and from user actions, so memoization would only add
// an identity that re-triggers effects on every parent re-render).
const startOAuthFlow = async () => {
if (!provider) return;
try {
setError(null);
@@ -357,6 +413,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide });
// Take ownership of server-side proxies so close stops them exactly once
// (replaces the per-provider stop branches; same behavior, one ledger).
if ((provider === "codex" && codexProxyActive) || (provider === "xai" && xaiProxyActive)) {
flowRef.current.proxyStarted = true;
flowRef.current.proxyProvider = provider;
flowRef.current.stopSent = false;
}
// Guard: device_code providers return authUrl:null from /authorize. Never window.open(null)
// (browsers coerce it to the relative path ".../null").
if (!data.authUrl) {
@@ -397,49 +461,55 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError(err.message);
setStep("error");
}
}, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]);
};
// Reset state and start OAuth when modal opens
// Sync latest props/flow into refs after every render (no dep array).
// The open effect below then depends only on stable primitives.
useEffect(() => {
if (isOpen && provider) {
// Guard against StrictMode/effect re-runs auto-opening multiple tabs.
if (openedRef.current) return;
openedRef.current = true;
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
setAuthMode("browser");
setPasteToken("");
setIdeStatus(null);
pollingAbortRef.current = false;
// Best-effort IDE detection for paste-token providers (Trae/Windsurf)
if (PASTE_TOKEN_PROVIDERS[provider]) {
fetch(`/api/oauth/${provider}/ide-status`)
.then((r) => r.json())
.then((data) => setIdeStatus(data))
.catch(() => setIdeStatus({ installed: false, path: null }));
}
startOAuthFlow();
} else if (!isOpen) {
// Abort polling and cleanup proxy when modal closes
pollingAbortRef.current = true;
openedRef.current = false;
if (provider === "codex") {
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
onSuccessRef.current = onSuccess;
onCloseRef.current = onClose;
isOpenRef.current = isOpen;
startOAuthFlowRef.current = startOAuthFlow;
});
// Reset state and start OAuth when modal opens — exactly once per open.
// Guarded by openedRef so StrictMode/effect re-runs never open extra tabs.
useEffect(() => {
if (!isOpen || !provider) return;
if (openedRef.current) return;
openedRef.current = true;
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
setAuthMode("browser");
setPasteToken("");
setIdeStatus(null);
pollingAbortRef.current = false;
flowRef.current = { proxyStarted: false, proxyProvider: null, stopSent: false };
// Best-effort IDE detection for paste-token providers (Trae/Windsurf)
if (PASTE_TOKEN_PROVIDERS[provider]) {
fetch(`/api/oauth/${provider}/ide-status`)
.then((r) => r.json())
.then((data) => setIdeStatus(data))
.catch(() => setIdeStatus({ installed: false, path: null }));
}
}, [isOpen, provider, startOAuthFlow]);
startOAuthFlowRef.current();
}, [isOpen, provider]);
// Cleanup when the modal closes: abort polling and stop the proxy THIS
// session started, exactly once. Deps are stable primitives, so unrelated
// parent re-renders cannot reach the stop call (previously every parent
// render re-fired stop-proxy while the modal was closed).
useEffect(() => {
if (isOpen) return;
pollingAbortRef.current = true;
openedRef.current = false;
stopOwnedProxy();
flowRef.current = { proxyStarted: false, proxyProvider: null, stopSent: false };
}, [isOpen, provider, stopOwnedProxy]);
// Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port):
// poll status until the proxy auto-exchanges and saves the connection.
@@ -468,7 +538,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (data.status === "done") {
callbackProcessedRef.current = true;
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
return;
}
if (data.status === "error") {
@@ -490,7 +560,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
};
setTimeout(tick, POLL_INTERVAL_MS);
return () => { cancelled = true; };
}, [authData, onSuccess]);
}, [authData]);
// Listen for OAuth callback via multiple methods
useEffect(() => {
@@ -590,23 +660,31 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
return;
}
const input = callbackUrl.trim();
// Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL
// Trae/Windsurf/Zed proxy flow fallback (popup blocked): paste the full callback URL
if (PROXY_OAUTH_PROVIDERS.has(provider) && input) {
const res = await fetch(`/api/oauth/${provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: input, state: authData?.state }),
body: JSON.stringify({
code: input,
state: authData?.state,
// Zed manual fallback needs the same attempt material as the
// automatic path (redirectUri + RSA verifier + system_id).
...(authData?.redirectUri ? { redirectUri: authData.redirectUri } : {}),
...(authData?.codeVerifier ? { codeVerifier: authData.codeVerifier } : {}),
...(authData?.systemId ? { systemId: authData.systemId } : {}),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
onSuccessRef.current?.();
return;
}
@@ -653,21 +731,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
};
// Clear session on modal close + cleanup proxy
// Clear session on modal close + cleanup proxy (idempotent: the owned
// proxy is stopped at most once across effect-close, button-close, and
// Escape/backdrop-close — all funnel through here or the close effect).
const handleClose = useCallback(() => {
if (provider === "codex") {
fetch("/api/oauth/codex/stop-proxy").catch(() => {});
} else if (provider === "xai") {
fetch("/api/oauth/xai/stop-proxy").catch(() => {});
} else if (provider === "trae") {
fetch("/api/oauth/trae/stop-proxy").catch(() => {});
} else if (provider === "windsurf") {
fetch("/api/oauth/windsurf/stop-proxy").catch(() => {});
} else if (provider === "zed") {
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
onClose();
}, [onClose, provider]);
stopOwnedProxy();
onCloseRef.current();
}, [stopOwnedProxy]);
if (!provider || !providerInfo) return null;
const isXaiProvider = provider === "xai";
+3
View File
@@ -304,6 +304,9 @@ 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> */}
</button>
{/* 9English */}
@@ -0,0 +1,276 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Modal, Button } from "@/shared/components";
/**
* Xiaomi MiMo 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.
*/
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
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
(async () => {
setPhase("detecting");
setError(null);
setDetectResult(null);
setOauthUrl(null);
try {
const res = await fetch("/api/oauth/xiaomi-mimo/auto-import");
const data = await res.json();
if (cancelled) return;
if (data.found && data.apiKey) {
setDetectResult(data);
setPhase("found");
} 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.");
}
}
})();
return () => { cancelled = true; };
}, [isOpen]);
// Import the auto-detected key
const handleImport = async () => {
if (!detectResult?.apiKey) return;
setPhase("importing");
setError(null);
try {
const res = await fetch("/api/oauth/xiaomi-mimo/api-key", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: detectResult.apiKey,
uid: detectResult.uid,
baseUrl: detectResult.baseUrl,
mimoPassToken: detectResult.mimoPassToken || null,
mimoUserId: detectResult.mimoUserId || null,
mimoCUserId: detectResult.mimoCUserId || null,
}),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.error || "Import failed");
}
onSuccess?.(data.connection);
onClose();
} catch (err) {
setPhase("found");
setError(err.message);
}
};
// Start browser OAuth fallback
const handleStartOAuth = async () => {
setError(null);
try {
const state = crypto.randomUUID();
const res = await fetch(`/api/oauth/xiaomi-mimo/authorize?state=${state}`);
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");
}
} 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.");
}
} catch (err) {
setError(err.message);
}
};
return (
<Modal isOpen={isOpen} title="Connect Xiaomi MiMo" onClose={onClose}>
<div className="flex flex-col gap-4">
{/* 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">
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>
</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">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connecting...</h3>
</div>
)}
{/* Not found — offer OAuth fallback */}
{phase === "not-found" && (
<>
<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="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>
</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
>
Retry Local Detect
</Button>
<Button onClick={handleStartOAuth} fullWidth>
Sign in via Browser
</Button>
</div>
) : (
<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>
</div>
)}
</>
)}
</div>
</Modal>
);
}
XiaomiMimoAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};
+1
View File
@@ -28,6 +28,7 @@ export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as XiaomiMimoAuthModal } from "./XiaomiMimoAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
export { default as EditConnectionModal } from "./EditConnectionModal";
+29 -27
View File
@@ -30,32 +30,6 @@ export const MITM_TOOLS = {
{ id: "gemini-3-flash", name: "Gemini 3 Flash (Command)", alias: "gemini-3-flash" },
],
},
copilot: {
id: "copilot",
name: "GitHub Copilot",
image: "/providers/copilot.png",
color: "#1F6FEB",
description: "GitHub Copilot IDE with MITM",
configType: "mitm",
mitmDomain: "api.individual.githubcopilot.com",
modelAliases: ["gpt-5-mini", "gpt-5.4-nano", "claude-haiku-4.5", "gpt-4o", "gpt-4.1"],
defaultModels: [
// Verified via live MITM passthrough capture of the GitHub Copilot CLI: its model
// picker offers "GPT-5 mini" (default → wire id "gpt-5-mini"), "Claude Haiku 4.5"
// ("claude-haiku-4.5") and "Auto". "Auto" is NOT a wire id — Copilot dispatches
// concrete models dynamically (observed "gpt-5.4-nano" for light tasks and
// "claude-haiku-4.5"), so it needs no slot of its own. Without a slot for
// gpt-5-mini / gpt-5.4-nano, getMappedModel returns null and the /chat/completions
// call is passed through to GitHub Copilot instead of the configured provider —
// and gpt-5-mini is the CLI default, so the primary turn leaks (same class as the
// Kiro "auto" misrouting). gpt-4o / gpt-4.1 are kept for the VS Code Copilot Chat picker.
{ id: "gpt-5-mini", name: "GPT-5 mini", alias: "gpt-5-mini" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 nano", alias: "gpt-5.4-nano" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
{ id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" },
{ id: "gpt-4.1", name: "GPT-4.1", alias: "gpt-4.1" },
],
},
kiro: {
id: "kiro",
name: "Kiro",
@@ -140,6 +114,34 @@ export const CLI_TOOLS = {
description: "OpenAI Codex CLI",
configType: "custom",
},
copilot: {
id: "copilot",
name: "GitHub Copilot",
image: "/providers/copilot.png",
color: "#1F6FEB",
description: "GitHub Copilot in VS Code via 9Router extension",
configType: "guide",
docsUrl: "https://marketplace.visualstudio.com/items?itemName=hotrungnhan.9router-for-github-copilot",
guideSteps: [
{
step: 1,
title: "Install Extension",
desc: "In VS Code, open Extensions (Ctrl+Shift+X or Cmd+Shift+X), search for '9Router for Github Copilot' and click Install.",
},
{
step: 2,
title: "Configure Server",
desc: "Press Cmd+Shift+P (or Ctrl+Shift+P), run '9Router: Configure Server', then enter your Server URL and API Key:",
value: "{{baseUrl}}",
copyable: true,
},
{
step: 3,
title: "Select Model in Copilot Chat",
desc: "Open Copilot Chat, click the model picker at the bottom → 'Manage Models...' → check the 9Router models to use.",
},
],
},
opencode: {
id: "opencode",
name: "OpenCode",
@@ -197,7 +199,7 @@ export const CLI_TOOLS = {
id: "cline",
name: "Cline",
image: "/providers/cline.png",
color: "#00D1B2",
color: "#5B9BD5",
description: "Cline AI Coding Assistant",
configType: "custom",
},
+17 -2
View File
@@ -5,7 +5,7 @@ import {
extractApiKey,
isValidApiKey,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getSettings, getProviderConnectionById } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js";
import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets } from "open-sse/handlers/videoCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
@@ -17,6 +17,21 @@ import * as log from "../utils/logger.js";
// (bare model id, or multipart bodies we deliberately don't parse) land here.
const DEFAULT_VIDEO_PROVIDER = "xai";
/**
* Poll requests carry no model, so the provider comes from the pinned
* connection (`x-connection-id`, returned on create) or an explicit
* `?provider=` — falling back to the historical xAI default.
*/
async function resolveGetProvider(request, connectionId) {
if (connectionId) {
const conn = await getProviderConnectionById(connectionId).catch(() => null);
if (conn?.provider && getVideoConfig(conn.provider)) return conn.provider;
}
const queried = new URL(request.url).searchParams.get("provider");
if (queried && getVideoConfig(queried)) return queried;
return DEFAULT_VIDEO_PROVIDER;
}
// Creation POSTs are billable jobs — only rotate to another account for
// errors that upstream rejects BEFORE creating a job (auth/quota). A 5xx may
// have created the job, so it is returned to the caller instead of re-sent.
@@ -185,8 +200,8 @@ export async function handleVideoGet(request, requestId) {
if (!requestId) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing video request id");
const provider = DEFAULT_VIDEO_PROVIDER;
const preferredConnectionId = request.headers.get("x-connection-id") || null;
const provider = await resolveGetProvider(request, preferredConnectionId);
const credentials = await getProviderCredentials(provider, null, null, { preferredConnectionId });
if (!credentials || credentials.allRateLimited) {
+1 -1
View File
@@ -302,7 +302,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
}
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
const reason = typeof errorText === "string" ? errorText.slice(0, 200) : "Provider error";
const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs);
await updateProviderConnection(connectionId, {
+35 -46
View File
@@ -9,6 +9,7 @@ import { getCredentialExpiryMs } from "open-sse/services/oauthCredentialManager.
export const BACKGROUND_REFRESH_LEAD_MS = 30 * 60 * 1000;
const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
const INITIAL_DELAY_MS = 10 * 1000;
const SENSITIVE_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
let started = false;
let intervalHandle = null;
@@ -110,47 +111,47 @@ async function refreshOne(connection) {
* @param {{ loadConnections?: Function, refreshConnection?: Function }} [deps]
*/
export async function runBackgroundTokenRefreshTick(deps = {}) {
if (tickRunning) {
log.debug("BG_TOKEN_REFRESH", "Tick already running, skip");
return;
}
if (tickRunning) return;
tickRunning = true;
try {
const load = deps.loadConnections || loadActiveConnections;
const refresh = deps.refreshConnection || refreshOne;
const sleep = deps.sleep || ((ms) => new Promise((res) => setTimeout(res, ms)));
const connections = await load();
const due = selectConnectionsNeedingRefresh(connections, Date.now());
if (due.length === 0) {
log.debug("BG_TOKEN_REFRESH", "No connections due for refresh", {
active: Array.isArray(connections) ? connections.length : 0,
});
return;
if (due.length === 0) return;
const baseSensitiveDelay = Number(process.env.BG_REFRESH_GOOGLE_DELAY_MS) || 12_000;
const baseNormalDelay = Number(process.env.BG_REFRESH_DELAY_MS) || 1_500;
for (let i = 0; i < due.length; i++) {
const conn = due[i];
try {
await refresh(conn);
log.info("BG_TOKEN_REFRESH", "Connection refresh finished", {
id: conn.id,
email: conn.email || conn.name || conn.id,
provider: conn.provider,
});
} catch (err) {
log.warn("BG_TOKEN_REFRESH", "Connection refresh failed (swallowed)", {
id: conn?.id,
email: conn?.email || conn?.name || conn?.id,
provider: conn?.provider,
error: err?.message ?? String(err),
});
}
// Sequential delay between accounts to prevent bursting upstream providers (especially Google Cloud)
if (i < due.length - 1) {
const isSensitive = SENSITIVE_PROVIDERS.has(conn.provider);
const baseDelay = isSensitive ? baseSensitiveDelay : baseNormalDelay;
const jitter = isSensitive ? Math.floor(Math.random() * 4000) : 200;
await sleep(baseDelay + jitter);
}
}
log.info("BG_TOKEN_REFRESH", "Refreshing due OAuth connections", {
due: due.length,
ids: due.map((c) => c.id).filter(Boolean),
});
await Promise.allSettled(
due.map(async (conn) => {
try {
await refresh(conn);
log.info("BG_TOKEN_REFRESH", "Connection refresh finished", {
id: conn.id,
provider: conn.provider,
});
} catch (err) {
log.warn("BG_TOKEN_REFRESH", "Connection refresh failed (swallowed)", {
id: conn?.id,
provider: conn?.provider,
error: err?.message ?? String(err),
});
}
})
);
} catch (err) {
log.warn("BG_TOKEN_REFRESH", "Tick failed (swallowed)", {
error: err?.message ?? String(err),
@@ -167,14 +168,8 @@ export async function runBackgroundTokenRefreshTick(deps = {}) {
*/
export function startBackgroundTokenRefresh({ intervalMs } = {}) {
if (started) return false;
if (isTruthyEnv(process.env.DISABLE_BACKGROUND_TOKEN_REFRESH)) {
log.info("BG_TOKEN_REFRESH", "Disabled via DISABLE_BACKGROUND_TOKEN_REFRESH");
return false;
}
if (isNonServerRuntime()) {
log.debug("BG_TOKEN_REFRESH", "Skip start outside long-running server runtime");
return false;
}
if (isTruthyEnv(process.env.DISABLE_BACKGROUND_TOKEN_REFRESH)) return false;
if (isNonServerRuntime()) return false;
started = true;
const period = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : DEFAULT_INTERVAL_MS;
@@ -194,11 +189,6 @@ export function startBackgroundTokenRefresh({ intervalMs } = {}) {
intervalHandle = setInterval(safeTick, period);
if (intervalHandle.unref) intervalHandle.unref();
log.info("BG_TOKEN_REFRESH", "Scheduler started", {
intervalMs: period,
initialDelayMs: INITIAL_DELAY_MS,
leadMs: BACKGROUND_REFRESH_LEAD_MS,
});
return true;
}
@@ -213,6 +203,5 @@ export function stopBackgroundTokenRefresh() {
}
if (started) {
started = false;
log.info("BG_TOKEN_REFRESH", "Scheduler stopped");
}
}
+18 -13
View File
@@ -125,25 +125,30 @@ function needsProjectId(provider) {
function _refreshProjectId(provider, connectionId, accessToken) {
if (!needsProjectId(provider) || !connectionId || !accessToken) return;
// Evict the stale cached entry so getProjectIdForConnection does a real fetch
// Invalidate the stale cached entry so getProjectIdForConnection does a real fetch
invalidateProjectId(connectionId);
getProjectIdForConnection(connectionId, accessToken)
.then((projectId) => {
if (!projectId) return;
updateProviderCredentials(connectionId, { projectId }).catch((err) => {
log.debug("TOKEN_REFRESH", "Failed to persist refreshed projectId", {
// Lazy resolution: Do not eagerly trigger onboardUser during background token refresh.
// Eagerly fetching projectId across multiple accounts simultaneously triggers Google Cloud anti-abuse / rate limits.
// Runtime handlers (e.g. chat handler) will lazily call getProjectIdForConnection() on demand.
if (process.env.EAGER_PROJECT_ID_REFRESH === "true") {
getProjectIdForConnection(connectionId, accessToken, provider)
.then((projectId) => {
if (!projectId) return;
updateProviderCredentials(connectionId, { projectId }).catch((err) => {
log.debug("TOKEN_REFRESH", "Failed to persist refreshed projectId", {
connectionId,
error: err?.message ?? err,
});
});
})
.catch((err) => {
log.debug("TOKEN_REFRESH", "Failed to fetch projectId after token refresh", {
connectionId,
error: err?.message ?? err,
});
});
})
.catch((err) => {
log.debug("TOKEN_REFRESH", "Failed to fetch projectId after token refresh", {
connectionId,
error: err?.message ?? err,
});
});
}
}
// ─── Local-specific: persist credentials to localDb ──────────────────────────