diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 75ac04f..d9c116e 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -17,6 +17,7 @@ function convertMessages(messages, tools, model) { let pendingUserContent = []; let pendingAssistantContent = []; let pendingToolResults = []; + let pendingImages = []; let currentRole = null; const flushPending = () => { @@ -28,7 +29,12 @@ function convertMessages(messages, tools, model) { modelId: "" } }; - + + // Attach images if present (Kiro API supports images field) + if (pendingImages.length > 0) { + userMsg.userInputMessage.images = pendingImages; + } + if (pendingToolResults.length > 0) { userMsg.userInputMessage.userInputMessageContext = { toolResults: pendingToolResults @@ -64,6 +70,7 @@ function convertMessages(messages, tools, model) { currentMessage = userMsg; pendingUserContent = []; pendingToolResults = []; + pendingImages = []; } else if (currentRole === "assistant") { const content = pendingAssistantContent.join("\n\n").trim() || "..."; const assistantMsg = { @@ -97,9 +104,24 @@ function convertMessages(messages, tools, model) { if (typeof msg.content === "string") { content = msg.content; } else if (Array.isArray(msg.content)) { - const textParts = msg.content - .filter(c => c.type === "text" || c.text) - .map(c => c.text || ""); + const textParts = []; + for (const c of msg.content) { + if (c.type === "text" || c.text) { + textParts.push(c.text || ""); + } else if (c.type === "image_url") { + const url = c.image_url?.url || ""; + const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); + if (base64Match) { + // Extract format from media type (e.g. "image/png" → "png") + const mediaType = base64Match[1]; + const format = mediaType.split("/")[1] || mediaType; + pendingImages.push({ format, source: { bytes: base64Match[2] } }); + } else if (url.startsWith("http://") || url.startsWith("https://")) { + // Kiro images field only supports base64 — fallback to URL text + textParts.push(`[Image: ${url}]`); + } + } + } content = textParts.join("\n"); // Check for tool_result blocks diff --git a/package.json b/package.json index a82afae..4e0fc6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.1", + "version": "0.3.16", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js index d4462ea..368b399 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -240,7 +240,23 @@ export default function AntigravityToolCard({ {isExpanded && (
- {/* Start/Stop Button - always on top */} + {/* Status indicators */} +
+ {[ + { label: "DNS", ok: status?.dnsConfigured }, + { label: "Cert", ok: status?.certExists }, + { label: "Server", ok: status?.running }, + ].map(({ label, ok }) => ( +
+ + {ok ? "check_circle" : "radio_button_unchecked"} + + {label} +
+ ))} +
+ + {/* Start/Stop Button */}
{isRunning ? (
); }; @@ -526,7 +563,8 @@ export default function ProviderDetailPage() {
); } -function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) { +function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, onDeleteAlias }) { const borderColor = testStatus === "ok" ? "border-green-500/40" : testStatus === "error" @@ -602,7 +652,7 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus }) { : undefined; return ( -
+
+ {isCustom && ( + + )}
); } @@ -632,6 +691,8 @@ ModelRow.propTypes = { copied: PropTypes.string, onCopy: PropTypes.func.isRequired, testStatus: PropTypes.oneOf(["ok", "error"]), + isCustom: PropTypes.bool, + onDeleteAlias: PropTypes.func, }; function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) { @@ -1553,3 +1614,115 @@ EditCompatibleNodeModal.propTypes = { isAnthropic: PropTypes.bool, }; +function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) { + const [modelId, setModelId] = useState(""); + const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" + const [testError, setTestError] = useState(""); + const [saving, setSaving] = useState(false); + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); } + }, [isOpen]); + + const handleTest = async () => { + if (!modelId.trim()) return; + setTestStatus("testing"); + setTestError(""); + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: `${providerAlias}/${modelId.trim()}` }), + }); + const data = await res.json(); + setTestStatus(data.ok ? "ok" : "error"); + setTestError(data.error || ""); + } catch (err) { + setTestStatus("error"); + setTestError(err.message); + } + }; + + const handleSave = async () => { + if (!modelId.trim() || saving) return; + setSaving(true); + try { + await onSave(modelId.trim()); + } finally { + setSaving(false); + } + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter") handleTest(); + }; + + return ( + +
+
+ +
+ { setModelId(e.target.value); setTestStatus(null); setTestError(""); }} + onKeyDown={handleKeyDown} + placeholder="e.g. claude-opus-4-5" + className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + autoFocus + /> + +
+

+ Sent to provider as: {modelId.trim() || "model-id"} +

+
+ + {/* Test result */} + {testStatus === "ok" && ( +
+ check_circle + Model is reachable +
+ )} + {testStatus === "error" && ( +
+ cancel + {testError || "Model not reachable"} +
+ )} + +
+ + +
+
+
+ ); +} + +AddCustomModelModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + providerAlias: PropTypes.string.isRequired, + providerDisplayAlias: PropTypes.string.isRequired, + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index d0549aa..8d6b1a7 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -222,7 +222,7 @@ export default function ProvidersPage() { title="Test all OAuth connections" aria-label="Test all OAuth connections" > - + {testingMode === "oauth" ? "sync" : "play_arrow"} {testingMode === "oauth" ? "Testing..." : "Test All"} @@ -260,7 +260,7 @@ export default function ProvidersPage() { title="Test all Free connections" aria-label="Test all Free provider connections" > - + {testingMode === "free" ? "sync" : "play_arrow"} {testingMode === "free" ? "Testing..." : "Test All"} @@ -297,7 +297,7 @@ export default function ProvidersPage() { title="Test all API Key connections" aria-label="Test all API Key connections" > - + {testingMode === "apikey" ? "sync" : "play_arrow"} {testingMode === "apikey" ? "Testing..." : "Test All"} @@ -335,7 +335,7 @@ export default function ProvidersPage() { }`} title="Test all Compatible connections" > - + {testingMode === "compatible" ? "sync" : "play_arrow"} {testingMode === "compatible" ? "Testing..." : "Test All"} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js index f6486db..d2391bc 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js @@ -144,7 +144,7 @@ function buildLayout(providers, activeSet, lastSet, errorSet) { const error = !active && errorSet.has(p.provider?.toLowerCase()); const nodeId = `provider-${p.provider}`; const data = { - label: config.name || p.name || p.provider, + label: (config.name !== p.provider ? config.name : null) || p.name || p.provider, color: config.color || "#6b7280", imageUrl: getProviderImageUrl(p.provider), textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(), diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index 29efbbe..7c76f30 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -21,7 +21,7 @@ const checkClaudeInstalled = async () => { try { const isWindows = os.platform() === "win32"; const command = isWindows ? "where claude" : "command -v claude"; - await execAsync(command); + await execAsync(command, { windowsHide: true }); return true; } catch { return false; diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js index 90bf85a..76ec7c6 100644 --- a/src/app/api/cli-tools/codex-settings/route.js +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -76,7 +76,7 @@ const checkCodexInstalled = async () => { try { const isWindows = os.platform() === "win32"; const command = isWindows ? "where codex" : "command -v codex"; - await execAsync(command); + await execAsync(command, { windowsHide: true }); return true; } catch { return false; diff --git a/src/app/api/cli-tools/droid-settings/route.js b/src/app/api/cli-tools/droid-settings/route.js index cdf044a..1a7a198 100644 --- a/src/app/api/cli-tools/droid-settings/route.js +++ b/src/app/api/cli-tools/droid-settings/route.js @@ -17,7 +17,7 @@ const checkDroidInstalled = async () => { try { const isWindows = os.platform() === "win32"; const command = isWindows ? "where droid" : "command -v droid"; - await execAsync(command); + await execAsync(command, { windowsHide: true }); return true; } catch { return false; diff --git a/src/app/api/cli-tools/openclaw-settings/route.js b/src/app/api/cli-tools/openclaw-settings/route.js index 4c1af02..3f5a9b6 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.js +++ b/src/app/api/cli-tools/openclaw-settings/route.js @@ -17,7 +17,7 @@ const checkOpenClawInstalled = async () => { try { const isWindows = os.platform() === "win32"; const command = isWindows ? "where openclaw" : "command -v openclaw"; - await execAsync(command); + await execAsync(command, { windowsHide: true }); return true; } catch { return false; diff --git a/src/app/api/models/test/route.js b/src/app/api/models/test/route.js new file mode 100644 index 0000000..c258849 --- /dev/null +++ b/src/app/api/models/test/route.js @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getApiKeys } from "@/lib/localDb"; + +// POST /api/models/test - Ping a single model via internal completions +export async function POST(request) { + try { + const { model } = await request.json(); + if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 }); + + const url = new URL(request.url); + const baseUrl = `${url.protocol}//${url.host}`; + + // Get an active internal API key for auth (if requireApiKey is enabled) + let apiKey = null; + try { + const keys = await getApiKeys(); + apiKey = keys.find((k) => k.isActive !== false)?.key || null; + } catch {} + + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + + const start = Date.now(); + const res = await fetch(`${baseUrl}/api/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: 1, + stream: false, + messages: [{ role: "user", content: "hi" }], + }), + signal: AbortSignal.timeout(15000), + }); + const latencyMs = Date.now() - start; + + // 200 = ok; 400 = bad request but auth passed (model reachable) + const ok = res.status === 200 || res.status === 400; + let error = null; + if (!ok) { + const text = await res.text().catch(() => ""); + error = `HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`; + } + + return NextResponse.json({ ok, latencyMs, error }); + } catch (err) { + return NextResponse.json({ ok: false, error: err.message }, { status: 500 }); + } +} diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 8ca7a35..cedec64 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getProviderConnections, createProviderConnection, getProviderNodeById } from "@/models"; +import { getProviderConnections, createProviderConnection, getProviderNodeById, getProviderNodes } from "@/models"; import { APIKEY_PROVIDERS } from "@/shared/constants/config"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; @@ -7,15 +7,31 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha export async function GET() { try { const connections = await getProviderConnections(); + + // Build nodeNameMap for compatible providers (id → name) + let nodeNameMap = {}; + try { + const nodes = await getProviderNodes(); + for (const node of nodes) { + if (node.id && node.name) nodeNameMap[node.id] = node.name; + } + } catch {} - // Hide sensitive fields - const safeConnections = connections.map(c => ({ - ...c, - apiKey: undefined, - accessToken: undefined, - refreshToken: undefined, - idToken: undefined, - })); + // Hide sensitive fields, enrich name for compatible providers + const safeConnections = connections.map(c => { + const isCompatible = isOpenAICompatibleProvider(c.provider) || isAnthropicCompatibleProvider(c.provider); + const name = isCompatible + ? (nodeNameMap[c.provider] || c.providerSpecificData?.nodeName || c.provider) + : c.name; + return { + ...c, + name, + apiKey: undefined, + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + }; + }); return NextResponse.json({ connections: safeConnections }); } catch (error) { diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 036ba32..1f55299 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -436,7 +436,7 @@ export async function getUsageStats() { const history = db.data.history || []; // Import localDb to get provider connection names and API keys - const { getProviderConnections, getApiKeys } = await import("@/lib/localDb.js"); + const { getProviderConnections, getApiKeys, getProviderNodes } = await import("@/lib/localDb.js"); // Fetch all provider connections to get account names let allConnections = []; @@ -453,6 +453,15 @@ export async function getUsageStats() { connectionMap[conn.id] = conn.name || conn.email || conn.id; } + // Build map from compatible provider ID → friendly name (from providerNodes) + const providerNodeNameMap = {}; + try { + const nodes = await getProviderNodes(); + for (const node of nodes) { + if (node.id && node.name) providerNodeNameMap[node.id] = node.name; + } + } catch {} + // Fetch all API keys to get key names let allApiKeys = []; try { @@ -596,6 +605,8 @@ export async function getUsageStats() { // By Model // Format: "modelName (provider)" if provider is known const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model; + // Resolve friendly name for compatible providers + const providerDisplayName = providerNodeNameMap[entry.provider] || entry.provider; if (!stats.byModel[modelKey]) { stats.byModel[modelKey] = { @@ -604,7 +615,7 @@ export async function getUsageStats() { completionTokens: 0, cost: 0, rawModel: entry.model, - provider: entry.provider, + provider: providerDisplayName, lastUsed: entry.timestamp }; } @@ -629,7 +640,7 @@ export async function getUsageStats() { completionTokens: 0, cost: 0, rawModel: entry.model, - provider: entry.provider, + provider: providerDisplayName, connectionId: entry.connectionId, accountName: accountName, lastUsed: entry.timestamp @@ -660,7 +671,7 @@ export async function getUsageStats() { completionTokens: 0, cost: 0, rawModel: entry.model, - provider: entry.provider, + provider: providerDisplayName, apiKey: entry.apiKey, keyName: keyName, apiKeyKey: apiKeyKey, @@ -686,7 +697,7 @@ export async function getUsageStats() { completionTokens: 0, cost: 0, rawModel: entry.model, - provider: entry.provider, + provider: providerDisplayName, apiKey: null, keyName: keyName, apiKeyKey: apiKeyKey, @@ -715,7 +726,7 @@ export async function getUsageStats() { cost: 0, endpoint: endpoint, rawModel: entry.model, - provider: entry.provider, + provider: providerDisplayName, lastUsed: entry.timestamp }; } diff --git a/src/mitm/cert/generate.js b/src/mitm/cert/generate.js index 9e59249..31c972a 100644 --- a/src/mitm/cert/generate.js +++ b/src/mitm/cert/generate.js @@ -1,6 +1,6 @@ const path = require("path"); const fs = require("fs"); -const os = require("os"); +const { MITM_DIR } = require("../paths"); const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; @@ -8,7 +8,7 @@ const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; * Generate self-signed SSL certificate using selfsigned (pure JS, no openssl needed) */ async function generateCert() { - const certDir = path.join(os.homedir(), ".9router", "mitm"); + const certDir = MITM_DIR; const keyPath = path.join(certDir, "server.key"); const certPath = path.join(certDir, "server.crt"); diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js index dfe9bc2..6d73be5 100644 --- a/src/mitm/cert/install.js +++ b/src/mitm/cert/install.js @@ -80,17 +80,17 @@ async function installCertMac(sudoPassword, certPath) { } async function installCertWindows(certPath) { - // Use PowerShell elevated to add cert to Root store - const psCommand = `Start-Process certutil -ArgumentList '-addstore','Root','${certPath.replace(/'/g, "''")}' -Verb RunAs -Wait`; + const escaped = certPath.replace(/'/g, "''"); + const psCommand = `Start-Process certutil -ArgumentList '-addstore','Root','${escaped}' -Verb RunAs -Wait -WindowStyle Hidden`; return new Promise((resolve, reject) => { - exec(`powershell -Command "${psCommand}"`, (error) => { - if (error) { - reject(new Error(`Failed to install certificate: ${error.message}`)); - } else { - console.log(`✅ Installed certificate to Windows Root store`); - resolve(); + exec( + `powershell -NonInteractive -WindowStyle Hidden -Command "${psCommand}"`, + { windowsHide: true }, + (error) => { + if (error) reject(new Error(`Failed to install certificate: ${error.message}`)); + else { console.log("✅ Installed certificate to Windows Root store"); resolve(); } } - }); + ); }); } @@ -125,16 +125,16 @@ async function uninstallCertMac(sudoPassword, certPath) { } async function uninstallCertWindows() { - const psCommand = `Start-Process certutil -ArgumentList '-delstore','Root','daily-cloudcode-pa.googleapis.com' -Verb RunAs -Wait`; + const psCommand = `Start-Process certutil -ArgumentList '-delstore','Root','daily-cloudcode-pa.googleapis.com' -Verb RunAs -Wait -WindowStyle Hidden`; return new Promise((resolve, reject) => { - exec(`powershell -Command "${psCommand}"`, (error) => { - if (error) { - reject(new Error(`Failed to uninstall certificate: ${error.message}`)); - } else { - console.log("✅ Uninstalled certificate from Windows Root store"); - resolve(); + exec( + `powershell -NonInteractive -WindowStyle Hidden -Command "${psCommand}"`, + { windowsHide: true }, + (error) => { + if (error) reject(new Error(`Failed to uninstall certificate: ${error.message}`)); + else { console.log("✅ Uninstalled certificate from Windows Root store"); resolve(); } } - }); + ); }); } diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js index 9cafc91..408f754 100644 --- a/src/mitm/dns/dnsConfig.js +++ b/src/mitm/dns/dnsConfig.js @@ -38,18 +38,20 @@ function execWithPassword(command, password) { } /** - * Execute elevated command on Windows via PowerShell RunAs + * Execute elevated command on Windows via PowerShell RunAs (hidden window) */ function execElevatedWindows(command) { return new Promise((resolve, reject) => { - const psCommand = `Start-Process cmd -ArgumentList '/c','${command.replace(/'/g, "''")}' -Verb RunAs -Wait`; - exec(`powershell -Command "${psCommand}"`, (error, stdout, stderr) => { - if (error) { - reject(new Error(`Elevated command failed: ${error.message}\n${stderr}`)); - } else { - resolve(stdout); + const escaped = command.replace(/'/g, "''"); + const psCommand = `Start-Process cmd -ArgumentList '/c','${escaped}' -Verb RunAs -Wait -WindowStyle Hidden`; + exec( + `powershell -NonInteractive -WindowStyle Hidden -Command "${psCommand}"`, + { windowsHide: true }, + (error, stdout, stderr) => { + if (error) reject(new Error(`Elevated command failed: ${error.message}\n${stderr}`)); + else resolve(stdout); } - }); + ); }); } @@ -84,17 +86,26 @@ async function addDNSEntry(sudoPassword) { try { if (IS_WIN) { - // Windows: add each entry separately - for (const host of entriesToAdd) { - const entry = `127.0.0.1 ${host}`; - await execElevatedWindows(`echo ${entry} >> "${HOSTS_FILE}"`); - } + // Windows: add all entries + flush in one elevated PowerShell call (single UAC) + const hostsPath = HOSTS_FILE.replace(/'/g, "''"); + const addLines = entriesToAdd.map(host => + `$hc = Get-Content -Path '${hostsPath}' -Raw -ErrorAction SilentlyContinue; if ($hc -notmatch '${host}') { Add-Content -Path '${hostsPath}' -Value '127.0.0.1 ${host}' -Encoding UTF8 }` + ).join("; "); + const psScript = `${addLines}; ipconfig /flushdns | Out-Null`; + await new Promise((resolve, reject) => { + const escaped = psScript.replace(/"/g, '\\"'); + exec( + `powershell -NonInteractive -WindowStyle Hidden -Command "Start-Process powershell -ArgumentList '-NonInteractive -WindowStyle Hidden -Command \\"${escaped}\\"' -Verb RunAs -Wait"`, + { windowsHide: true }, + (error) => { if (error) reject(new Error(`Failed to add DNS: ${error.message}`)); else resolve(); } + ); + }); } else { await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword); } - // Flush DNS cache + // Flush DNS cache (non-Windows) if (IS_WIN) { - await execElevatedWindows("ipconfig /flushdns"); + // already flushed above } else if (IS_MAC) { await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword); } else { @@ -121,7 +132,7 @@ async function removeDNSEntry(sudoPassword) { try { if (IS_WIN) { - // Read in Node, filter, write to temp file, then elevated-copy over hosts + // Read in Node, filter, write to temp file, then single elevated-copy + flush (1 UAC) const content = fs.readFileSync(HOSTS_FILE, "utf8"); const filtered = content.split(/\r?\n/).filter(l => !TARGET_HOSTS.some(host => l.includes(host))).join("\r\n"); if (!filtered.trim() && content.trim()) { @@ -129,14 +140,21 @@ async function removeDNSEntry(sudoPassword) { } const tmpFile = path.join(os.tmpdir(), "hosts_filtered.tmp"); fs.writeFileSync(tmpFile, filtered, "utf8"); - // Use elevated cmd to copy temp file over hosts (safe: original untouched until copy succeeds) - const psCommand = `Start-Process cmd -ArgumentList '/c','copy /Y "${tmpFile}" "${HOSTS_FILE}"' -Verb RunAs -Wait`; + const tmpEsc = tmpFile.replace(/'/g, "''"); + const hostsEsc = HOSTS_FILE.replace(/'/g, "''"); + // Single UAC: copy temp file over hosts + flush DNS + const psScript = `Copy-Item -Path '${tmpEsc}' -Destination '${hostsEsc}' -Force; ipconfig /flushdns | Out-Null; Remove-Item '${tmpEsc}' -ErrorAction SilentlyContinue`; await new Promise((resolve, reject) => { - exec(`powershell -Command "${psCommand}"`, (error) => { - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - if (error) reject(new Error(`Failed to remove DNS entry: ${error.message}`)); - else resolve(); - }); + const escaped = psScript.replace(/"/g, '\\"'); + exec( + `powershell -NonInteractive -WindowStyle Hidden -Command "Start-Process powershell -ArgumentList '-NonInteractive -WindowStyle Hidden -Command \\"${escaped}\\"' -Verb RunAs -Wait"`, + { windowsHide: true }, + (error) => { + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + if (error) reject(new Error(`Failed to remove DNS entry: ${error.message}`)); + else resolve(); + } + ); }); } else { // Remove all target hosts using sed @@ -147,9 +165,9 @@ async function removeDNSEntry(sudoPassword) { await execWithPassword(sedCmd, sudoPassword); } } - // Flush DNS cache + // Flush DNS cache (non-Windows, already flushed above for Windows) if (IS_WIN) { - await execElevatedWindows("ipconfig /flushdns"); + // already flushed above } else if (IS_MAC) { await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword); } else { diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 38091d2..44058b9 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -10,9 +10,12 @@ const { addDNSEntry, removeDNSEntry, checkDNSEntry } = require("./dns/dnsConfig" const IS_WIN = process.platform === "win32"; const { generateCert } = require("./cert/generate"); const { installCert } = require("./cert/install"); +const { MITM_DIR } = require("./paths"); const MITM_PORT = 443; -const PID_FILE = path.join(os.homedir(), ".9router", "mitm", ".mitm.pid"); +// Windows: node listens on 8443, netsh portproxy forwards 443→8443 +const MITM_WIN_NODE_PORT = 8443; +const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); // Resolve server.js path robustly: // __dirname is unreliable inside Next.js bundles, so we use DATA_DIR env or @@ -48,20 +51,15 @@ const ENCRYPT_SALT = "9router-mitm-pwd"; function getProcessUsingPort443() { try { if (IS_WIN) { - // Windows: use netstat to find PID, then tasklist to get process name - const netstatResult = execSync("netstat -ano | findstr :443", { encoding: "utf8" }); - const lines = netstatResult.trim().split("\n"); - if (lines.length > 0) { - // Extract PID from last column (format: TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 1234) - const pidMatch = lines[0].match(/\s+(\d+)\s*$/); - if (pidMatch) { - const pid = pidMatch[1]; - const tasklistResult = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8" }); - const processMatch = tasklistResult.match(/"([^"]+)"/); - if (processMatch) { - return processMatch[1].replace(".exe", ""); - } - } + // Use PowerShell for precise port 443 owner lookup + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + + `"$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { $c.OwningProcess } else { 0 }"`; + const pidStr = execSync(psCmd, { encoding: "utf8", windowsHide: true }).trim(); + const pid = parseInt(pidStr, 10); + if (pid && pid > 4) { + const tasklistResult = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8", windowsHide: true }); + const processMatch = tasklistResult.match(/"([^"]+)"/); + if (processMatch) return processMatch[1].replace(".exe", ""); } } else { // macOS/Linux: use lsof @@ -208,20 +206,19 @@ function checkPort443Free() { function getPort443Owner(sudoPassword) { return new Promise((resolve) => { if (IS_WIN) { - exec(`netstat -ano | findstr ":443 "`, (err, stdout) => { - if (err || !stdout.trim()) return resolve(null); - for (const line of stdout.split("\n")) { - const match = line.match(/LISTENING\s+(\d+)/i); - if (match) { - const pid = parseInt(match[1], 10); - exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, (e2, out2) => { - const m = out2?.match(/"([^"]+)"/); - resolve({ pid, name: m ? m[1] : "unknown" }); - }); - return; - } - } - resolve(null); + // Use PowerShell Get-NetTCPConnection for precise port 443 owner lookup + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "` + + `$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; ` + + `if ($c) { $c.OwningProcess } else { 0 }"`; + exec(psCmd, { windowsHide: true }, (err, stdout) => { + if (err) return resolve(null); + const pid = parseInt(stdout.trim(), 10); + // 0 = no owner, <=4 = System/Idle — not real port owners + if (!pid || pid <= 4) return resolve(null); + exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (e2, out2) => { + const m = out2?.match(/"([^"]+)"/); + resolve({ pid, name: m ? m[1] : "unknown" }); + }); }); } else { // Use ps to find node process running server.js (no sudo needed) @@ -281,12 +278,12 @@ async function killLeftoverMitm(sudoPassword) { * Poll MITM health endpoint until server is up or timeout. * Returns { ok, pid } on success, null on timeout. */ -function pollMitmHealth(timeoutMs) { +function pollMitmHealth(timeoutMs, port = MITM_PORT) { return new Promise((resolve) => { const deadline = Date.now() + timeoutMs; const check = () => { const req = https.request( - { hostname: "127.0.0.1", port: 443, path: "/_mitm_health", method: "GET", rejectUnauthorized: false }, + { hostname: "127.0.0.1", port, path: "/_mitm_health", method: "GET", rejectUnauthorized: false }, (res) => { let body = ""; res.on("data", (d) => { body += d; }); @@ -332,8 +329,7 @@ async function getMitmStatus() { } const dnsConfigured = checkDNSEntry(); - const certDir = path.join(os.homedir(), ".9router", "mitm"); - const certExists = fs.existsSync(path.join(certDir, "server.crt")); + const certExists = fs.existsSync(path.join(MITM_DIR, "server.crt")); return { running, pid, dnsConfigured, certExists }; } @@ -372,71 +368,132 @@ async function startMitm(apiKey, sudoPassword) { // Kill any leftover MITM server from a previous failed start attempt await killLeftoverMitm(sudoPassword); - // Check port 443 availability BEFORE modifying system - // "no-permission" = EACCES: port may be held by a root process, check via lsof/netstat - const portStatus = await checkPort443Free(); - if (portStatus === "in-use" || portStatus === "no-permission") { - const owner = await getPort443Owner(sudoPassword); - if (owner && owner.name === "node") { - // Orphan MITM node process — kill it and continue - console.log(`[MITM] Killing orphan node process on port 443 (PID ${owner.pid})...`); - try { - if (IS_WIN) { - await new Promise((resolve) => exec(`taskkill /F /PID ${owner.pid}`, resolve)); - } else { + if (!IS_WIN) { + // Check port 443 availability — Windows handles this inside elevated script + const portStatus = await checkPort443Free(); + if (portStatus === "in-use" || portStatus === "no-permission") { + const owner = await getPort443Owner(sudoPassword); + if (owner && owner.name === "node") { + // Orphan MITM node process — kill it and continue + console.log(`[MITM] Killing orphan node process on port 443 (PID ${owner.pid})...`); + try { const { execWithPassword } = require("./dns/dnsConfig"); await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword); - } - await new Promise(r => setTimeout(r, 800)); - } catch { - // best effort — continue anyway + await new Promise(r => setTimeout(r, 800)); + } catch { /* best effort */ } + } else if (owner) { + const shortName = owner.name.includes("/") + ? owner.name.split("/").filter(Boolean).pop() + : owner.name; + throw new Error( + `Port 443 is already in use by "${shortName}" (PID ${owner.pid}). Stop that process first, then retry.` + ); } - } else if (owner) { - const shortName = owner.name.includes("/") - ? owner.name.split("/").filter(Boolean).pop() - : owner.name; - throw new Error( - `Port 443 is already in use by "${shortName}" (PID ${owner.pid}). Stop that process first, then retry.` - ); } - // owner === null + no-permission → likely just needs sudo, proceed } - // 1. Generate SSL certificate if not exists - const certPath = path.join(os.homedir(), ".9router", "mitm", "server.crt"); + // 1. Generate SSL certificate if not exists (no elevation needed) + const certPath = path.join(MITM_DIR, "server.crt"); if (!fs.existsSync(certPath)) { console.log("Generating SSL certificate..."); await generateCert(); } - // 2. Install certificate to system keychain - // Skip if db flag says installed AND cert file still exists (same cert in keychain) - const settings = _getSettings ? await _getSettings().catch(() => ({})) : {}; - const certAlreadyInstalled = settings.mitmCertInstalled && fs.existsSync(certPath); - if (!certAlreadyInstalled) { - await installCert(sudoPassword, certPath); - if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); - } - - // 3. Add DNS entry - console.log("Adding DNS entry..."); - await addDNSEntry(sudoPassword); - - // 4. Spawn MITM server with sudo (port 443 requires root on macOS/Linux) + // 4. Spawn MITM server console.log("Starting MITM server..."); if (IS_WIN) { - // Use cmd /c to set env vars inline before launching node (env vars survive RunAs) - const nodePath = process.execPath.replace(/"/g, '\\"'); - const serverPath = SERVER_PATH.replace(/"/g, '\\"'); - const cmdLine = `set ROUTER_API_KEY=${apiKey}&& set NODE_ENV=production&& "${nodePath}" "${serverPath}"`; - serverProcess = spawn("powershell", [ - "-NoProfile", "-Command", - `Start-Process cmd -ArgumentList '/c','${cmdLine.replace(/'/g, "''")}' -Verb RunAs -WindowStyle Hidden` - ], { stdio: "ignore" }); + // Windows: single UAC via VBScript → elevated PowerShell script that: + // 1. Installs SSL cert 2. Adds DNS entries 3. Starts node server.js (elevated → can bind 443) 4. Writes flag + // Node polls flag file to know when server is ready, then health-checks port 443 + const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); + const TARGET_HOSTS_WIN = ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"]; + + // Use Chr(34) in VBScript for quotes — avoid escaping issues + const flagFile = path.join(os.tmpdir(), `mitm_ready_${Date.now()}.flag`); + + // PowerShell uses single-quoted strings — escape single quotes only + const psSQ = (s) => s.replace(/'/g, "''"); + const certPs = psSQ(certPath); + const hostsPs = psSQ(hostsFile); + const nodePs = psSQ(process.execPath); + const serverPs = psSQ(SERVER_PATH); + const flagPs = psSQ(flagFile); + + const dnsLines = TARGET_HOSTS_WIN.map(h => + `$hc = Get-Content -Path '${hostsPs}' -Raw -ErrorAction SilentlyContinue\n` + + `if ($hc -notmatch [regex]::Escape('${h}')) { Add-Content -Path '${hostsPs}' -Value '127.0.0.1 ${h}' -Encoding UTF8 }` + ).join("\n"); + + const psScript = [ + `# 0. Kill any orphan node process on port 443`, + `$conn = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1`, + `if ($conn -and $conn.OwningProcess -gt 4) { Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue }`, + `Start-Sleep -Milliseconds 500`, + ``, + `# 1. Install SSL cert to Windows Root store (always run to ensure trust)`, + `& certutil -addstore Root '${certPs}' | Out-Null`, + ``, + `# 2. Add DNS entries to hosts file`, + dnsLines, + `& ipconfig /flushdns | Out-Null`, + ``, + `# 3. Start node MITM server elevated (required to bind port 443)`, + `# Use cmd /c to pass env vars inline — Start-Process does not inherit current env`, + `$nodeCmd = 'set ROUTER_API_KEY=${psSQ(apiKey)}&& set NODE_ENV=production&& "${nodePs}" "${serverPs}"'`, + `Start-Process cmd -ArgumentList '/c',$nodeCmd -WindowStyle Hidden`, + ``, + `# 4. Signal ready`, + `Start-Sleep -Milliseconds 500`, + `Set-Content -Path '${flagPs}' -Value 'ready' -Encoding UTF8`, + ].join("\n"); + + const tmpPs1 = path.join(os.tmpdir(), `mitm_start_${Date.now()}.ps1`); + fs.writeFileSync(tmpPs1, psScript, "utf8"); + + // VBScript uses Shell.Application.ShellExecute to trigger UAC from any context + // Chr(34) = double-quote, avoids VBScript string escaping issues + const vbs = [ + `Set oShell = CreateObject("Shell.Application")`, + `Dim ps`, + `ps = Chr(34) & "powershell.exe" & Chr(34)`, + `Dim args`, + `args = "-NoProfile -ExecutionPolicy Bypass -File " & Chr(34) & "${tmpPs1}" & Chr(34)`, + `oShell.ShellExecute ps, args, "", "runas", 1`, + ].join("\r\n"); + const tmpVbs = path.join(os.tmpdir(), `mitm_uac_${Date.now()}.vbs`); + fs.writeFileSync(tmpVbs, vbs, "utf8"); + + // Launch VBScript — shows UAC dialog, user confirms, script runs elevated + spawn("wscript.exe", [tmpVbs], { stdio: "ignore", windowsHide: false, detached: true }).unref(); + + // Poll flag file — resolves when elevated script completes + await new Promise((resolve, reject) => { + const deadline = Date.now() + 90000; // 90s: UAC wait + cert install + node start + const poll = () => { + if (fs.existsSync(flagFile)) { + try { fs.unlinkSync(flagFile); fs.unlinkSync(tmpPs1); fs.unlinkSync(tmpVbs); } catch { /* ignore */ } + return resolve(); + } + if (Date.now() > deadline) return reject(new Error("Timed out waiting for UAC confirmation. Please try again.")); + setTimeout(poll, 500); + }; + poll(); + }); + + if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); } else { + // macOS/Linux: install cert + add DNS (requires sudo), then spawn server + const settings = _getSettings ? await _getSettings().catch(() => ({})) : {}; + const certAlreadyInstalled = settings.mitmCertInstalled && fs.existsSync(certPath); + if (!certAlreadyInstalled) { + await installCert(sudoPassword, certPath); + if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); + } + console.log("Adding DNS entry..."); + await addDNSEntry(sudoPassword); + // sudo -S: read password from stdin, -E: preserve env vars - // Pass ROUTER_API_KEY inline via env=... wrapper to avoid sudo stripping env const inlineCmd = `ROUTER_API_KEY='${apiKey}' NODE_ENV='production' '${process.execPath}' '${SERVER_PATH}'`; serverProcess = spawn( "sudo", ["-S", "-E", "sh", "-c", inlineCmd], @@ -447,8 +504,11 @@ async function startMitm(apiKey, sudoPassword) { serverProcess.stdin.end(); } - serverPid = serverProcess.pid; - fs.writeFileSync(PID_FILE, String(serverPid)); + // Windows: node was started by elevated script — PID comes from health check later + if (!IS_WIN && serverProcess) { + serverPid = serverProcess.pid; + fs.writeFileSync(PID_FILE, String(serverPid)); + } let startError = null; if (!IS_WIN) { @@ -471,8 +531,8 @@ async function startMitm(apiKey, sudoPassword) { }); } - // Wait for server to be ready by polling health endpoint - const health = await pollMitmHealth(IS_WIN ? 12000 : 8000); + // Wait for server to be ready by polling health endpoint on port 443 + const health = await pollMitmHealth(IS_WIN ? 15000 : 8000, MITM_PORT); if (!health) { if (IS_WIN) serverProcess = null; @@ -483,6 +543,9 @@ async function startMitm(apiKey, sudoPassword) { throw new Error(`MITM server failed to start. ${reason}`); } + // On Windows, mark cert as installed after successful start + if (IS_WIN && _updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); + // On Windows, use real PID from health check (launcher exits immediately after UAC) if (IS_WIN && health.pid) { serverPid = health.pid; @@ -524,8 +587,58 @@ async function stopMitm(sudoPassword) { serverPid = null; } - console.log("Removing DNS entry..."); - await removeDNSEntry(sudoPassword); + if (IS_WIN) { + // Windows stop: remove DNS entries via elevated VBScript (1 UAC) + const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); + const TARGET_HOSTS_WIN = ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"]; + const psSQ = (s) => s.replace(/'/g, "''"); + + // Filter hosts content in Node (read doesn't need elevation) + let hostsContent = ""; + try { hostsContent = fs.readFileSync(hostsFile, "utf8"); } catch { /* ignore */ } + const filtered = hostsContent.split(/\r?\n/) + .filter(l => !TARGET_HOSTS_WIN.some(h => l.includes(h))) + .join("\r\n"); + const tmpHosts = path.join(os.tmpdir(), "mitm_hosts_clean.tmp"); + fs.writeFileSync(tmpHosts, filtered, "utf8"); + + const flagFile = path.join(os.tmpdir(), "mitm_stop_done.flag"); + const psScript = [ + `Copy-Item -Path '${psSQ(tmpHosts)}' -Destination '${psSQ(hostsFile)}' -Force`, + `& ipconfig /flushdns | Out-Null`, + `Remove-Item '${psSQ(tmpHosts)}' -ErrorAction SilentlyContinue`, + `Set-Content -Path '${psSQ(flagFile)}' -Value 'done' -Encoding UTF8`, + ].join("\n"); + const tmpPs1 = path.join(os.tmpdir(), "mitm_stop.ps1"); + fs.writeFileSync(tmpPs1, psScript, "utf8"); + + const vbs = [ + `Set oShell = CreateObject("Shell.Application")`, + `Dim args`, + `args = "-NoProfile -ExecutionPolicy Bypass -File " & Chr(34) & "${tmpPs1}" & Chr(34)`, + `oShell.ShellExecute "powershell.exe", args, "", "runas", 1`, + ].join("\r\n"); + const tmpVbs = path.join(os.tmpdir(), "mitm_stop_uac.vbs"); + fs.writeFileSync(tmpVbs, vbs, "utf8"); + spawn("wscript.exe", [tmpVbs], { stdio: "ignore", windowsHide: false, detached: true }).unref(); + + // Poll flag — best effort, don't block UI if user cancels UAC + await new Promise((resolve) => { + const deadline = Date.now() + 30000; + const poll = () => { + if (fs.existsSync(flagFile)) { + try { fs.unlinkSync(flagFile); fs.unlinkSync(tmpPs1); fs.unlinkSync(tmpVbs); } catch { /* ignore */ } + return resolve(); + } + if (Date.now() > deadline) return resolve(); + setTimeout(poll, 500); + }; + poll(); + }); + } else { + console.log("Removing DNS entry..."); + await removeDNSEntry(sudoPassword); + } try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } diff --git a/src/mitm/paths.js b/src/mitm/paths.js new file mode 100644 index 0000000..3c667f4 --- /dev/null +++ b/src/mitm/paths.js @@ -0,0 +1,16 @@ +const path = require("path"); +const os = require("os"); + +// Single source of truth for data directory — matches localDb.js logic +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router"); + } + return path.join(os.homedir(), ".9router"); +} + +const DATA_DIR = getDataDir(); +const MITM_DIR = path.join(DATA_DIR, "mitm"); + +module.exports = { DATA_DIR, MITM_DIR }; diff --git a/src/mitm/server.js b/src/mitm/server.js index d982325..ec76c38 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -3,8 +3,6 @@ const fs = require("fs"); const path = require("path"); const dns = require("dns"); const { promisify } = require("util"); -const os = require("os"); - // Configuration const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; const TARGET_HOSTS = [ @@ -14,7 +12,8 @@ const TARGET_HOSTS = [ const LOCAL_PORT = 443; const ROUTER_URL = "http://localhost:20128/v1/chat/completions"; const API_KEY = process.env.ROUTER_API_KEY; -const DB_FILE = path.join(os.homedir(), ".9router", "db.json"); +const { DATA_DIR, MITM_DIR } = require("./paths"); +const DB_FILE = path.join(DATA_DIR, "db.json"); // Toggle logging (set true to enable file logging for debugging) const ENABLE_FILE_LOG = false; @@ -25,7 +24,7 @@ if (!API_KEY) { } // Load SSL certificates -const certDir = path.join(os.homedir(), ".9router", "mitm"); +const certDir = MITM_DIR; let sslOptions; try { sslOptions = { @@ -92,17 +91,18 @@ function collectBodyRaw(req) { }); } -function extractModel(body) { - try { - return JSON.parse(body.toString()).model || null; - } catch { - return null; - } +// Extract model from URL path (Gemini format: /v1beta/models/gemini-2.0-flash:generateContent) +// Fallback to body.model (OpenAI format) +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; } } function getMappedModel(model) { if (!model) return null; try { + if (!fs.existsSync(DB_FILE)) return null; const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); return db.mitmAlias?.antigravity?.[model] || null; } catch { @@ -200,8 +200,8 @@ const server = https.createServer(sslOptions, async (req, res) => { return passthrough(req, res, bodyBuffer); } - const model = extractModel(bodyBuffer); - console.log(`📡 ${model} (passthrough)`); + const model = extractModel(req.url, bodyBuffer); + console.log(`📡 intercepted: ${req.url} | model: ${model}`); const mappedModel = getMappedModel(model); if (!mappedModel) { diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 230ad64..f67619f 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -187,7 +187,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re }); }, onRequestSuccess: async () => { - await clearAccountError(credentials.connectionId, credentials); + await clearAccountError(credentials.connectionId, credentials, model); } }); diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index 3449ea5..a8a68e0 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -122,7 +122,7 @@ export async function handleEmbeddings(request) { }); }, onRequestSuccess: async () => { - await clearAccountError(credentials.connectionId, credentials); + await clearAccountError(credentials.connectionId, credentials, model); } }); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index e1be191..fa9b710 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -178,30 +178,47 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr } /** - * Clear account error status (only if currently has error) - * Clears all modelLock_* fields and resets error state. + * Clear account error status on successful request. + * - Clears modelLock_${model} (the model that just succeeded) + * - Lazy-cleans any other expired modelLock_* keys + * - Resets error state only if no active locks remain + * @param {string} connectionId + * @param {object} currentConnection - credentials object (has _connection) or raw connection + * @param {string|null} model - model that succeeded */ -export async function clearAccountError(connectionId, currentConnection) { - // Support both direct connection object and credentials wrapper +export async function clearAccountError(connectionId, currentConnection, model = null) { const conn = currentConnection._connection || currentConnection; const now = Date.now(); - - // Collect all modelLock_* keys (both active and expired) const allLockKeys = Object.keys(conn).filter(k => k.startsWith("modelLock_")); - const hasError = conn.testStatus === "unavailable" || conn.lastError || allLockKeys.length > 0; - if (!hasError) return; // Skip if already clean + if (!conn.testStatus && !conn.lastError && allLockKeys.length === 0) return; - // Clear all modelLock_* keys (lazy cleanup of expired ones included) - const clearLocks = Object.fromEntries(allLockKeys.map(k => [k, null])); - await updateProviderConnection(connectionId, { - ...clearLocks, - testStatus: "active", - lastError: null, - lastErrorAt: null, - backoffLevel: 0 + // Keys to clear: current model's lock + all expired locks + const keysToClear = allLockKeys.filter(k => { + if (model && k === `modelLock_${model}`) return true; // succeeded model + if (model && k === "modelLock___all") return true; // account-level lock + const expiry = conn[k]; + return expiry && new Date(expiry).getTime() <= now; // expired }); - log.info("AUTH", `Account ${connectionId.slice(0, 8)} error cleared`); + + if (keysToClear.length === 0 && conn.testStatus !== "unavailable" && !conn.lastError) return; + + // Check if any active locks remain after clearing + const remainingActiveLocks = allLockKeys.filter(k => { + if (keysToClear.includes(k)) return false; + const expiry = conn[k]; + return expiry && new Date(expiry).getTime() > now; + }); + + const clearObj = Object.fromEntries(keysToClear.map(k => [k, null])); + + // Only reset error state if no active locks remain + if (remainingActiveLocks.length === 0) { + Object.assign(clearObj, { testStatus: "active", lastError: null, lastErrorAt: null, backoffLevel: 0 }); + } + + await updateProviderConnection(connectionId, clearObj); + log.info("AUTH", `Account ${connectionId.slice(0, 8)} cleared lock for model=${model || "__all"}`); } /**