# 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:
decolua 2026-05-18 16:26:35 +07:00
parent 90a47c3f29
commit 613a0a819a
12 changed files with 125 additions and 47 deletions

View file

@ -197,8 +197,8 @@ function killCloudflaredByAppPort(appPort) {
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
// Kill MITM first (admin/sudo process, needs special handling)
killMitmByPidFile();
// Kill MIT first (privileged process, needs special handling)
killProxyByPidFile();
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
killTunnelByPidFile();
@ -305,13 +305,13 @@ function waitForExit(pid, timeoutMs) {
return false;
}
// Kill MITM server by PID file (MITM runs as admin/sudo, needs special handling)
// Sends SIGTERM first so MITM can clean up /etc/hosts entries before dying.
function killMitmByPidFile() {
// Kill MIT server by PID file (runs privileged, needs special handling)
// Sends SIGTERM first so MIT can clean up host entries before dying.
function killProxyByPidFile() {
try {
const mitmPidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
if (!fs.existsSync(mitmPidFile)) return;
const pid = parseInt(fs.readFileSync(mitmPidFile, "utf8").trim(), 10);
const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid");
if (!fs.existsSync(pidFile)) return;
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
if (!pid) return;
if (process.platform === "win32") {
@ -333,7 +333,7 @@ function killMitmByPidFile() {
catch { try { process.kill(pid, "SIGKILL"); } catch { } }
}
}
try { fs.unlinkSync(mitmPidFile); } catch { }
try { fs.unlinkSync(pidFile); } catch { }
} catch { }
}
@ -584,8 +584,8 @@ function startServer(latestVersion) {
const { killTray } = require("./src/cli/tray/tray");
killTray();
} catch (e) { }
// Kill MITM server (admin/sudo process) via PID file
killMitmByPidFile();
// Kill MIT server (privileged process) via PID file
killProxyByPidFile();
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
killTunnelByPidFile();
// Kill server process directly
@ -772,7 +772,7 @@ function startServer(latestVersion) {
if (aliveMs >= RESTART_RESET_MS) restartCount = 0;
if (restartCount >= MAX_RESTARTS) {
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MITM and restarting...`);
console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`);
try {
const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json"));
if (fs.existsSync(dbPath)) {

View file

@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.4.52",
"version": "0.4.55",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"

View file

@ -395,6 +395,8 @@ export const PROVIDERS = {
baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions",
format: "openai"
},
// Region map for Xiaomi MiMo Token Plan (keys are cluster-specific)
// Used by resolveXiaomiTokenplanBaseUrl below
// === Free-tier providers (synced from OmniRoute) ===
// Claude-format with Claude CLI header spoofing (auth: x-api-key)
agentrouter: { baseUrl: "https://agentrouter.org/v1/messages", format: "claude", headers: { ...CLAUDE_CLI_SPOOF_HEADERS } },
@ -437,3 +439,15 @@ export function resolveOllamaLocalHost(credentials) {
const raw = credentials?.providerSpecificData?.baseUrl?.trim();
return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, "");
}
export const XIAOMI_TOKENPLAN_REGIONS = {
sgp: "https://token-plan-sgp.xiaomimimo.com/v1",
cn: "https://token-plan-cn.xiaomimimo.com/v1",
ams: "https://token-plan-ams.xiaomimimo.com/v1"
};
export const XIAOMI_TOKENPLAN_DEFAULT_REGION = "sgp";
export function resolveXiaomiTokenplanBaseUrl(credentials) {
const region = credentials?.providerSpecificData?.region;
return XIAOMI_TOKENPLAN_REGIONS[region] || XIAOMI_TOKENPLAN_REGIONS[XIAOMI_TOKENPLAN_DEFAULT_REGION];
}

View file

@ -1,5 +1,5 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { PROVIDERS, resolveXiaomiTokenplanBaseUrl } from "../config/providers.js";
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js";
import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js";
@ -39,6 +39,9 @@ export class DefaultExecutor extends BaseExecutor {
case "gemini":
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
default: {
if (this.provider === "xiaomi-tokenplan") {
return `${resolveXiaomiTokenplanBaseUrl(credentials)}/chat/completions`;
}
const url = this.config.baseUrl;
if (url?.includes("{accountId}")) {
const accountId = credentials?.providerSpecificData?.accountId;

View file

@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.4.52",
"version": "0.4.55",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View file

@ -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"

View file

@ -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}

View file

@ -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}`;

View file

@ -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) {

View file

@ -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;
}

View file

@ -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 };
}

View file

@ -56,7 +56,7 @@ const MINIMAX_TTS_MODELS = [
// OAuth Providers
export const OAUTH_PROVIDERS = {
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757", deprecated: true, deprecationNotice: RISK_NOTICE, website: "https://claude.ai", notice: { signupUrl: "https://claude.ai" } },
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } },
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } },
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", deprecated: true, deprecationNotice: RISK_NOTICE, thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", deprecated: true, deprecationNotice: RISK_NOTICE, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } },
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA", website: "https://cursor.com", notice: { signupUrl: "https://cursor.com" } },
@ -75,7 +75,7 @@ export const APIKEY_PROVIDERS = {
alicode: { id: "alicode", alias: "alicode", name: "Alibaba", icon: "cloud", color: "#FF6A00", textIcon: "ALi", website: "https://bailian.console.aliyun.com", notice: { apiKeyUrl: "https://bailian.console.aliyun.com/?apiKey=1" } },
"alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi", website: "https://modelstudio.console.alibabacloud.com", notice: { apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1" } },
"xiaomi-mimo": { id: "xiaomi-mimo", alias: "mimo", name: "Xiaomi MiMo", icon: "smart_toy", color: "#FF6900", textIcon: "XM", website: "https://xiaomimimo.com", notice: { apiKeyUrl: "https://xiaomimimo.com" } },
"xiaomi-tokenplan": { id: "xiaomi-tokenplan", alias: "xmtp", name: "Xiaomi MiMo (Token Plan)", icon: "smart_toy", color: "#FF6700", textIcon: "XT", website: "https://mimo.xiaomi.com", notice: { text: "Xiaomi MiMo Token Plan subscription (API key starts with tp-). Uses Singapore endpoint.", apiKeyUrl: "https://mimo.xiaomi.com" } },
"xiaomi-tokenplan": { id: "xiaomi-tokenplan", alias: "xmtp", name: "Xiaomi MiMo (Token Plan)", icon: "smart_toy", color: "#FF6700", textIcon: "XT", website: "https://mimo.xiaomi.com", notice: { text: "Xiaomi MiMo Token Plan subscription (API key starts with tp-). Token Plan keys are cluster-specific — select the region matching your subscription.", apiKeyUrl: "https://mimo.xiaomi.com" }, hasProviderSpecificData: true, regions: [{ id: "sgp", label: "Singapore", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1" }, { id: "cn", label: "China", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1" }, { id: "ams", label: "Europe", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1" }], defaultRegion: "sgp" },
"volcengine-ark": { id: "volcengine-ark", alias: "ark", name: "Volcengine Ark", icon: "cloud", color: "#1677FF", textIcon: "ARK", website: "https://ark.cn-beijing.volces.com", notice: { apiKeyUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey" } },
openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", notice: { apiKeyUrl: "https://platform.openai.com/api-keys" }, serviceKinds: ["llm", "embedding", "tts", "stt", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchViaChat: { defaultModel: "gpt-4o-mini", pricingUrl: "https://openai.com/api/pricing" }, ttsConfig: { baseUrl: "https://api.openai.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "tts-1", name: "TTS-1" }, { id: "tts-1-hd", name: "TTS-1 HD" }, { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS" }] }, sttConfig: { baseUrl: "https://api.openai.com/v1/audio/transcriptions", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "whisper-1", name: "Whisper 1" }, { id: "gpt-4o-transcribe", name: "GPT-4o Transcribe" }, { id: "gpt-4o-mini-transcribe", name: "GPT-4o Mini Transcribe" }] }, embeddingConfig: { baseUrl: "https://api.openai.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large", dimensions: 3072 }, { id: "text-embedding-ada-002", name: "Text Embedding Ada 002", dimensions: 1536 }] } },
"vercel-ai-gateway": { id: "vercel-ai-gateway", alias: "vercel", name: "Vercel AI Gateway", icon: "deployed_code", color: "#111827", textIcon: "VG", website: "https://vercel.com/ai-gateway", notice: { text: "Unified OpenAI-compatible endpoint from Vercel. Use your AI Gateway API key, then pick models with provider/model IDs like anthropic/claude-sonnet-4.6 or openai/gpt-5.4.", apiKeyUrl: "https://vercel.com/dashboard/~/ai-gateway" }, passthroughModels: true, serviceKinds: ["llm"] },