diff --git a/.gitignore b/.gitignore index 1e3eb2f5..bf08aba4 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ README1.md deploy.sh ecosystem.config.* start.sh -src/mitm/server copy.js +src/mitm/server2.js diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 2b6001a8..7cb0e8e0 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -169,7 +169,9 @@ export class AntigravityExecutor extends BaseExecutor { let lastError = null; let lastStatus = 0; const MAX_AUTO_RETRIES = 3; + const MAX_RETRY_AFTER_RETRIES = 3; const retryAttemptsByUrl = {}; // Track retry attempts per URL + const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex); @@ -177,10 +179,13 @@ export class AntigravityExecutor extends BaseExecutor { const sessionId = transformedBody.request?.sessionId; const headers = this.buildHeaders(credentials, stream, sessionId); - // Initialize retry counter for this URL + // Initialize retry counters for this URL if (!retryAttemptsByUrl[urlIndex]) { retryAttemptsByUrl[urlIndex] = 0; } + if (!retryAfterAttemptsByUrl[urlIndex]) { + retryAfterAttemptsByUrl[urlIndex] = 0; + } try { const response = await proxyAwareFetch(url, { @@ -206,8 +211,9 @@ export class AntigravityExecutor extends BaseExecutor { } } - if (retryMs && retryMs <= MAX_RETRY_AFTER_MS) { - log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting...`); + if (retryMs && retryMs <= MAX_RETRY_AFTER_MS && retryAfterAttemptsByUrl[urlIndex] < MAX_RETRY_AFTER_RETRIES) { + retryAfterAttemptsByUrl[urlIndex]++; + log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting... (${retryAfterAttemptsByUrl[urlIndex]}/${MAX_RETRY_AFTER_RETRIES})`); await new Promise(resolve => setTimeout(resolve, retryMs)); urlIndex--; continue; diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index e090fc73..18b9adf6 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -155,13 +155,30 @@ export class CursorExecutor extends BaseExecutor { throw new Error("http2 module not available"); } + const HTTP2_TIMEOUT_MS = 60000; // 60s max — prevent hung sessions + return new Promise((resolve, reject) => { const urlObj = new URL(url); const client = http2.connect(`https://${urlObj.host}`); const chunks = []; let responseHeaders = {}; + let settled = false; - client.on("error", reject); + // Ensure client is always closed on settle + const finish = (fn) => (...args) => { + if (settled) return; + settled = true; + clearTimeout(hangTimeout); + client.close(); + fn(...args); + }; + + // Hard timeout: close session if server never responds + const hangTimeout = setTimeout(finish(() => { + reject(new Error("HTTP/2 request timed out")); + }), HTTP2_TIMEOUT_MS); + + client.on("error", finish(reject)); const req = client.request({ ":method": "POST", @@ -173,25 +190,18 @@ export class CursorExecutor extends BaseExecutor { req.on("response", (hdrs) => { responseHeaders = hdrs; }); req.on("data", (chunk) => { chunks.push(chunk); }); - req.on("end", () => { - client.close(); + req.on("end", finish(() => { resolve({ status: responseHeaders[":status"], headers: responseHeaders, body: Buffer.concat(chunks) }); - }); - req.on("error", (err) => { - client.close(); - reject(err); - }); + })); + req.on("error", finish(reject)); if (signal) { - signal.addEventListener("abort", () => { - req.close(); - client.close(); - reject(new Error("Request aborted")); - }); + const onAbort = finish(() => reject(new Error("Request aborted"))); + signal.addEventListener("abort", onAbort, { once: true }); } req.write(body); diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index f7f72a4a..b174007a 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -198,6 +198,9 @@ export class GithubExecutor extends BaseExecutor { } }); + if (!response.body) { + return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody }; + } const convertedStream = response.body.pipeThrough(transformStream); return { diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 698dcdf4..cc9ebdcc 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -345,6 +345,9 @@ export class KiroExecutor extends BaseExecutor { }); // Pipe response body through transform stream + if (!response.body) { + return new Response("data: [DONE]\n\n", { status: response.status, headers: { "Content-Type": "text/event-stream" } }); + } const transformedStream = response.body.pipeThrough(transformStream); return new Response(transformedStream, { diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index af62dbae..b61ed427 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -56,6 +56,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); let translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger); + if (!translatedBody) { + trackPendingRequest(model, provider, connectionId, false, true); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`); + } const toolNameMap = translatedBody._toolNameMap; delete translatedBody._toolNameMap; translatedBody.model = model; @@ -137,17 +141,23 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Handle 401/403 - try token refresh if (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN) { - const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); - if (newCredentials?.accessToken || newCredentials?.copilotToken) { - log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); - Object.assign(credentials, newCredentials); - if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials); - try { - const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); - if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; } - } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); } - } else { - log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + try { + const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); + if (newCredentials?.accessToken || newCredentials?.copilotToken) { + log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); + Object.assign(credentials, newCredentials); + if (onCredentialsRefreshed) { + try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); } + } + try { + const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); + if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; } + } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); } + } else { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + } + } catch (e) { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${e.message}`); } } @@ -182,12 +192,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Provider forced streaming but client wants JSON if (!clientRequestedStreaming && providerRequiresStreaming) { const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog }); - if (result) return result; + if (result) { streamController.handleComplete(); return result; } } // True non-streaming response if (!stream) { - return handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, trackDone, appendLog }); + const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, trackDone, appendLog }); + streamController.handleComplete(); + return result; } // Streaming response diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 4d202354..8b92852a 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -79,7 +79,8 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source for (const block of responseBody.content) { if (block.type === "text") { // Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```) - const text = block.text.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, ""); + const raw = block.text ?? ""; + const text = raw.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, ""); textContent += text; } else if (block.type === "thinking") thinkingContent += block.thinking || ""; else if (block.type === "tool_use") { diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 415c9b87..03d12c13 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -76,6 +76,8 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime, total: Date.now() - requestStartTime }; + const safeContent = contentObj?.content || "[Empty streaming response]"; + const safeThinking = contentObj?.thinking || null; saveRequestDetail(buildRequestDetail({ provider, model, connectionId, @@ -83,8 +85,8 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r tokens: usage || { prompt_tokens: 0, completion_tokens: 0 }, request: extractRequestConfig(body, stream), providerRequest: finalBody || translatedBody || null, - providerResponse: contentObj.content || "[Empty streaming response]", - response: { content: contentObj.content || "[Empty streaming response]", thinking: contentObj.thinking || null, type: "streaming" }, + providerResponse: safeContent, + response: { content: safeContent, thinking: safeThinking, type: "streaming" }, status: "success" }, { id: streamDetailId })).catch(err => { console.error("[RequestDetail] Failed to update streaming content:", err.message); diff --git a/open-sse/services/compact.js b/open-sse/services/compact.js index 2732d40e..812cd27b 100644 --- a/open-sse/services/compact.js +++ b/open-sse/services/compact.js @@ -38,8 +38,15 @@ export async function handleComboChat({ body, models, handleSingleModel, log }) const modelStr = models[i]; log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`); - const result = await handleSingleModel(body, modelStr); - + let result; + try { + result = await handleSingleModel(body, modelStr); + } catch (e) { + lastError = `${modelStr}: ${e.message}`; + log.warn("COMBO", `Model threw exception, trying next`, { model: modelStr, error: e.message }); + continue; + } + // Success or client error - return response if (result.ok || result.status < 500) { return result; diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js index 4f5ba327..b7aa3002 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -254,7 +254,9 @@ async function onboardUser(accessToken, tierID, externalSignal) { console.warn(`[ProjectId] onboardUser failed after ${MAX_ATTEMPTS} attempts: ${error.message}`); return null; } - throw error; + // Continue to next attempt instead of throwing (which would skip remaining retries) + console.warn(`[ProjectId] onboardUser attempt ${attempt} failed: ${error.message}, retrying...`); + await new Promise(resolve => setTimeout(resolve, 2000)); } finally { clearTimeout(timeoutId); externalSignal?.removeEventListener("abort", forwardAbort); diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 1bb41d7e..0949c64c 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -255,7 +255,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body } break; - case "github": + case "github": { // GitHub Copilot requires special headers to mimic VSCode // Prioritize copilotToken from providerSpecificData, fallback to accessToken const githubToken = credentials.copilotToken || credentials.accessToken; @@ -279,6 +279,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body headers["X-Initiator"] = "user"; headers["Accept"] = "application/json"; break; + } case "codex": case "qwen": diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 7ee5b0a4..1cdd93ec 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -69,83 +69,67 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo * Specialized refresh for Claude OAuth tokens */ export async function refreshClaudeOAuthToken(refreshToken, log) { - const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { - status: response.status, - error: errorText, + try { + const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: PROVIDERS.claude.clientId, + }), }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); + return null; + } + + const tokens = await response.json(); + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); return null; } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; } /** * Specialized refresh for Google providers (Gemini, Antigravity) */ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { - const response = await fetch(OAUTH_ENDPOINTS.google.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: clientId, - client_secret: clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { - status: response.status, - error: errorText, + try { + const response = await fetch(OAUTH_ENDPOINTS.google.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }), }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { status: response.status, error: errorText }); + return null; + } + + const tokens = await response.json(); + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`); return null; } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; } /** @@ -206,6 +190,7 @@ export async function refreshQwenToken(refreshToken, log) { * Specialized refresh for Codex (OpenAI) OAuth tokens */ export async function refreshCodexToken(refreshToken, log) { + try { const response = await fetch(OAUTH_ENDPOINTS.openai.token, { method: "POST", headers: { @@ -242,6 +227,10 @@ export async function refreshCodexToken(refreshToken, log) { refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); + return null; + } } /** diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 59ddf8ec..bf0c28ab 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -213,30 +213,34 @@ async function getGeminiUsage(accessToken) { */ async function getAntigravityUsage(accessToken, providerSpecificData) { try { - // First get project ID from subscription info - const projectId = await getAntigravityProjectId(accessToken); + // Fetch subscription info once — reuse for both projectId and plan + const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken); + const projectId = subscriptionInfo?.cloudaicompanionProject || null; // Fetch quota data with timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout - - const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { - method: "POST", - headers: { - "Authorization": `Bearer ${accessToken}`, - "User-Agent": ANTIGRAVITY_CONFIG.userAgent, - "Content-Type": "application/json", - "X-Client-Name": "antigravity", - "X-Client-Version": "1.107.0", - "x-request-source": "local", // MITM bypass - }, - body: JSON.stringify({ - ...(projectId ? { project: projectId } : {}) - }), - signal: controller.signal, - }); - - clearTimeout(timeoutId); + + let response; + try { + response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": ANTIGRAVITY_CONFIG.userAgent, + "Content-Type": "application/json", + "X-Client-Name": "antigravity", + "X-Client-Version": "1.107.0", + "x-request-source": "local", // MITM bypass + }, + body: JSON.stringify({ + ...(projectId ? { project: projectId } : {}) + }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } if (response.status === 403) { return { @@ -302,9 +306,6 @@ async function getAntigravityUsage(accessToken, providerSpecificData) { } } - // Get subscription info for plan type - const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken); - return { plan: subscriptionInfo?.currentTier?.name || "Unknown", quotas, @@ -332,10 +333,9 @@ async function getAntigravityProjectId(accessToken) { * Get Antigravity subscription info */ async function getAntigravitySubscriptionInfo(accessToken) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout - const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { method: "POST", headers: { @@ -347,15 +347,14 @@ async function getAntigravitySubscriptionInfo(accessToken) { body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }), signal: controller.signal, }); - - clearTimeout(timeoutId); if (!response.ok) return null; - return await response.json(); } catch (error) { console.error("[Antigravity Subscription] Error:", error.message); return null; + } finally { + clearTimeout(timeoutId); } } diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js index 9d94b1e8..e6f7f8b5 100644 --- a/open-sse/utils/cursorProtobuf.js +++ b/open-sse/utils/cursorProtobuf.js @@ -60,6 +60,7 @@ const FIELD = { MSG_ID: 13, MSG_TOOL_RESULTS: 18, MSG_IS_AGENTIC: 29, + MSG_SERVER_BUBBLE_ID: 32, MSG_UNIFIED_MODE: 47, MSG_SUPPORTED_TOOLS: 51, @@ -78,10 +79,19 @@ const FIELD = { CLIENT_RESULT_TOOL_CALL_ID: 35, CLIENT_RESULT_MODEL_CALL_ID: 48, CLIENT_RESULT_TOOL_INDEX: 49, + // Aliases used by encodeClientSideToolV2Result + CV2R_TOOL: 1, + CV2R_MCP_RESULT: 28, + CV2R_CALL_ID: 35, + CV2R_MODEL_CALL_ID: 48, + CV2R_TOOL_INDEX: 49, // MCPResult (nested inside ClientSideToolV2Result.mcp_result) MCP_RESULT_SELECTED_TOOL: 1, MCP_RESULT_RESULT: 2, + // Aliases used by encodeMcpResult + MCPR_SELECTED_TOOL: 1, + MCPR_RESULT: 2, // ClientSideToolV2Call (nested inside ToolResult.tool_call) CLIENT_CALL_TOOL: 1, @@ -91,6 +101,14 @@ const FIELD = { CLIENT_CALL_RAW_ARGS: 10, CLIENT_CALL_TOOL_INDEX: 48, CLIENT_CALL_MODEL_CALL_ID: 49, + // Aliases used by encodeClientSideToolV2Call + CV2C_TOOL: 1, + CV2C_MCP_PARAMS: 27, + CV2C_CALL_ID: 3, + CV2C_NAME: 9, + CV2C_RAW_ARGS: 10, + CV2C_TOOL_INDEX: 48, + CV2C_MODEL_CALL_ID: 49, // Model MODEL_NAME: 1, diff --git a/open-sse/utils/ollamaTransform.js b/open-sse/utils/ollamaTransform.js index 33b77b78..b4fb6a65 100644 --- a/open-sse/utils/ollamaTransform.js +++ b/open-sse/utils/ollamaTransform.js @@ -49,7 +49,7 @@ export function transformToOllama(response, model) { const formattedCalls = toolCallsArr.map(tc => ({ function: { name: tc.function.name, - arguments: JSON.parse(tc.function.arguments || "{}") + arguments: (() => { try { return JSON.parse(tc.function.arguments || "{}"); } catch { return {}; } })() } })); const ollama = JSON.stringify({ @@ -75,6 +75,9 @@ export function transformToOllama(response, model) { } }); + if (!response.body) { + return new Response("", { status: response.status, headers: { "Content-Type": "application/x-ndjson" } }); + } return new Response(response.body.pipeThrough(transform), { headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" } }); diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index d686fe11..05bb36ae 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -5,8 +5,8 @@ const isCloud = typeof caches !== "undefined" && typeof caches === "object"; const originalFetch = globalThis.fetch; const proxyDispatchers = new Map(); -// DNS cache: { [hostname]: { ip, expiry } } -const DNS_CACHE = {}; +// DNS cache — use Map to avoid prototype pollution via malformed hostnames +const DNS_CACHE = new Map(); const MITM_BYPASS_HOSTS = ["cloudcode-pa.googleapis.com", "daily-cloudcode-pa.googleapis.com", "googleapis.com"]; const MITM_BYPASS_HEADER = "x-request-source"; const MITM_BYPASS_VALUE = "local"; @@ -24,7 +24,7 @@ function normalizeString(value) { * Resolve real IP using Google DNS (bypass system DNS) */ async function resolveRealIP(hostname) { - const cached = DNS_CACHE[hostname]; + const cached = DNS_CACHE.get(hostname); if (cached && Date.now() < cached.expiry) return cached.ip; try { @@ -34,7 +34,7 @@ async function resolveRealIP(hostname) { resolver.setServers(GOOGLE_DNS_SERVERS); const resolve4 = promisify(resolver.resolve4.bind(resolver)); const addresses = await resolve4(hostname); - DNS_CACHE[hostname] = { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs }; + DNS_CACHE.set(hostname, { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs }); return addresses[0]; } catch (error) { console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message); @@ -53,23 +53,27 @@ function shouldBypassMitmDns(url, options) { headers[MITM_BYPASS_HEADER.charAt(0).toUpperCase() + MITM_BYPASS_HEADER.slice(1)] === MITM_BYPASS_VALUE; if (!hasLocalMarker) { - // Debug: log when bypass is not triggered - const hostname = new URL(url).hostname; - if (MITM_BYPASS_HOSTS.some(host => hostname.includes(host))) { - console.warn(`[ProxyFetch] MITM bypass NOT triggered for ${hostname} - missing header`); - } + try { + const hostname = new URL(url).hostname; + if (MITM_BYPASS_HOSTS.some(host => hostname.includes(host))) { + console.warn(`[ProxyFetch] MITM bypass NOT triggered for ${hostname} - missing header`); + } + } catch { /* invalid URL — skip debug log */ } return false; } - const hostname = new URL(url).hostname; - return MITM_BYPASS_HOSTS.some(host => hostname.includes(host)); + try { + const hostname = new URL(url).hostname; + return MITM_BYPASS_HOSTS.some(host => hostname.includes(host)); + } catch { return false; } } function shouldBypassByNoProxy(targetUrl, noProxyValue) { const noProxy = normalizeString(noProxyValue); if (!noProxy) return false; - const hostname = new URL(targetUrl).hostname.toLowerCase(); + let hostname; + try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { return false; } const patterns = noProxy.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean); return patterns.some((pattern) => { @@ -86,7 +90,8 @@ function getEnvProxyUrl(targetUrl) { const noProxy = process.env.NO_PROXY || process.env.no_proxy; if (shouldBypassByNoProxy(targetUrl, noProxy)) return null; - const protocol = new URL(targetUrl).protocol; + let protocol; + try { protocol = new URL(targetUrl).protocol; } catch { return null; } if (protocol === "https:") { return process.env.HTTPS_PROXY || process.env.https_proxy || @@ -152,7 +157,7 @@ async function getDispatcher(proxyUrl) { async function createBypassRequest(parsedUrl, realIP, options) { const https = await import("https"); const net = await import("net"); - const { Readable } = require("stream"); + const { Readable } = await import("stream"); return new Promise((resolve, reject) => { const socket = new net.Socket(); diff --git a/open-sse/utils/requestLogger.js b/open-sse/utils/requestLogger.js index cc04540a..010153d3 100644 --- a/open-sse/utils/requestLogger.js +++ b/open-sse/utils/requestLogger.js @@ -44,7 +44,7 @@ async function createLogSession(sourceFormat, targetFormat, model) { } const timestamp = formatTimestamp(); - const safeModel = model.replace(/[/:]/g, "-"); + const safeModel = (model || "unknown").replace(/[/:]/g, "-"); const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`; const sessionPath = path.join(LOGS_DIR, folderName); diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js index ce56291a..7c08013e 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.js @@ -52,6 +52,13 @@ export function deriveSessionId(connectionId) { return existing.sessionId; } + // Evict oldest entry if store exceeds max size (safety cap between cleanup cycles) + const MAX_SESSIONS = 1000; + if (runtimeSessionStore.size >= MAX_SESSIONS) { + const oldest = runtimeSessionStore.keys().next().value; + runtimeSessionStore.delete(oldest); + } + const sessionId = generateBinaryStyleId(); runtimeSessionStore.set(connectionId, { sessionId, lastUsed: Date.now() }); return sessionId; diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 77946bf2..138f2ee3 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -6,7 +6,7 @@ import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./str export { COLORS, formatSSE }; -const sharedDecoder = new TextDecoder(); +// sharedEncoder is stateless — safe to share across streams const sharedEncoder = new TextEncoder(); /** @@ -49,6 +49,9 @@ export function createSSEStream(options = {}) { let buffer = ""; let usage = null; + // Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks + const decoder = new TextDecoder("utf-8", { fatal: false }); + const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null; let totalContentLength = 0; @@ -61,7 +64,7 @@ export function createSSEStream(options = {}) { if (!ttftAt) { ttftAt = Date.now(); } - const text = sharedDecoder.decode(chunk, { stream: true }); + const text = decoder.decode(chunk, { stream: true }); buffer += text; reqLogger?.appendProviderChunk?.(text); @@ -253,7 +256,7 @@ export function createSSEStream(options = {}) { flush(controller) { trackPendingRequest(model, provider, connectionId, false); try { - const remaining = sharedDecoder.decode(); + const remaining = decoder.decode(); if (remaining) buffer += remaining; if (mode === STREAM_MODE.PASSTHROUGH) { diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 5e90adcf..27580641 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -107,6 +107,9 @@ export function createDisconnectAwareStream(transformStream, streamController) { controller.enqueue(value); } catch (error) { streamController.handleError(error); + // Cleanup reader/writer to avoid orphaned streams + reader.cancel().catch(() => {}); + writer.abort().catch(() => {}); controller.error(error); } }, diff --git a/package.json b/package.json index 36542bee..915f4162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.47", + "version": "0.3.48", "description": "9Router web dashboard", "private": true, "scripts": { @@ -15,6 +15,7 @@ "@monaco-editor/react": "^4.7.0", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", + "better-sqlite3": "^12.6.2", "confbox": "^0.2.4", "express": "^5.2.1", "fs": "^0.0.1-security", diff --git a/src/app/api/usage/stream/route.js b/src/app/api/usage/stream/route.js index acfad388..8fa20acd 100644 --- a/src/app/api/usage/stream/route.js +++ b/src/app/api/usage/stream/route.js @@ -39,7 +39,9 @@ export async function GET() { controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`)); } catch { state.closed = true; + statsEmitter.off("update", state.send); statsEmitter.off("pending", state.sendPending); + clearInterval(state.keepalive); } }; diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js index 6bd8135e..8f8a9045 100644 --- a/src/lib/requestDetailsDb.js +++ b/src/lib/requestDetailsDb.js @@ -246,23 +246,25 @@ export async function getRequestDetailById(id) { return db.data.records.find(r => r.id === id) || null; } -// Graceful shutdown -let shutdownHandlerRegistered = false; +// Graceful shutdown — use named handler so we can remove it on re-registration +const _shutdownHandler = async () => { + if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } + if (writeBuffer.length > 0) await flushToDatabase(); +}; function ensureShutdownHandler() { - if (shutdownHandlerRegistered || isCloud) return; + if (isCloud) return; - const handler = async () => { - if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } - if (writeBuffer.length > 0) await flushToDatabase(); - }; + // Remove any previously registered listeners from this module (hot-reload safety) + process.off("beforeExit", _shutdownHandler); + process.off("SIGINT", _shutdownHandler); + process.off("SIGTERM", _shutdownHandler); + process.off("exit", _shutdownHandler); - process.on("beforeExit", handler); - process.on("SIGINT", handler); - process.on("SIGTERM", handler); - process.on("exit", handler); - - shutdownHandlerRegistered = true; + process.on("beforeExit", _shutdownHandler); + process.on("SIGINT", _shutdownHandler); + process.on("SIGTERM", _shutdownHandler); + process.on("exit", _shutdownHandler); } ensureShutdownHandler(); diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js index 555b5fbd..2188521f 100644 --- a/src/lib/tunnel/cloudflared.js +++ b/src/lib/tunnel/cloudflared.js @@ -141,8 +141,10 @@ export async function spawnCloudflared(tunnelToken) { const handleLog = (data) => { const msg = data.toString(); - if (msg.includes("Registered tunnel connection")) { - connectionCount++; + // Count exact occurrences in this chunk (each chunk may contain multiple lines) + const matches = msg.match(/Registered tunnel connection/g); + if (matches) { + connectionCount += matches.length; if (connectionCount >= 4 && !resolved) { resolved = true; clearTimeout(timeout); @@ -165,6 +167,7 @@ export async function spawnCloudflared(tunnelToken) { child.on("exit", (code) => { cloudflaredProcess = null; clearPid(); + const wasConnected = resolved; // true = already connected successfully if (!resolved) { resolved = true; clearTimeout(timeout); @@ -173,8 +176,8 @@ export async function spawnCloudflared(tunnelToken) { return; } } - // Notify reconnect handler if tunnel died after successful connection - if (unexpectedExitHandler) { + // Only notify on unexpected exit AFTER successful connection + if (wasConnected && unexpectedExitHandler) { unexpectedExitHandler(); } }); diff --git a/src/lib/tunnel/tunnelManager.js b/src/lib/tunnel/tunnelManager.js index a3ff4c15..14026df6 100644 --- a/src/lib/tunnel/tunnelManager.js +++ b/src/lib/tunnel/tunnelManager.js @@ -8,7 +8,8 @@ const MACHINE_ID_SALT = "9router-tunnel-salt"; const API_KEY_SECRET = "9router-tunnel-api-key-secret"; const SHORT_ID_LENGTH = 6; const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789"; -const RECONNECT_DELAYS_MS = [5000, 15000, 30000]; +const RECONNECT_DELAYS_MS = [5000, 10000, 20000, 30000, 60000]; +const MAX_RECONNECT_ATTEMPTS = RECONNECT_DELAYS_MS.length; let isReconnecting = false; @@ -83,8 +84,10 @@ export async function enableTunnel() { await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname }); - // Register exit handler for auto-reconnect on unexpected crash/sleep-wake - setUnexpectedExitHandler(() => scheduleReconnect(0)); + // Re-register exit handler each time tunnel starts (handles reconnect scenario too) + setUnexpectedExitHandler(() => { + if (!isReconnecting) scheduleReconnect(0); + }); return { success: true, tunnelUrl: hostname, shortId }; } @@ -112,7 +115,7 @@ async function scheduleReconnect(attempt) { console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message); isReconnecting = false; const nextAttempt = attempt + 1; - if (nextAttempt < RECONNECT_DELAYS_MS.length) { + if (nextAttempt < MAX_RECONNECT_ATTEMPTS) { scheduleReconnect(nextAttempt); } else { console.log("[Tunnel] All reconnect attempts exhausted"); diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index e81513e3..ded59b5e 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -245,8 +245,11 @@ export async function saveRequestUsage(entry) { entry.cost = entryCost; db.data.history.push(entry); - // Optional: Limit history size if needed in future - // if (db.data.history.length > 10000) db.data.history.shift(); + // Cap history to prevent unbounded memory/disk growth + const MAX_HISTORY = 10000; + if (db.data.history.length > MAX_HISTORY) { + db.data.history.splice(0, db.data.history.length - MAX_HISTORY); + } await db.write(); statsEmitter.emit("update"); diff --git a/src/mitm/manager.js b/src/mitm/manager.js index a265f46f..89ee0bce 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -16,6 +16,14 @@ const MITM_PORT = 443; const MITM_WIN_NODE_PORT = 8443; const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); +const MITM_MAX_RESTARTS = 5; +const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000]; +const MITM_RESTART_RESET_MS = 60000; + +let mitmRestartCount = 0; +let mitmLastStartTime = 0; +let mitmIsRestarting = false; + function resolveServerPath() { if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH; const sibling = path.join(__dirname, "server.js"); @@ -273,6 +281,50 @@ async function getMitmStatus() { return { running, pid, certExists, dnsStatus }; } +async function scheduleMitmRestart(apiKey) { + if (mitmIsRestarting) return; + + const aliveMs = Date.now() - mitmLastStartTime; + if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0; + + if (mitmRestartCount >= MITM_MAX_RESTARTS) { + console.error("[MITM] Max restart attempts reached. Giving up."); + return; + } + + const attempt = mitmRestartCount; + const delay = MITM_RESTART_DELAYS_MS[Math.min(attempt, MITM_RESTART_DELAYS_MS.length - 1)]; + mitmRestartCount++; + mitmIsRestarting = true; + + console.log(`[MITM] Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`); + await new Promise((r) => setTimeout(r, delay)); + + try { + const settings = _getSettings ? await _getSettings() : null; + if (settings && !settings.mitmEnabled) { + console.log("[MITM] MITM disabled, skipping restart"); + mitmIsRestarting = false; + return; + } + const password = getCachedPassword() || await loadEncryptedPassword(); + if (!password && !IS_WIN) { + console.error("[MITM] No cached password, cannot auto-restart"); + mitmIsRestarting = false; + return; + } + await startServer(apiKey, password); + console.log("[MITM] Restarted successfully"); + mitmRestartCount = 0; + mitmIsRestarting = false; + } catch (err) { + console.error(`[MITM] Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed:`, err.message); + mitmIsRestarting = false; + // Schedule next retry + scheduleMitmRestart(apiKey); + } +} + /** * Start MITM server only (cert + server, no DNS) */ @@ -378,6 +430,7 @@ async function startServer(apiKey, sudoPassword) { if (!IS_WIN && serverProcess) { serverPid = serverProcess.pid; fs.writeFileSync(PID_FILE, String(serverPid)); + mitmLastStartTime = Date.now(); } let startError = null; @@ -397,6 +450,8 @@ async function startServer(apiKey, sudoPassword) { serverProcess = null; serverPid = null; try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } + // Auto-restart on unexpected exit + if (code !== 0 && !mitmIsRestarting) scheduleMitmRestart(apiKey); }); } @@ -425,6 +480,9 @@ async function startServer(apiKey, sudoPassword) { * Stop MITM server — removes ALL tool DNS entries first, then kills server */ async function stopServer(sudoPassword) { + // Prevent auto-restart from triggering on intentional stop + mitmIsRestarting = true; + mitmRestartCount = 0; console.log("[MITM] Stopping server..."); // Kill server process @@ -476,6 +534,7 @@ async function stopServer(sudoPassword) { try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } await saveMitmSettings(false, null); + mitmIsRestarting = false; return { running: false, pid: null }; } diff --git a/src/mitm/server.js b/src/mitm/server.js index 735cbe20..2fe27572 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -85,7 +85,7 @@ 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(__dirname, "../../logs/mitm"); +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) { diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index e7ed1916..b8b3eb9c 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -111,7 +111,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, forceSourceFormat), + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), log }); } @@ -132,12 +132,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re const userAgent = request?.headers?.get("user-agent") || ""; // Try with available accounts (fallback on errors) - let excludeConnectionId = null; + const excludeConnectionIds = new Set(); let lastError = null; let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionId, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); // All accounts unavailable if (!credentials || credentials.allRateLimited) { @@ -147,7 +147,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); } - if (!excludeConnectionId) { + if (excludeConnectionIds.size === 0) { log.error("AUTH", `No credentials for provider: ${provider}`); return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } @@ -204,7 +204,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re if (shouldFallback) { log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); - excludeConnectionId = credentials.connectionId; + excludeConnectionIds.add(credentials.connectionId); lastError = result.error; lastStatus = result.status; continue; diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index 0aa2463f..49380948 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -80,12 +80,12 @@ export async function handleEmbeddings(request) { } // Credential + fallback loop (mirrors handleChat) - let excludeConnectionId = null; + const excludeConnectionIds = new Set(); let lastError = null; let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionId, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); // All accounts unavailable if (!credentials || credentials.allRateLimited) { @@ -95,7 +95,7 @@ export async function handleEmbeddings(request) { log.warn("EMBEDDINGS", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); } - if (!excludeConnectionId) { + if (excludeConnectionIds.size === 0) { log.error("AUTH", `No credentials for provider: ${provider}`); return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } @@ -131,7 +131,7 @@ export async function handleEmbeddings(request) { if (shouldFallback) { log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); - excludeConnectionId = credentials.connectionId; + excludeConnectionIds.add(credentials.connectionId); lastError = result.error; lastStatus = result.status; continue; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index c5589a30..15e055c0 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -11,10 +11,14 @@ let selectionMutex = Promise.resolve(); * Get provider credentials from localDb * Filters out unavailable accounts and returns the selected account based on strategy * @param {string} provider - Provider name - * @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account) + * @param {Set|string|null} excludeConnectionIds - Connection ID(s) to exclude (for retry with next account) * @param {string|null} model - Model name for per-model rate limit filtering */ -export async function getProviderCredentials(provider, excludeConnectionId = null, model = null) { +export async function getProviderCredentials(provider, excludeConnectionIds = null, model = null) { + // Normalize to Set for consistent handling + const excludeSet = excludeConnectionIds instanceof Set + ? excludeConnectionIds + : (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set()); // Acquire mutex to prevent race conditions const currentMutex = selectionMutex; let resolveMutex; @@ -27,7 +31,7 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul const providerId = resolveProviderId(provider); const connections = await getProviderConnections({ provider: providerId, isActive: true }); - log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}, model: ${model || "any"}`); + log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`); if (connections.length === 0) { log.warn("AUTH", `No credentials for ${provider}`); @@ -36,14 +40,14 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul // Filter out model-locked and excluded connections const availableConnections = connections.filter(c => { - if (excludeConnectionId && c.id === excludeConnectionId) return false; + if (excludeSet.has(c.id)) return false; if (isModelLockActive(c, model)) return false; return true; }); log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`); connections.forEach(c => { - const excluded = excludeConnectionId && c.id === excludeConnectionId; + const excluded = excludeSet.has(c.id); const locked = isModelLockActive(c, model); if (excluded || locked) { const lockUntil = getEarliestModelLockUntil(c);