mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Feat : Auto restart after crash
This commit is contained in:
+1
-1
@@ -67,4 +67,4 @@ README1.md
|
|||||||
deploy.sh
|
deploy.sh
|
||||||
ecosystem.config.*
|
ecosystem.config.*
|
||||||
start.sh
|
start.sh
|
||||||
src/mitm/server copy.js
|
src/mitm/server2.js
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = 0;
|
let lastStatus = 0;
|
||||||
const MAX_AUTO_RETRIES = 3;
|
const MAX_AUTO_RETRIES = 3;
|
||||||
|
const MAX_RETRY_AFTER_RETRIES = 3;
|
||||||
const retryAttemptsByUrl = {}; // Track retry attempts per URL
|
const retryAttemptsByUrl = {}; // Track retry attempts per URL
|
||||||
|
const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL
|
||||||
|
|
||||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||||
const url = this.buildUrl(model, stream, urlIndex);
|
const url = this.buildUrl(model, stream, urlIndex);
|
||||||
@@ -177,10 +179,13 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
const sessionId = transformedBody.request?.sessionId;
|
const sessionId = transformedBody.request?.sessionId;
|
||||||
const headers = this.buildHeaders(credentials, stream, sessionId);
|
const headers = this.buildHeaders(credentials, stream, sessionId);
|
||||||
|
|
||||||
// Initialize retry counter for this URL
|
// Initialize retry counters for this URL
|
||||||
if (!retryAttemptsByUrl[urlIndex]) {
|
if (!retryAttemptsByUrl[urlIndex]) {
|
||||||
retryAttemptsByUrl[urlIndex] = 0;
|
retryAttemptsByUrl[urlIndex] = 0;
|
||||||
}
|
}
|
||||||
|
if (!retryAfterAttemptsByUrl[urlIndex]) {
|
||||||
|
retryAfterAttemptsByUrl[urlIndex] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await proxyAwareFetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
@@ -206,8 +211,9 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (retryMs && retryMs <= MAX_RETRY_AFTER_MS) {
|
if (retryMs && retryMs <= MAX_RETRY_AFTER_MS && retryAfterAttemptsByUrl[urlIndex] < MAX_RETRY_AFTER_RETRIES) {
|
||||||
log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting...`);
|
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));
|
await new Promise(resolve => setTimeout(resolve, retryMs));
|
||||||
urlIndex--;
|
urlIndex--;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -155,13 +155,30 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
throw new Error("http2 module not available");
|
throw new Error("http2 module not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTTP2_TIMEOUT_MS = 60000; // 60s max — prevent hung sessions
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const client = http2.connect(`https://${urlObj.host}`);
|
const client = http2.connect(`https://${urlObj.host}`);
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let responseHeaders = {};
|
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({
|
const req = client.request({
|
||||||
":method": "POST",
|
":method": "POST",
|
||||||
@@ -173,25 +190,18 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
req.on("response", (hdrs) => { responseHeaders = hdrs; });
|
req.on("response", (hdrs) => { responseHeaders = hdrs; });
|
||||||
req.on("data", (chunk) => { chunks.push(chunk); });
|
req.on("data", (chunk) => { chunks.push(chunk); });
|
||||||
req.on("end", () => {
|
req.on("end", finish(() => {
|
||||||
client.close();
|
|
||||||
resolve({
|
resolve({
|
||||||
status: responseHeaders[":status"],
|
status: responseHeaders[":status"],
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
body: Buffer.concat(chunks)
|
body: Buffer.concat(chunks)
|
||||||
});
|
});
|
||||||
});
|
}));
|
||||||
req.on("error", (err) => {
|
req.on("error", finish(reject));
|
||||||
client.close();
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signal) {
|
if (signal) {
|
||||||
signal.addEventListener("abort", () => {
|
const onAbort = finish(() => reject(new Error("Request aborted")));
|
||||||
req.close();
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
client.close();
|
|
||||||
reject(new Error("Request aborted"));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req.write(body);
|
req.write(body);
|
||||||
|
|||||||
@@ -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);
|
const convertedStream = response.body.pipeThrough(transformStream);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Pipe response body through transform stream
|
// 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);
|
const transformedStream = response.body.pipeThrough(transformStream);
|
||||||
|
|
||||||
return new Response(transformedStream, {
|
return new Response(transformedStream, {
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
||||||
|
|
||||||
let translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger);
|
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;
|
const toolNameMap = translatedBody._toolNameMap;
|
||||||
delete translatedBody._toolNameMap;
|
delete translatedBody._toolNameMap;
|
||||||
translatedBody.model = model;
|
translatedBody.model = model;
|
||||||
@@ -137,17 +141,23 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
|
|
||||||
// Handle 401/403 - try token refresh
|
// Handle 401/403 - try token refresh
|
||||||
if (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN) {
|
if (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN) {
|
||||||
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
|
try {
|
||||||
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
|
||||||
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
|
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
||||||
Object.assign(credentials, newCredentials);
|
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
|
||||||
if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials);
|
Object.assign(credentials, newCredentials);
|
||||||
try {
|
if (onCredentialsRefreshed) {
|
||||||
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
|
||||||
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
|
}
|
||||||
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
|
try {
|
||||||
} else {
|
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
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
|
// Provider forced streaming but client wants JSON
|
||||||
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
||||||
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog });
|
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog });
|
||||||
if (result) return result;
|
if (result) { streamController.handleComplete(); return result; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// True non-streaming response
|
// True non-streaming response
|
||||||
if (!stream) {
|
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
|
// Streaming response
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
|
|||||||
for (const block of responseBody.content) {
|
for (const block of responseBody.content) {
|
||||||
if (block.type === "text") {
|
if (block.type === "text") {
|
||||||
// Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```)
|
// 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;
|
textContent += text;
|
||||||
} else if (block.type === "thinking") thinkingContent += block.thinking || "";
|
} else if (block.type === "thinking") thinkingContent += block.thinking || "";
|
||||||
else if (block.type === "tool_use") {
|
else if (block.type === "tool_use") {
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
|
|||||||
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
|
ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime,
|
||||||
total: Date.now() - requestStartTime
|
total: Date.now() - requestStartTime
|
||||||
};
|
};
|
||||||
|
const safeContent = contentObj?.content || "[Empty streaming response]";
|
||||||
|
const safeThinking = contentObj?.thinking || null;
|
||||||
|
|
||||||
saveRequestDetail(buildRequestDetail({
|
saveRequestDetail(buildRequestDetail({
|
||||||
provider, model, connectionId,
|
provider, model, connectionId,
|
||||||
@@ -83,8 +85,8 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
|
|||||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
providerRequest: finalBody || translatedBody || null,
|
providerRequest: finalBody || translatedBody || null,
|
||||||
providerResponse: contentObj.content || "[Empty streaming response]",
|
providerResponse: safeContent,
|
||||||
response: { content: contentObj.content || "[Empty streaming response]", thinking: contentObj.thinking || null, type: "streaming" },
|
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
|
||||||
status: "success"
|
status: "success"
|
||||||
}, { id: streamDetailId })).catch(err => {
|
}, { id: streamDetailId })).catch(err => {
|
||||||
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ export async function handleComboChat({ body, models, handleSingleModel, log })
|
|||||||
const modelStr = models[i];
|
const modelStr = models[i];
|
||||||
log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
|
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
|
// Success or client error - return response
|
||||||
if (result.ok || result.status < 500) {
|
if (result.ok || result.status < 500) {
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -254,7 +254,9 @@ async function onboardUser(accessToken, tierID, externalSignal) {
|
|||||||
console.warn(`[ProjectId] onboardUser failed after ${MAX_ATTEMPTS} attempts: ${error.message}`);
|
console.warn(`[ProjectId] onboardUser failed after ${MAX_ATTEMPTS} attempts: ${error.message}`);
|
||||||
return null;
|
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 {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
externalSignal?.removeEventListener("abort", forwardAbort);
|
externalSignal?.removeEventListener("abort", forwardAbort);
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "github":
|
case "github": {
|
||||||
// GitHub Copilot requires special headers to mimic VSCode
|
// GitHub Copilot requires special headers to mimic VSCode
|
||||||
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
||||||
const githubToken = credentials.copilotToken || credentials.accessToken;
|
const githubToken = credentials.copilotToken || credentials.accessToken;
|
||||||
@@ -279,6 +279,7 @@ export function buildProviderHeaders(provider, credentials, stream = true, body
|
|||||||
headers["X-Initiator"] = "user";
|
headers["X-Initiator"] = "user";
|
||||||
headers["Accept"] = "application/json";
|
headers["Accept"] = "application/json";
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "codex":
|
case "codex":
|
||||||
case "qwen":
|
case "qwen":
|
||||||
|
|||||||
@@ -69,83 +69,67 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
|
|||||||
* Specialized refresh for Claude OAuth tokens
|
* Specialized refresh for Claude OAuth tokens
|
||||||
*/
|
*/
|
||||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
Accept: "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
Accept: "application/json",
|
||||||
body: JSON.stringify({
|
},
|
||||||
grant_type: "refresh_token",
|
body: JSON.stringify({
|
||||||
refresh_token: refreshToken,
|
grant_type: "refresh_token",
|
||||||
client_id: PROVIDERS.claude.clientId,
|
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
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)
|
* Specialized refresh for Google providers (Gemini, Antigravity)
|
||||||
*/
|
*/
|
||||||
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||||
headers: {
|
method: "POST",
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
headers: {
|
||||||
Accept: "application/json",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
Accept: "application/json",
|
||||||
body: new URLSearchParams({
|
},
|
||||||
grant_type: "refresh_token",
|
body: new URLSearchParams({
|
||||||
refresh_token: refreshToken,
|
grant_type: "refresh_token",
|
||||||
client_id: clientId,
|
refresh_token: refreshToken,
|
||||||
client_secret: clientSecret,
|
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
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
|
* Specialized refresh for Codex (OpenAI) OAuth tokens
|
||||||
*/
|
*/
|
||||||
export async function refreshCodexToken(refreshToken, log) {
|
export async function refreshCodexToken(refreshToken, log) {
|
||||||
|
try {
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -242,6 +227,10 @@ export async function refreshCodexToken(refreshToken, log) {
|
|||||||
refreshToken: tokens.refresh_token || refreshToken,
|
refreshToken: tokens.refresh_token || refreshToken,
|
||||||
expiresIn: tokens.expires_in,
|
expiresIn: tokens.expires_in,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+28
-29
@@ -213,30 +213,34 @@ async function getGeminiUsage(accessToken) {
|
|||||||
*/
|
*/
|
||||||
async function getAntigravityUsage(accessToken, providerSpecificData) {
|
async function getAntigravityUsage(accessToken, providerSpecificData) {
|
||||||
try {
|
try {
|
||||||
// First get project ID from subscription info
|
// Fetch subscription info once — reuse for both projectId and plan
|
||||||
const projectId = await getAntigravityProjectId(accessToken);
|
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken);
|
||||||
|
const projectId = subscriptionInfo?.cloudaicompanionProject || null;
|
||||||
|
|
||||||
// Fetch quota data with timeout
|
// Fetch quota data with timeout
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
|
|
||||||
const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
|
let response;
|
||||||
method: "POST",
|
try {
|
||||||
headers: {
|
response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
method: "POST",
|
||||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
"X-Client-Name": "antigravity",
|
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||||
"X-Client-Version": "1.107.0",
|
"Content-Type": "application/json",
|
||||||
"x-request-source": "local", // MITM bypass
|
"X-Client-Name": "antigravity",
|
||||||
},
|
"X-Client-Version": "1.107.0",
|
||||||
body: JSON.stringify({
|
"x-request-source": "local", // MITM bypass
|
||||||
...(projectId ? { project: projectId } : {})
|
},
|
||||||
}),
|
body: JSON.stringify({
|
||||||
signal: controller.signal,
|
...(projectId ? { project: projectId } : {})
|
||||||
});
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
clearTimeout(timeoutId);
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
return {
|
return {
|
||||||
@@ -302,9 +306,6 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get subscription info for plan type
|
|
||||||
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plan: subscriptionInfo?.currentTier?.name || "Unknown",
|
plan: subscriptionInfo?.currentTier?.name || "Unknown",
|
||||||
quotas,
|
quotas,
|
||||||
@@ -332,10 +333,9 @@ async function getAntigravityProjectId(accessToken) {
|
|||||||
* Get Antigravity subscription info
|
* Get Antigravity subscription info
|
||||||
*/
|
*/
|
||||||
async function getAntigravitySubscriptionInfo(accessToken) {
|
async function getAntigravitySubscriptionInfo(accessToken) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
|
||||||
|
|
||||||
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -347,15 +347,14 @@ async function getAntigravitySubscriptionInfo(accessToken) {
|
|||||||
body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }),
|
body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Antigravity Subscription] Error:", error.message);
|
console.error("[Antigravity Subscription] Error:", error.message);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const FIELD = {
|
|||||||
MSG_ID: 13,
|
MSG_ID: 13,
|
||||||
MSG_TOOL_RESULTS: 18,
|
MSG_TOOL_RESULTS: 18,
|
||||||
MSG_IS_AGENTIC: 29,
|
MSG_IS_AGENTIC: 29,
|
||||||
|
MSG_SERVER_BUBBLE_ID: 32,
|
||||||
MSG_UNIFIED_MODE: 47,
|
MSG_UNIFIED_MODE: 47,
|
||||||
MSG_SUPPORTED_TOOLS: 51,
|
MSG_SUPPORTED_TOOLS: 51,
|
||||||
|
|
||||||
@@ -78,10 +79,19 @@ const FIELD = {
|
|||||||
CLIENT_RESULT_TOOL_CALL_ID: 35,
|
CLIENT_RESULT_TOOL_CALL_ID: 35,
|
||||||
CLIENT_RESULT_MODEL_CALL_ID: 48,
|
CLIENT_RESULT_MODEL_CALL_ID: 48,
|
||||||
CLIENT_RESULT_TOOL_INDEX: 49,
|
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)
|
// MCPResult (nested inside ClientSideToolV2Result.mcp_result)
|
||||||
MCP_RESULT_SELECTED_TOOL: 1,
|
MCP_RESULT_SELECTED_TOOL: 1,
|
||||||
MCP_RESULT_RESULT: 2,
|
MCP_RESULT_RESULT: 2,
|
||||||
|
// Aliases used by encodeMcpResult
|
||||||
|
MCPR_SELECTED_TOOL: 1,
|
||||||
|
MCPR_RESULT: 2,
|
||||||
|
|
||||||
// ClientSideToolV2Call (nested inside ToolResult.tool_call)
|
// ClientSideToolV2Call (nested inside ToolResult.tool_call)
|
||||||
CLIENT_CALL_TOOL: 1,
|
CLIENT_CALL_TOOL: 1,
|
||||||
@@ -91,6 +101,14 @@ const FIELD = {
|
|||||||
CLIENT_CALL_RAW_ARGS: 10,
|
CLIENT_CALL_RAW_ARGS: 10,
|
||||||
CLIENT_CALL_TOOL_INDEX: 48,
|
CLIENT_CALL_TOOL_INDEX: 48,
|
||||||
CLIENT_CALL_MODEL_CALL_ID: 49,
|
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
|
||||||
MODEL_NAME: 1,
|
MODEL_NAME: 1,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function transformToOllama(response, model) {
|
|||||||
const formattedCalls = toolCallsArr.map(tc => ({
|
const formattedCalls = toolCallsArr.map(tc => ({
|
||||||
function: {
|
function: {
|
||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
arguments: JSON.parse(tc.function.arguments || "{}")
|
arguments: (() => { try { return JSON.parse(tc.function.arguments || "{}"); } catch { return {}; } })()
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const ollama = JSON.stringify({
|
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), {
|
return new Response(response.body.pipeThrough(transform), {
|
||||||
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" }
|
headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
|||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const proxyDispatchers = new Map();
|
const proxyDispatchers = new Map();
|
||||||
|
|
||||||
// DNS cache: { [hostname]: { ip, expiry } }
|
// DNS cache — use Map to avoid prototype pollution via malformed hostnames
|
||||||
const DNS_CACHE = {};
|
const DNS_CACHE = new Map();
|
||||||
const MITM_BYPASS_HOSTS = ["cloudcode-pa.googleapis.com", "daily-cloudcode-pa.googleapis.com", "googleapis.com"];
|
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_HEADER = "x-request-source";
|
||||||
const MITM_BYPASS_VALUE = "local";
|
const MITM_BYPASS_VALUE = "local";
|
||||||
@@ -24,7 +24,7 @@ function normalizeString(value) {
|
|||||||
* Resolve real IP using Google DNS (bypass system DNS)
|
* Resolve real IP using Google DNS (bypass system DNS)
|
||||||
*/
|
*/
|
||||||
async function resolveRealIP(hostname) {
|
async function resolveRealIP(hostname) {
|
||||||
const cached = DNS_CACHE[hostname];
|
const cached = DNS_CACHE.get(hostname);
|
||||||
if (cached && Date.now() < cached.expiry) return cached.ip;
|
if (cached && Date.now() < cached.expiry) return cached.ip;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -34,7 +34,7 @@ async function resolveRealIP(hostname) {
|
|||||||
resolver.setServers(GOOGLE_DNS_SERVERS);
|
resolver.setServers(GOOGLE_DNS_SERVERS);
|
||||||
const resolve4 = promisify(resolver.resolve4.bind(resolver));
|
const resolve4 = promisify(resolver.resolve4.bind(resolver));
|
||||||
const addresses = await resolve4(hostname);
|
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];
|
return addresses[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message);
|
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;
|
headers[MITM_BYPASS_HEADER.charAt(0).toUpperCase() + MITM_BYPASS_HEADER.slice(1)] === MITM_BYPASS_VALUE;
|
||||||
|
|
||||||
if (!hasLocalMarker) {
|
if (!hasLocalMarker) {
|
||||||
// Debug: log when bypass is not triggered
|
try {
|
||||||
const hostname = new URL(url).hostname;
|
const hostname = new URL(url).hostname;
|
||||||
if (MITM_BYPASS_HOSTS.some(host => hostname.includes(host))) {
|
if (MITM_BYPASS_HOSTS.some(host => hostname.includes(host))) {
|
||||||
console.warn(`[ProxyFetch] MITM bypass NOT triggered for ${hostname} - missing header`);
|
console.warn(`[ProxyFetch] MITM bypass NOT triggered for ${hostname} - missing header`);
|
||||||
}
|
}
|
||||||
|
} catch { /* invalid URL — skip debug log */ }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = new URL(url).hostname;
|
try {
|
||||||
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
const hostname = new URL(url).hostname;
|
||||||
|
return MITM_BYPASS_HOSTS.some(host => hostname.includes(host));
|
||||||
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldBypassByNoProxy(targetUrl, noProxyValue) {
|
function shouldBypassByNoProxy(targetUrl, noProxyValue) {
|
||||||
const noProxy = normalizeString(noProxyValue);
|
const noProxy = normalizeString(noProxyValue);
|
||||||
if (!noProxy) return false;
|
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);
|
const patterns = noProxy.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
||||||
return patterns.some((pattern) => {
|
return patterns.some((pattern) => {
|
||||||
@@ -86,7 +90,8 @@ function getEnvProxyUrl(targetUrl) {
|
|||||||
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
||||||
if (shouldBypassByNoProxy(targetUrl, noProxy)) return null;
|
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:") {
|
if (protocol === "https:") {
|
||||||
return process.env.HTTPS_PROXY || process.env.https_proxy ||
|
return process.env.HTTPS_PROXY || process.env.https_proxy ||
|
||||||
@@ -152,7 +157,7 @@ async function getDispatcher(proxyUrl) {
|
|||||||
async function createBypassRequest(parsedUrl, realIP, options) {
|
async function createBypassRequest(parsedUrl, realIP, options) {
|
||||||
const https = await import("https");
|
const https = await import("https");
|
||||||
const net = await import("net");
|
const net = await import("net");
|
||||||
const { Readable } = require("stream");
|
const { Readable } = await import("stream");
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = new net.Socket();
|
const socket = new net.Socket();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function createLogSession(sourceFormat, targetFormat, model) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = formatTimestamp();
|
const timestamp = formatTimestamp();
|
||||||
const safeModel = model.replace(/[/:]/g, "-");
|
const safeModel = (model || "unknown").replace(/[/:]/g, "-");
|
||||||
const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`;
|
const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`;
|
||||||
const sessionPath = path.join(LOGS_DIR, folderName);
|
const sessionPath = path.join(LOGS_DIR, folderName);
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ export function deriveSessionId(connectionId) {
|
|||||||
return existing.sessionId;
|
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();
|
const sessionId = generateBinaryStyleId();
|
||||||
runtimeSessionStore.set(connectionId, { sessionId, lastUsed: Date.now() });
|
runtimeSessionStore.set(connectionId, { sessionId, lastUsed: Date.now() });
|
||||||
return sessionId;
|
return sessionId;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./str
|
|||||||
|
|
||||||
export { COLORS, formatSSE };
|
export { COLORS, formatSSE };
|
||||||
|
|
||||||
const sharedDecoder = new TextDecoder();
|
// sharedEncoder is stateless — safe to share across streams
|
||||||
const sharedEncoder = new TextEncoder();
|
const sharedEncoder = new TextEncoder();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +49,9 @@ export function createSSEStream(options = {}) {
|
|||||||
let buffer = "";
|
let buffer = "";
|
||||||
let usage = null;
|
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;
|
const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null;
|
||||||
|
|
||||||
let totalContentLength = 0;
|
let totalContentLength = 0;
|
||||||
@@ -61,7 +64,7 @@ export function createSSEStream(options = {}) {
|
|||||||
if (!ttftAt) {
|
if (!ttftAt) {
|
||||||
ttftAt = Date.now();
|
ttftAt = Date.now();
|
||||||
}
|
}
|
||||||
const text = sharedDecoder.decode(chunk, { stream: true });
|
const text = decoder.decode(chunk, { stream: true });
|
||||||
buffer += text;
|
buffer += text;
|
||||||
reqLogger?.appendProviderChunk?.(text);
|
reqLogger?.appendProviderChunk?.(text);
|
||||||
|
|
||||||
@@ -253,7 +256,7 @@ export function createSSEStream(options = {}) {
|
|||||||
flush(controller) {
|
flush(controller) {
|
||||||
trackPendingRequest(model, provider, connectionId, false);
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
try {
|
try {
|
||||||
const remaining = sharedDecoder.decode();
|
const remaining = decoder.decode();
|
||||||
if (remaining) buffer += remaining;
|
if (remaining) buffer += remaining;
|
||||||
|
|
||||||
if (mode === STREAM_MODE.PASSTHROUGH) {
|
if (mode === STREAM_MODE.PASSTHROUGH) {
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export function createDisconnectAwareStream(transformStream, streamController) {
|
|||||||
controller.enqueue(value);
|
controller.enqueue(value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
streamController.handleError(error);
|
streamController.handleError(error);
|
||||||
|
// Cleanup reader/writer to avoid orphaned streams
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
writer.abort().catch(() => {});
|
||||||
controller.error(error);
|
controller.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.3.47",
|
"version": "0.3.48",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"confbox": "^0.2.4",
|
"confbox": "^0.2.4",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"fs": "^0.0.1-security",
|
"fs": "^0.0.1-security",
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export async function GET() {
|
|||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
||||||
} catch {
|
} catch {
|
||||||
state.closed = true;
|
state.closed = true;
|
||||||
|
statsEmitter.off("update", state.send);
|
||||||
statsEmitter.off("pending", state.sendPending);
|
statsEmitter.off("pending", state.sendPending);
|
||||||
|
clearInterval(state.keepalive);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+15
-13
@@ -246,23 +246,25 @@ export async function getRequestDetailById(id) {
|
|||||||
return db.data.records.find(r => r.id === id) || null;
|
return db.data.records.find(r => r.id === id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown — use named handler so we can remove it on re-registration
|
||||||
let shutdownHandlerRegistered = false;
|
const _shutdownHandler = async () => {
|
||||||
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||||
|
if (writeBuffer.length > 0) await flushToDatabase();
|
||||||
|
};
|
||||||
|
|
||||||
function ensureShutdownHandler() {
|
function ensureShutdownHandler() {
|
||||||
if (shutdownHandlerRegistered || isCloud) return;
|
if (isCloud) return;
|
||||||
|
|
||||||
const handler = async () => {
|
// Remove any previously registered listeners from this module (hot-reload safety)
|
||||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
process.off("beforeExit", _shutdownHandler);
|
||||||
if (writeBuffer.length > 0) await flushToDatabase();
|
process.off("SIGINT", _shutdownHandler);
|
||||||
};
|
process.off("SIGTERM", _shutdownHandler);
|
||||||
|
process.off("exit", _shutdownHandler);
|
||||||
|
|
||||||
process.on("beforeExit", handler);
|
process.on("beforeExit", _shutdownHandler);
|
||||||
process.on("SIGINT", handler);
|
process.on("SIGINT", _shutdownHandler);
|
||||||
process.on("SIGTERM", handler);
|
process.on("SIGTERM", _shutdownHandler);
|
||||||
process.on("exit", handler);
|
process.on("exit", _shutdownHandler);
|
||||||
|
|
||||||
shutdownHandlerRegistered = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureShutdownHandler();
|
ensureShutdownHandler();
|
||||||
|
|||||||
@@ -141,8 +141,10 @@ export async function spawnCloudflared(tunnelToken) {
|
|||||||
|
|
||||||
const handleLog = (data) => {
|
const handleLog = (data) => {
|
||||||
const msg = data.toString();
|
const msg = data.toString();
|
||||||
if (msg.includes("Registered tunnel connection")) {
|
// Count exact occurrences in this chunk (each chunk may contain multiple lines)
|
||||||
connectionCount++;
|
const matches = msg.match(/Registered tunnel connection/g);
|
||||||
|
if (matches) {
|
||||||
|
connectionCount += matches.length;
|
||||||
if (connectionCount >= 4 && !resolved) {
|
if (connectionCount >= 4 && !resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -165,6 +167,7 @@ export async function spawnCloudflared(tunnelToken) {
|
|||||||
child.on("exit", (code) => {
|
child.on("exit", (code) => {
|
||||||
cloudflaredProcess = null;
|
cloudflaredProcess = null;
|
||||||
clearPid();
|
clearPid();
|
||||||
|
const wasConnected = resolved; // true = already connected successfully
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
resolved = true;
|
resolved = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
@@ -173,8 +176,8 @@ export async function spawnCloudflared(tunnelToken) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Notify reconnect handler if tunnel died after successful connection
|
// Only notify on unexpected exit AFTER successful connection
|
||||||
if (unexpectedExitHandler) {
|
if (wasConnected && unexpectedExitHandler) {
|
||||||
unexpectedExitHandler();
|
unexpectedExitHandler();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const MACHINE_ID_SALT = "9router-tunnel-salt";
|
|||||||
const API_KEY_SECRET = "9router-tunnel-api-key-secret";
|
const API_KEY_SECRET = "9router-tunnel-api-key-secret";
|
||||||
const SHORT_ID_LENGTH = 6;
|
const SHORT_ID_LENGTH = 6;
|
||||||
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
|
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;
|
let isReconnecting = false;
|
||||||
|
|
||||||
@@ -83,8 +84,10 @@ export async function enableTunnel() {
|
|||||||
|
|
||||||
await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname });
|
await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname });
|
||||||
|
|
||||||
// Register exit handler for auto-reconnect on unexpected crash/sleep-wake
|
// Re-register exit handler each time tunnel starts (handles reconnect scenario too)
|
||||||
setUnexpectedExitHandler(() => scheduleReconnect(0));
|
setUnexpectedExitHandler(() => {
|
||||||
|
if (!isReconnecting) scheduleReconnect(0);
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true, tunnelUrl: hostname, shortId };
|
return { success: true, tunnelUrl: hostname, shortId };
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,7 @@ async function scheduleReconnect(attempt) {
|
|||||||
console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message);
|
console.log(`[Tunnel] Reconnect attempt ${attempt + 1} failed:`, err.message);
|
||||||
isReconnecting = false;
|
isReconnecting = false;
|
||||||
const nextAttempt = attempt + 1;
|
const nextAttempt = attempt + 1;
|
||||||
if (nextAttempt < RECONNECT_DELAYS_MS.length) {
|
if (nextAttempt < MAX_RECONNECT_ATTEMPTS) {
|
||||||
scheduleReconnect(nextAttempt);
|
scheduleReconnect(nextAttempt);
|
||||||
} else {
|
} else {
|
||||||
console.log("[Tunnel] All reconnect attempts exhausted");
|
console.log("[Tunnel] All reconnect attempts exhausted");
|
||||||
|
|||||||
+5
-2
@@ -245,8 +245,11 @@ export async function saveRequestUsage(entry) {
|
|||||||
entry.cost = entryCost;
|
entry.cost = entryCost;
|
||||||
db.data.history.push(entry);
|
db.data.history.push(entry);
|
||||||
|
|
||||||
// Optional: Limit history size if needed in future
|
// Cap history to prevent unbounded memory/disk growth
|
||||||
// if (db.data.history.length > 10000) db.data.history.shift();
|
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();
|
await db.write();
|
||||||
statsEmitter.emit("update");
|
statsEmitter.emit("update");
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ const MITM_PORT = 443;
|
|||||||
const MITM_WIN_NODE_PORT = 8443;
|
const MITM_WIN_NODE_PORT = 8443;
|
||||||
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
|
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() {
|
function resolveServerPath() {
|
||||||
if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH;
|
if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH;
|
||||||
const sibling = path.join(__dirname, "server.js");
|
const sibling = path.join(__dirname, "server.js");
|
||||||
@@ -273,6 +281,50 @@ async function getMitmStatus() {
|
|||||||
return { running, pid, certExists, dnsStatus };
|
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)
|
* Start MITM server only (cert + server, no DNS)
|
||||||
*/
|
*/
|
||||||
@@ -378,6 +430,7 @@ async function startServer(apiKey, sudoPassword) {
|
|||||||
if (!IS_WIN && serverProcess) {
|
if (!IS_WIN && serverProcess) {
|
||||||
serverPid = serverProcess.pid;
|
serverPid = serverProcess.pid;
|
||||||
fs.writeFileSync(PID_FILE, String(serverPid));
|
fs.writeFileSync(PID_FILE, String(serverPid));
|
||||||
|
mitmLastStartTime = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
let startError = null;
|
let startError = null;
|
||||||
@@ -397,6 +450,8 @@ async function startServer(apiKey, sudoPassword) {
|
|||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
serverPid = null;
|
serverPid = null;
|
||||||
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
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
|
* Stop MITM server — removes ALL tool DNS entries first, then kills server
|
||||||
*/
|
*/
|
||||||
async function stopServer(sudoPassword) {
|
async function stopServer(sudoPassword) {
|
||||||
|
// Prevent auto-restart from triggering on intentional stop
|
||||||
|
mitmIsRestarting = true;
|
||||||
|
mitmRestartCount = 0;
|
||||||
console.log("[MITM] Stopping server...");
|
console.log("[MITM] Stopping server...");
|
||||||
|
|
||||||
// Kill server process
|
// Kill server process
|
||||||
@@ -476,6 +534,7 @@ async function stopServer(sudoPassword) {
|
|||||||
|
|
||||||
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
|
||||||
await saveMitmSettings(false, null);
|
await saveMitmSettings(false, null);
|
||||||
|
mitmIsRestarting = false;
|
||||||
|
|
||||||
return { running: false, pid: null };
|
return { running: false, pid: null };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ const ANTIGRAVITY_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
|
|||||||
// Copilot: OpenAI-compatible + Anthropic endpoints
|
// Copilot: OpenAI-compatible + Anthropic endpoints
|
||||||
const COPILOT_URL_PATTERNS = ["/chat/completions", "/v1/messages", "/responses"];
|
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 });
|
if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
|
||||||
function saveRequestLog(url, bodyBuffer) {
|
function saveRequestLog(url, bodyBuffer) {
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
return handleComboChat({
|
return handleComboChat({
|
||||||
body,
|
body,
|
||||||
models: comboModels,
|
models: comboModels,
|
||||||
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, forceSourceFormat),
|
handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey),
|
||||||
log
|
log
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,12 +132,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
const userAgent = request?.headers?.get("user-agent") || "";
|
const userAgent = request?.headers?.get("user-agent") || "";
|
||||||
|
|
||||||
// Try with available accounts (fallback on errors)
|
// Try with available accounts (fallback on errors)
|
||||||
let excludeConnectionId = null;
|
const excludeConnectionIds = new Set();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionId, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
||||||
|
|
||||||
// All accounts unavailable
|
// All accounts unavailable
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
@@ -147,7 +147,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|||||||
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||||
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, 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}`);
|
log.error("AUTH", `No credentials for provider: ${provider}`);
|
||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `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) {
|
if (shouldFallback) {
|
||||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||||
excludeConnectionId = credentials.connectionId;
|
excludeConnectionIds.add(credentials.connectionId);
|
||||||
lastError = result.error;
|
lastError = result.error;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -80,12 +80,12 @@ export async function handleEmbeddings(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Credential + fallback loop (mirrors handleChat)
|
// Credential + fallback loop (mirrors handleChat)
|
||||||
let excludeConnectionId = null;
|
const excludeConnectionIds = new Set();
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
let lastStatus = null;
|
let lastStatus = null;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const credentials = await getProviderCredentials(provider, excludeConnectionId, model);
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model);
|
||||||
|
|
||||||
// All accounts unavailable
|
// All accounts unavailable
|
||||||
if (!credentials || credentials.allRateLimited) {
|
if (!credentials || credentials.allRateLimited) {
|
||||||
@@ -95,7 +95,7 @@ export async function handleEmbeddings(request) {
|
|||||||
log.warn("EMBEDDINGS", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
log.warn("EMBEDDINGS", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||||
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, 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}`);
|
log.error("AUTH", `No credentials for provider: ${provider}`);
|
||||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `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) {
|
if (shouldFallback) {
|
||||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||||
excludeConnectionId = credentials.connectionId;
|
excludeConnectionIds.add(credentials.connectionId);
|
||||||
lastError = result.error;
|
lastError = result.error;
|
||||||
lastStatus = result.status;
|
lastStatus = result.status;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ let selectionMutex = Promise.resolve();
|
|||||||
* Get provider credentials from localDb
|
* Get provider credentials from localDb
|
||||||
* Filters out unavailable accounts and returns the selected account based on strategy
|
* Filters out unavailable accounts and returns the selected account based on strategy
|
||||||
* @param {string} provider - Provider name
|
* @param {string} provider - Provider name
|
||||||
* @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account)
|
* @param {Set<string>|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
|
* @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
|
// Acquire mutex to prevent race conditions
|
||||||
const currentMutex = selectionMutex;
|
const currentMutex = selectionMutex;
|
||||||
let resolveMutex;
|
let resolveMutex;
|
||||||
@@ -27,7 +31,7 @@ export async function getProviderCredentials(provider, excludeConnectionId = nul
|
|||||||
const providerId = resolveProviderId(provider);
|
const providerId = resolveProviderId(provider);
|
||||||
|
|
||||||
const connections = await getProviderConnections({ provider: providerId, isActive: true });
|
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) {
|
if (connections.length === 0) {
|
||||||
log.warn("AUTH", `No credentials for ${provider}`);
|
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
|
// Filter out model-locked and excluded connections
|
||||||
const availableConnections = connections.filter(c => {
|
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;
|
if (isModelLockActive(c, model)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`);
|
log.debug("AUTH", `${provider} | available: ${availableConnections.length}/${connections.length}`);
|
||||||
connections.forEach(c => {
|
connections.forEach(c => {
|
||||||
const excluded = excludeConnectionId && c.id === excludeConnectionId;
|
const excluded = excludeSet.has(c.id);
|
||||||
const locked = isModelLockActive(c, model);
|
const locked = isModelLockActive(c, model);
|
||||||
if (excluded || locked) {
|
if (excluded || locked) {
|
||||||
const lockUntil = getEarliestModelLockUntil(c);
|
const lockUntil = getEarliestModelLockUntil(c);
|
||||||
|
|||||||
Reference in New Issue
Block a user