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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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))),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "-",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 ? "✓" : "✗";
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user