# v0.4.55 (2026-05-18)
## Features - Xiaomi MiMo Token Plan: region selector (Singapore / China / Europe) — keys are cluster-specific - Antigravity: risk confirmation dialog before first connection - Gemini CLI: surface upstream retry delay on 429 errors ## Fixes - MITM: cannot kill process on macOS under sudo (lsof not found in PATH) - Stream: false-positive stall timeout on Claude reasoning / Kiro responses - Tunnel: cannot re-enable after disable (stuck state) - Tunnel: cloudflared error messages now include log tail for easier debugging - Language switcher: applies selected locale immediately on close (#1234) - Antigravity OAuth: metadata now matches the official client ## Improvements - Gemini CLI: bump engine to 0.34.0 - Re-hide `qwen` (OAuth EOL) and `iflow` (not ready) providers
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;
|
||||
|
||||
@@ -17,6 +18,8 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
|
||||
const isAzure = provider === "azure";
|
||||
const isCloudflareAi = provider === "cloudflare-ai";
|
||||
const providerRegions = AI_PROVIDERS?.[provider]?.regions || null;
|
||||
const defaultRegion = AI_PROVIDERS?.[provider]?.defaultRegion || providerRegions?.[0]?.id || "";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
@@ -33,6 +36,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
organization: "",
|
||||
});
|
||||
const [cloudflareData, setCloudflareData] = useState({ accountId: "" });
|
||||
const [region, setRegion] = useState(defaultRegion);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -55,6 +59,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
if (isCloudflareAi) {
|
||||
return { accountId: cloudflareData.accountId };
|
||||
}
|
||||
if (providerRegions && region) {
|
||||
return { region };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -234,6 +241,14 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{providerRegions && (
|
||||
<Select
|
||||
label="Region"
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
options={providerRegions.map((r) => ({ value: r.id, label: r.label }))}
|
||||
/>
|
||||
)}
|
||||
{isCompatible && (
|
||||
<Input
|
||||
label="Default Model"
|
||||
|
||||
@@ -49,8 +49,40 @@ export default function ProviderDetailPage() {
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
const [disabledModelIds, setDisabledModelIds] = useState([]);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const [showAgRiskModal, setShowAgRiskModal] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
|
||||
|
||||
const triggerAddConnection = () => {
|
||||
if (providerId === "antigravity" && typeof window !== "undefined") {
|
||||
const confirmed = window.localStorage.getItem(AG_RISK_STORAGE_KEY) === "true";
|
||||
if (!confirmed) {
|
||||
setShowAgRiskModal(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isOAuth) {
|
||||
setShowOAuthModal(true);
|
||||
return;
|
||||
}
|
||||
setAddConnectionError("");
|
||||
setShowAddApiKeyModal(true);
|
||||
};
|
||||
|
||||
const handleAgRiskConfirm = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(AG_RISK_STORAGE_KEY, "true");
|
||||
}
|
||||
setShowAgRiskModal(false);
|
||||
if (isOAuth) {
|
||||
setShowOAuthModal(true);
|
||||
return;
|
||||
}
|
||||
setAddConnectionError("");
|
||||
setShowAddApiKeyModal(true);
|
||||
};
|
||||
|
||||
const providerInfo = providerNode
|
||||
? {
|
||||
id: providerNode.id,
|
||||
@@ -1066,14 +1098,7 @@ export default function ProviderDetailPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => {
|
||||
if (isOAuth) {
|
||||
setShowOAuthModal(true);
|
||||
return;
|
||||
}
|
||||
setAddConnectionError("");
|
||||
setShowAddApiKeyModal(true);
|
||||
}}
|
||||
onClick={triggerAddConnection}
|
||||
>
|
||||
{isCompatible ? "Add API Key" : (providerId === "iflow" ? "OAuth" : "Add Connection")}
|
||||
</Button>
|
||||
@@ -1099,14 +1124,7 @@ export default function ProviderDetailPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => {
|
||||
if (isOAuth) {
|
||||
setShowOAuthModal(true);
|
||||
return;
|
||||
}
|
||||
setAddConnectionError("");
|
||||
setShowAddApiKeyModal(true);
|
||||
}}
|
||||
onClick={triggerAddConnection}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Add
|
||||
@@ -1242,6 +1260,18 @@ export default function ProviderDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AG Risk Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showAgRiskModal}
|
||||
onClose={() => setShowAgRiskModal(false)}
|
||||
onConfirm={handleAgRiskConfirm}
|
||||
title="Risk Notice"
|
||||
message={providerInfo?.deprecationNotice}
|
||||
confirmText="I Understand, Continue"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* Confirm Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!confirmState}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { getProviderNodeById } from "@/models";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getDefaultModel } from "open-sse/config/providerModels.js";
|
||||
import { resolveOllamaLocalHost, PROVIDERS } from "open-sse/config/providers.js";
|
||||
import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from "open-sse/config/providers.js";
|
||||
import { openaiToCommandCode } from "open-sse/translator/request/openai-to-commandcode.js";
|
||||
import { PROVIDER_ENDPOINTS } from "@/shared/constants/config";
|
||||
import { normalizeProviderId } from "@/lib/providerNormalization";
|
||||
@@ -382,7 +382,7 @@ export async function POST(request) {
|
||||
chutes: "https://llm.chutes.ai/v1/models",
|
||||
nvidia: "https://integrate.api.nvidia.com/v1/models",
|
||||
"xiaomi-mimo": "https://api.xiaomimimo.com/v1/models",
|
||||
"xiaomi-tokenplan": "https://token-plan-sgp.xiaomimimo.com/v1/models"
|
||||
"xiaomi-tokenplan": `${resolveXiaomiTokenplanBaseUrl({ providerSpecificData })}/models`
|
||||
};
|
||||
const headers = {};
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
@@ -124,7 +124,10 @@ async function canAccessPublicLlmApi(request) {
|
||||
}
|
||||
|
||||
async function canAccessLocalOnlyRoute(request) {
|
||||
return await hasValidCliToken(request);
|
||||
if (await hasValidCliToken(request)) return true;
|
||||
// Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + JWT cookie (blocks unauth raw clients)
|
||||
if (isLocalRequest(request) && await hasValidToken(request)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hasValidToken(request) {
|
||||
|
||||
@@ -305,6 +305,8 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
// Keep a small tail of raw cloudflared logs to surface real failure causes
|
||||
let logTail = "";
|
||||
|
||||
function getQuickTunnelUrlFromLog(message) {
|
||||
// cloudflared logs may contain "api.trycloudflare.com" as well,
|
||||
@@ -326,13 +328,14 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(new Error("Quick tunnel timed out"));
|
||||
reject(new Error(`Quick tunnel timed out. Last log: ${logTail.slice(-800) || "(empty)"}`));
|
||||
}, 90000);
|
||||
|
||||
let lastUrl = null;
|
||||
|
||||
const handleLog = (data) => {
|
||||
const msg = data.toString();
|
||||
logTail = (logTail + msg).slice(-4000);
|
||||
const tunnelUrl = getQuickTunnelUrlFromLog(msg);
|
||||
if (!tunnelUrl) return;
|
||||
|
||||
@@ -370,17 +373,18 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) {
|
||||
cloudflaredProcess = null;
|
||||
clearPid();
|
||||
console.log(`[Tunnel] cloudflared exit code=${code} signal=${signal}`);
|
||||
if (logTail) console.log(`[Tunnel] cloudflared log tail:\n${logTail.slice(-1500)}`);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
// Provide more helpful error messages for common exit codes
|
||||
const tail = logTail.slice(-600).trim() || "(empty)";
|
||||
if (code === 1) {
|
||||
reject(new Error(`cloudflared exited with code ${code}. This often means: (1) the tunnel token is invalid or expired, (2) network connectivity issues, or (3) cloudflared cannot reach the local server.`));
|
||||
reject(new Error(`cloudflared quick tunnel exited (code 1). Common causes: (1) outbound port 7844 (TCP/UDP) blocked, (2) TryCloudflare service issue, (3) cannot reach 127.0.0.1:${localPort}, (4) protocol (http2/quic) blocked by network. Last log: ${tail}`));
|
||||
} else if (code === 2) {
|
||||
reject(new Error(`cloudflared exited with code ${code}. Check that arguments are correct.`));
|
||||
reject(new Error(`cloudflared exited (code 2). Bad arguments. Last log: ${tail}`));
|
||||
} else {
|
||||
reject(new Error(`cloudflared exited with code ${code}`));
|
||||
reject(new Error(`cloudflared exited (code ${code}). Last log: ${tail}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import { loadState, saveState, generateShortId } from "./state.js";
|
||||
import { loadState, saveState, generateShortId, clearPid } from "./state.js";
|
||||
import { spawnQuickTunnel, killCloudflared, isCloudflaredRunning, setUnexpectedExitHandler } from "./cloudflared.js";
|
||||
import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
@@ -127,12 +127,15 @@ export async function enableTunnel(localPort = 20128) {
|
||||
await updateSettings({ tunnelEnabled: true, tunnelUrl });
|
||||
console.log(`[Tunnel] registered shortId=${shortId} publicUrl=${publicUrl}`);
|
||||
|
||||
// Verify direct tunnel URL first (avoid CDN false positive on publicUrl)
|
||||
await waitForHealth(tunnelUrl, token);
|
||||
console.log("[Tunnel] direct URL healthy");
|
||||
// Then verify public URL (DNS propagated through worker)
|
||||
// Verify publicUrl first (worker route is reliable; direct *.trycloudflare.com DNS may lag)
|
||||
await waitForHealth(publicUrl, token);
|
||||
console.log("[Tunnel] public URL healthy");
|
||||
// Direct tunnel probe is best-effort: DNS for *.trycloudflare.com can be slow/blocked on some networks
|
||||
if (!(await probeUrlAlive(tunnelUrl))) {
|
||||
console.warn("[Tunnel] direct URL not reachable yet, continuing via publicUrl");
|
||||
} else {
|
||||
console.log("[Tunnel] direct URL healthy");
|
||||
}
|
||||
|
||||
// Prime reachable cache so UI shows correct state immediately
|
||||
tunnelReachable.value = true;
|
||||
@@ -151,15 +154,21 @@ export async function enableTunnel(localPort = 20128) {
|
||||
|
||||
export async function disableTunnel() {
|
||||
console.log("[Tunnel] disable");
|
||||
// Abort any in-flight enable so it cannot resurrect state after we clear it
|
||||
tunnelSvc.cancelToken.cancelled = true;
|
||||
setUnexpectedExitHandler(null);
|
||||
killCloudflared(tunnelSvc.activeLocalPort);
|
||||
|
||||
try { killCloudflared(tunnelSvc.activeLocalPort); } catch (e) { console.warn(`[Tunnel] kill warn: ${e.message}`); }
|
||||
clearPid();
|
||||
|
||||
const state = loadState();
|
||||
if (state) saveState({ shortId: state.shortId, machineId: state.machineId, tunnelUrl: null });
|
||||
|
||||
await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
|
||||
tunnelReachable.value = false; tunnelReachable.url = null; tunnelReachable.fetchedAt = Date.now();
|
||||
// Force-clear flags so a subsequent enable is not blocked by a stuck spawnInProgress
|
||||
tunnelSvc.spawnInProgress = false;
|
||||
tunnelSvc.activeLocalPort = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user