From 03ff35100d70a7c3bbb675af849169e8521776bb Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 17 Mar 2026 16:12:25 +0700 Subject: [PATCH] Feat : Kiro MITM --- .gitignore | 2 +- package.json | 4 +- .../dashboard/mitm/MitmPageClient.js | 2 +- src/lib/initCloudSync.js | 6 +- src/mitm/config.js | 24 ++ src/mitm/dns/dnsConfig.js | 1 + src/mitm/handlers/antigravity.js | 20 ++ src/mitm/handlers/base.js | 50 ++++ src/mitm/handlers/copilot.js | 35 +++ src/mitm/handlers/cursor.js | 15 ++ src/mitm/handlers/kiro.js | 20 ++ src/mitm/server.js | 238 ++++++------------ src/shared/constants/cliTools.js | 18 +- 13 files changed, 266 insertions(+), 169 deletions(-) create mode 100644 src/mitm/config.js create mode 100644 src/mitm/handlers/antigravity.js create mode 100644 src/mitm/handlers/base.js create mode 100644 src/mitm/handlers/copilot.js create mode 100644 src/mitm/handlers/cursor.js create mode 100644 src/mitm/handlers/kiro.js diff --git a/.gitignore b/.gitignore index bf08aba..78ff5bc 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ README1.md deploy.sh ecosystem.config.* start.sh -src/mitm/server2.js + diff --git a/package.json b/package.json index 5d55b52..e815d19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.54", + "version": "0.3.57", "description": "9Router web dashboard", "private": true, "scripts": { @@ -40,8 +40,10 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", + "esbuild": "^0.27.4", "eslint": "^9", "eslint-config-next": "16.1.6", + "javascript-obfuscator": "^5.3.0", "postcss": "^8.5.6", "tailwindcss": "^4" } diff --git a/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js index 25b6bbc..8634658 100644 --- a/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js +++ b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js @@ -6,7 +6,7 @@ import { getModelsByProviderId } from "@/shared/constants/models"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { MitmServerCard, MitmToolCard } from "@/app/(dashboard)/dashboard/cli-tools/components"; -const MITM_TOOL_IDS = ["antigravity", "copilot"]; +const MITM_TOOL_IDS = ["antigravity", "copilot", "kiro"]; export default function MitmPageClient() { const [connections, setConnections] = useState([]); diff --git a/src/lib/initCloudSync.js b/src/lib/initCloudSync.js index 8caca5e..64195bd 100644 --- a/src/lib/initCloudSync.js +++ b/src/lib/initCloudSync.js @@ -14,7 +14,9 @@ export async function ensureAppInitialized() { return initialized; } -// Auto-initialize when module loads -ensureAppInitialized().catch(console.log); +// Auto-initialize at runtime only, not during next build +if (process.env.NEXT_PHASE !== "phase-production-build") { + ensureAppInitialized().catch(console.log); +} export default ensureAppInitialized; diff --git a/src/mitm/config.js b/src/mitm/config.js new file mode 100644 index 0000000..896c621 --- /dev/null +++ b/src/mitm/config.js @@ -0,0 +1,24 @@ +// All intercepted domains + URL patterns per tool + +const TARGET_HOSTS = [ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com", + "api.individual.githubcopilot.com", + "q.us-east-1.amazonaws.com", +]; + +const URL_PATTERNS = { + antigravity: [":generateContent", ":streamGenerateContent"], + copilot: ["/chat/completions", "/v1/messages", "/responses"], + kiro: ["/generateAssistantResponse"], +}; + +function getToolForHost(host) { + const h = (host || "").split(":")[0]; + if (h === "api.individual.githubcopilot.com") return "copilot"; + if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity"; + if (h === "q.us-east-1.amazonaws.com") return "kiro"; + return null; +} + +module.exports = { TARGET_HOSTS, URL_PATTERNS, getToolForHost }; diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js index 9de77b1..fc42a82 100644 --- a/src/mitm/dns/dnsConfig.js +++ b/src/mitm/dns/dnsConfig.js @@ -8,6 +8,7 @@ const { log, err } = require("../logger"); const TOOL_HOSTS = { antigravity: ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"], copilot: ["api.individual.githubcopilot.com"], + kiro: ["q.us-east-1.amazonaws.com", "codewhisperer.us-east-1.amazonaws.com"], }; const IS_WIN = process.platform === "win32"; diff --git a/src/mitm/handlers/antigravity.js b/src/mitm/handlers/antigravity.js new file mode 100644 index 0000000..d88d733 --- /dev/null +++ b/src/mitm/handlers/antigravity.js @@ -0,0 +1,20 @@ +const { err } = require("../logger"); +const { fetchRouter, pipeSSE } = require("./base"); + +/** + * Intercept Antigravity (Gemini) request — replace model and forward to router + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + const body = JSON.parse(bodyBuffer.toString()); + body.model = mappedModel; + const routerRes = await fetchRouter(body); + await pipeSSE(routerRes, res); + } catch (error) { + err(`[antigravity] ${error.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/base.js b/src/mitm/handlers/base.js new file mode 100644 index 0000000..558b209 --- /dev/null +++ b/src/mitm/handlers/base.js @@ -0,0 +1,50 @@ +const { log, err } = require("../logger"); + +const ROUTER_BASE = "http://localhost:20128"; +const API_KEY = process.env.ROUTER_API_KEY; + +/** + * Send body to 9Router at the given path and return the fetch Response object + */ +async function fetchRouter(openaiBody, path = "/v1/chat/completions") { + const response = await fetch(`${ROUTER_BASE}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(API_KEY && { "Authorization": `Bearer ${API_KEY}` }) + }, + body: JSON.stringify(openaiBody) + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error(`[${response.status}]: ${errText}`); + } + + return response; +} + +/** + * Pipe SSE stream from router directly to client response + */ +async function pipeSSE(routerRes, res) { + const ct = routerRes.headers.get("content-type") || "application/json"; + const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; + if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; + res.writeHead(200, resHeaders); + + if (!routerRes.body) { + res.end(await routerRes.text().catch(() => "")); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) { res.end(); break; } + res.write(decoder.decode(value, { stream: true })); + } +} + +module.exports = { fetchRouter, pipeSSE }; diff --git a/src/mitm/handlers/copilot.js b/src/mitm/handlers/copilot.js new file mode 100644 index 0000000..78a52bb --- /dev/null +++ b/src/mitm/handlers/copilot.js @@ -0,0 +1,35 @@ +const { err } = require("../logger"); +const { fetchRouter, pipeSSE } = require("./base"); + +// Map Copilot endpoint → 9Router path +const URL_MAP = { + "/chat/completions": "/v1/chat/completions", + "/v1/messages": "/v1/messages", + "/responses": "/v1/responses", +}; + +function resolveRouterPath(reqUrl) { + for (const [pattern, routerPath] of Object.entries(URL_MAP)) { + if (reqUrl.includes(pattern)) return routerPath; + } + return "/v1/chat/completions"; +} + +/** + * Intercept Copilot request — replace model and forward to matching 9Router endpoint + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + const body = JSON.parse(bodyBuffer.toString()); + body.model = mappedModel; + const routerPath = resolveRouterPath(req.url); + const routerRes = await fetchRouter(body, routerPath); + await pipeSSE(routerRes, res); + } catch (error) { + err(`[copilot] ${error.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/cursor.js b/src/mitm/handlers/cursor.js new file mode 100644 index 0000000..0f38663 --- /dev/null +++ b/src/mitm/handlers/cursor.js @@ -0,0 +1,15 @@ +/** + * Cursor MITM handler — coming soon + * This feature is currently under development. + */ +async function intercept(req, res) { + res.writeHead(501, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + error: { + message: "Cursor MITM support is coming soon.", + type: "not_implemented" + } + })); +} + +module.exports = { intercept }; diff --git a/src/mitm/handlers/kiro.js b/src/mitm/handlers/kiro.js new file mode 100644 index 0000000..5b4c0e7 --- /dev/null +++ b/src/mitm/handlers/kiro.js @@ -0,0 +1,20 @@ +const { err } = require("../logger"); +const { fetchRouter, pipeSSE } = require("./base"); + +/** + * Intercept Kiro request — replace model and forward to router + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + const body = JSON.parse(bodyBuffer.toString()); + body.model = mappedModel; + const routerRes = await fetchRouter(body); + await pipeSSE(routerRes, res); + } catch (error) { + err(`[Kiro] ${error.message}`); + if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); + } +} + +module.exports = { intercept }; diff --git a/src/mitm/server.js b/src/mitm/server.js index 62fd169..3bde1c0 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -4,100 +4,62 @@ const path = require("path"); const dns = require("dns"); const { promisify } = require("util"); const { log, err } = require("./logger"); - -// Allow self-signed certs from MITM root CA when fetching external hosts - - -const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; - -// All intercepted domains across all tools -const TARGET_HOSTS = [ - "daily-cloudcode-pa.googleapis.com", - "cloudcode-pa.googleapis.com", - "api.individual.githubcopilot.com", -]; - -const LOCAL_PORT = 443; -const ROUTER_URL = "http://localhost:20128/v1/chat/completions"; -const API_KEY = process.env.ROUTER_API_KEY; +const { TARGET_HOSTS, URL_PATTERNS, getToolForHost } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); -const DB_FILE = path.join(DATA_DIR, "db.json"); - -const ENABLE_FILE_LOG = false; - - const { getCertForDomain } = require("./cert/generate"); -// Certificate cache for performance -const certCache = new Map(); +const DB_FILE = path.join(DATA_DIR, "db.json"); +const LOCAL_PORT = 443; +const ENABLE_FILE_LOG = false; +const LOG_DIR = path.join(DATA_DIR, "logs", "mitm"); +const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; -// SNI callback for dynamic certificate generation -function sniCallback(servername, cb) { - try { - // Check cache first - if (certCache.has(servername)) { - const cached = certCache.get(servername); - return cb(null, cached); - } +if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); - // Generate new cert for this domain - const certData = getCertForDomain(servername); - if (!certData) { - return cb(new Error(`Failed to generate cert for ${servername}`)); - } - - // Create secure context - const ctx = require("tls").createSecureContext({ - key: certData.key, - cert: certData.cert - }); - - // Cache it - certCache.set(servername, ctx); - log(`🔐 Cert generated: ${servername}`); - - cb(null, ctx); - } catch (error) { - err(`SNI error for ${servername}: ${error.message}`); - cb(error); - } +// Load handlers — dev/ overrides handlers/ for private implementations +function loadHandler(name) { + try { return require(`./dev/${name}`); } catch {} + return require(`./handlers/${name}`); } -// Load Root CA for default context -const certDir = MITM_DIR; -const rootCAKeyPath = path.join(certDir, "rootCA.key"); -const rootCACertPath = path.join(certDir, "rootCA.crt"); +const handlers = { + antigravity: loadHandler("antigravity"), + copilot: loadHandler("copilot"), + kiro: loadHandler("kiro"), +}; + +// ── SSL / SNI ───────────────────────────────────────────────── + +const certCache = new Map(); + +function sniCallback(servername, cb) { + try { + if (certCache.has(servername)) return cb(null, certCache.get(servername)); + const certData = getCertForDomain(servername); + if (!certData) return cb(new Error(`Failed to generate cert for ${servername}`)); + const ctx = require("tls").createSecureContext({ key: certData.key, cert: certData.cert }); + certCache.set(servername, ctx); + log(`🔐 Cert generated: ${servername}`); + cb(null, ctx); + } catch (e) { + err(`SNI error for ${servername}: ${e.message}`); + cb(e); + } +} let sslOptions; try { sslOptions = { - key: fs.readFileSync(rootCAKeyPath), - cert: fs.readFileSync(rootCACertPath), + key: fs.readFileSync(path.join(MITM_DIR, "rootCA.key")), + cert: fs.readFileSync(path.join(MITM_DIR, "rootCA.crt")), SNICallback: sniCallback }; } catch (e) { - err(`Root CA not found in ${certDir}: ${e.message}`); + err(`Root CA not found: ${e.message}`); process.exit(1); } -// Antigravity: Gemini generateContent endpoints -const ANTIGRAVITY_URL_PATTERNS = [":generateContent", ":streamGenerateContent"]; -// Copilot: OpenAI-compatible + Anthropic endpoints -const COPILOT_URL_PATTERNS = ["/chat/completions", "/v1/messages", "/responses"]; - -const LOG_DIR = path.join(DATA_DIR, "logs", "mitm"); -if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); - -function saveRequestLog(url, bodyBuffer) { - if (!ENABLE_FILE_LOG) return; - try { - const ts = new Date().toISOString().replace(/[:.]/g, "-"); - const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); - const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`); - const body = JSON.parse(bodyBuffer.toString()); - fs.writeFileSync(filePath, JSON.stringify(body, null, 2)); - } catch { /* ignore */ } -} +// ── Helpers ─────────────────────────────────────────────────── const cachedTargetIPs = {}; async function resolveTargetIP(hostname) { @@ -119,11 +81,17 @@ function collectBodyRaw(req) { }); } -// Extract model from URL path (Gemini) or body (OpenAI/Anthropic) +// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState function extractModel(url, body) { const urlMatch = url.match(/\/models\/([^/:]+)/); if (urlMatch) return urlMatch[1]; - try { return JSON.parse(body.toString()).model || null; } catch { return null; } + try { + const parsed = JSON.parse(body.toString()); + if (parsed.conversationState) { + return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; + } + return parsed.model || null; + } catch { return null; } } function getMappedModel(tool, model) { @@ -133,24 +101,21 @@ function getMappedModel(tool, model) { const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); const aliases = db.mitmAlias?.[tool]; if (!aliases) return null; - // Exact match first if (aliases[model]) return aliases[model]; - // Prefix match fallback: find alias key that starts with model or model starts with key + // Prefix match fallback const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (model.startsWith(k) || k.startsWith(model))); return prefixKey ? aliases[prefixKey] : null; - } catch { - return null; - } + } catch { return null; } } -/** - * Determine which tool this request belongs to based on hostname - */ -function getToolForHost(host) { - const h = (host || "").split(":")[0]; - if (h === "api.individual.githubcopilot.com") return "copilot"; - if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity"; - return null; +function saveRequestLog(url, bodyBuffer) { + if (!ENABLE_FILE_LOG) return; + try { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const slug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); + const body = JSON.parse(bodyBuffer.toString()); + fs.writeFileSync(path.join(LOG_DIR, `${ts}_${slug}.json`), JSON.stringify(body, null, 2)); + } catch { /* ignore */ } } async function passthrough(req, res, bodyBuffer) { @@ -180,53 +145,9 @@ async function passthrough(req, res, bodyBuffer) { forwardReq.end(); } -async function intercept(req, res, bodyBuffer, mappedModel) { - try { - const body = JSON.parse(bodyBuffer.toString()); - body.model = mappedModel; - - const response = await fetch(ROUTER_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(API_KEY && { "Authorization": `Bearer ${API_KEY}` }) - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - const errText = await response.text().catch(() => ""); - throw new Error(`9Router ${response.status}: ${errText}`); - } - - const ct = response.headers.get("content-type") || "application/json"; - const resHeaders = { "Content-Type": ct, "Cache-Control": "no-cache", "Connection": "keep-alive" }; - if (ct.includes("text/event-stream")) resHeaders["X-Accel-Buffering"] = "no"; - res.writeHead(200, resHeaders); - - // Guard: some responses have no body (e.g. errors, empty replies) - if (!response.body) { - const text = await response.text().catch(() => ""); - res.end(text); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - while (true) { - const { done, value } = await reader.read(); - if (done) { res.end(); break; } - res.write(decoder.decode(value, { stream: true })); - } - } catch (error) { - err(`Intercept error: ${error.message}`); - if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } })); - } -} +// ── Request handler ─────────────────────────────────────────── const server = https.createServer(sslOptions, async (req, res) => { - // Top-level catch to prevent uncaughtException from crashing the server try { if (req.url === "/_mitm_health") { res.writeHead(200, { "Content-Type": "application/json" }); @@ -237,7 +158,7 @@ const server = https.createServer(sslOptions, async (req, res) => { const bodyBuffer = await collectBodyRaw(req); if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); - // Anti-loop: requests originating from 9Router bypass interception + // Anti-loop: skip requests from 9Router if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) { return passthrough(req, res, bodyBuffer); } @@ -245,49 +166,40 @@ const server = https.createServer(sslOptions, async (req, res) => { const tool = getToolForHost(req.headers.host); if (!tool) return passthrough(req, res, bodyBuffer); - // Check if this URL should be intercepted based on tool - const isChat = tool === "antigravity" - ? ANTIGRAVITY_URL_PATTERNS.some(p => req.url.includes(p)) - : COPILOT_URL_PATTERNS.some(p => req.url.includes(p)); - + const patterns = URL_PATTERNS[tool] || []; + const isChat = patterns.some(p => req.url.includes(p)); if (!isChat) return passthrough(req, res, bodyBuffer); - const model = extractModel(req.url, bodyBuffer); - log(`🔍 model="${model}" url=${req.url}`); - const mappedModel = getMappedModel(tool, model); + log(`🔍 [${tool}] url=${req.url} | bodyLen=${bodyBuffer.length}`); + const model = extractModel(req.url, bodyBuffer); + log(`🔍 [${tool}] model="${model}"`); + + const mappedModel = getMappedModel(tool, model); if (!mappedModel) { log(`⏩ passthrough | no mapping | ${tool} | ${model || "unknown"}`); return passthrough(req, res, bodyBuffer); } log(`⚡ intercept | ${tool} | ${model} → ${mappedModel}`); - return intercept(req, res, bodyBuffer, mappedModel); + return handlers[tool].intercept(req, res, bodyBuffer, mappedModel, passthrough); } catch (e) { - err(`Unhandled request error: ${e.message}`); + err(`Unhandled error: ${e.message}`); if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } })); } }); -server.listen(LOCAL_PORT, () => { - log(`🚀 Server ready on :${LOCAL_PORT}`); -}); +server.listen(LOCAL_PORT, () => log(`🚀 Server ready on :${LOCAL_PORT}`)); -server.on("error", (error) => { - if (error.code === "EADDRINUSE") { - err(`Port ${LOCAL_PORT} already in use`); - } else if (error.code === "EACCES") { - err(`Permission denied for port ${LOCAL_PORT}`); - } else { - err(error.message); - } +server.on("error", (e) => { + if (e.code === "EADDRINUSE") err(`Port ${LOCAL_PORT} already in use`); + else if (e.code === "EACCES") err(`Permission denied for port ${LOCAL_PORT}`); + else err(e.message); process.exit(1); }); -const shutdown = () => { server.close(() => process.exit(0)); }; +const shutdown = () => server.close(() => process.exit(0)); process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); -if (process.platform === "win32") { - process.on("SIGBREAK", shutdown); -} +if (process.platform === "win32") process.on("SIGBREAK", shutdown); diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 97766ce..22b69a2 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -76,10 +76,26 @@ export const CLI_TOOLS = { defaultModels: [ { id: "gpt-4o", name: "GPT-4o", alias: "gpt-4o" }, { id: "gpt-4.1", name: "GPT-4.1", alias: "gpt-4.1" }, - { id: "gpt-5-mini", name: "GPT-5 Mini", alias: "gpt-5-mini" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" }, ], }, + kiro: { + id: "kiro", + name: "Kiro", + image: "/providers/kiro.png", + color: "#FF6B00", + description: "Kiro IDE with MITM", + configType: "mitm", + mitmDomain: "q.us-east-1.amazonaws.com", + defaultModels: [ + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4.5" }, + { id: "claude-sonnet-4", name: "Claude Sonnet 4", alias: "claude-sonnet-4" }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" }, + { id: "deepseek-3.2", name: "DeepSeek 3.2", alias: "deepseek-3.2" }, + { id: "minimax-m2.1", name: "MiniMax M2.1", alias: "minimax-m2.1" }, + { id: "simple-task", name: "Qwen3 Coder Next", alias: "simple-task" }, + ], + }, droid: { id: "droid", name: "Factory Droid",