diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js index 91ca1cbd1c..dd2b633a18 100644 --- a/src/app/api/providers/[id]/test/route.js +++ b/src/app/api/providers/[id]/test/route.js @@ -502,89 +502,102 @@ async function testApiKeyConnection(connection) { }; } +/** + * Core test logic — reusable by test-batch without HTTP self-calls. + * @param {string} connectionId + * @returns {Promise} Test result (same shape as the JSON response) + */ +export async function testSingleConnection(connectionId) { + const connection = await getProviderConnectionById(connectionId); + + if (!connection) { + return { valid: false, error: "Connection not found", diagnosis: null, latencyMs: 0 }; + } + + let result; + const startTime = Date.now(); + const runtime = await getProviderRuntimeStatus(connection.provider); + + if (runtime?.diagnosis) { + result = { + valid: false, + error: runtime.error, + refreshed: false, + diagnosis: runtime.diagnosis, + }; + } else if (connection.authType === "apikey") { + result = await testApiKeyConnection(connection); + } else { + result = await testOAuthConnection(connection); + } + + const latencyMs = Date.now() - startTime; + + // Build update data + const now = new Date().toISOString(); + const diagnosis = + result.diagnosis || + (result.valid + ? makeDiagnosis("ok", "local", null, null) + : classifyFailure({ error: result.error, statusCode: result.statusCode })); + + const updateData = { + testStatus: result.valid ? "active" : "error", + lastError: result.valid ? null : result.error, + lastErrorAt: result.valid ? null : now, + lastTested: now, + lastErrorType: result.valid ? null : diagnosis.type, + lastErrorSource: result.valid ? null : diagnosis.source, + errorCode: result.valid ? null : diagnosis.code || result.statusCode || null, + rateLimitedUntil: result.valid ? null : connection.rateLimitedUntil || null, + }; + + if (result.valid) { + updateData.backoffLevel = 0; + } + + // If token was refreshed, update tokens in DB + if (result.refreshed && result.newTokens) { + updateData.accessToken = result.newTokens.accessToken; + if (result.newTokens.refreshToken) { + updateData.refreshToken = result.newTokens.refreshToken; + } + if (result.newTokens.expiresIn) { + updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString(); + } + } + + // Update status in db + await updateProviderConnection(connectionId, updateData); + + // Sync to cloud if token was refreshed + if (result.refreshed) { + await syncToCloudIfEnabled(); + } + + return { + valid: result.valid, + error: result.error, + refreshed: result.refreshed || false, + diagnosis, + latencyMs, + statusCode: result.statusCode || null, + runtime: runtime || null, + testedAt: now, + }; +} + // POST /api/providers/[id]/test - Test connection export async function POST(request, { params }) { try { const { id } = await params; - const connection = await getProviderConnectionById(id); + const data = await testSingleConnection(id); - if (!connection) { + if (data.error === "Connection not found") { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } - let result; - const startTime = Date.now(); - const runtime = await getProviderRuntimeStatus(connection.provider); - - if (runtime?.diagnosis) { - result = { - valid: false, - error: runtime.error, - refreshed: false, - diagnosis: runtime.diagnosis, - }; - } else if (connection.authType === "apikey") { - result = await testApiKeyConnection(connection); - } else { - result = await testOAuthConnection(connection); - } - - const latencyMs = Date.now() - startTime; - - // Build update data - const now = new Date().toISOString(); - const diagnosis = - result.diagnosis || - (result.valid - ? makeDiagnosis("ok", "local", null, null) - : classifyFailure({ error: result.error, statusCode: result.statusCode })); - - const updateData = { - testStatus: result.valid ? "active" : "error", - lastError: result.valid ? null : result.error, - lastErrorAt: result.valid ? null : now, - lastTested: now, - lastErrorType: result.valid ? null : diagnosis.type, - lastErrorSource: result.valid ? null : diagnosis.source, - errorCode: result.valid ? null : diagnosis.code || result.statusCode || null, - rateLimitedUntil: result.valid ? null : connection.rateLimitedUntil || null, - }; - - if (result.valid) { - updateData.backoffLevel = 0; - } - - // If token was refreshed, update tokens in DB - if (result.refreshed && result.newTokens) { - updateData.accessToken = result.newTokens.accessToken; - if (result.newTokens.refreshToken) { - updateData.refreshToken = result.newTokens.refreshToken; - } - if (result.newTokens.expiresIn) { - updateData.expiresAt = new Date( - Date.now() + result.newTokens.expiresIn * 1000 - ).toISOString(); - } - } - - // Update status in db - await updateProviderConnection(id, updateData); - - // Sync to cloud if token was refreshed - if (result.refreshed) { - await syncToCloudIfEnabled(); - } - - return NextResponse.json({ - valid: result.valid, - error: result.error, - refreshed: result.refreshed || false, - diagnosis, - latencyMs, - statusCode: result.statusCode || null, - runtime: runtime || null, - testedAt: now, - }); + return NextResponse.json(data); } catch (error) { console.log("Error testing connection:", error); return NextResponse.json({ error: "Test failed" }, { status: 500 }); diff --git a/src/app/api/providers/test-batch/route.js b/src/app/api/providers/test-batch/route.js index 06e0d301ca..c9d292ecaf 100644 --- a/src/app/api/providers/test-batch/route.js +++ b/src/app/api/providers/test-batch/route.js @@ -7,6 +7,7 @@ import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; +import { testSingleConnection } from "../[id]/test/route"; // Determine auth type group for a provider id function getAuthGroup(providerId) { @@ -73,43 +74,33 @@ export async function POST(request) { }); } - // Test each connection sequentially via internal API call + // Test each connection sequentially via direct function call (no HTTP self-call) const results = []; - const baseUrl = request.nextUrl.origin; for (const conn of connectionsToTest) { - const startTime = Date.now(); try { - const res = await fetch(`${baseUrl}/api/providers/${conn.id}/test`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - signal: AbortSignal.timeout(20000), - }); - const data = await res.json(); - const latencyMs = data.latencyMs || Date.now() - startTime; - + const data = await testSingleConnection(conn.id); results.push({ provider: conn.provider, connectionId: conn.id, connectionName: conn.name || conn.email || conn.provider, authType: conn.authType || getAuthGroup(conn.provider), valid: data.valid, - latencyMs, + latencyMs: data.latencyMs || 0, error: data.error || null, diagnosis: data.diagnosis || null, statusCode: data.statusCode || null, testedAt: data.testedAt || new Date().toISOString(), }); } catch (error) { - const latencyMs = Date.now() - startTime; results.push({ provider: conn.provider, connectionId: conn.id, connectionName: conn.name || conn.email || conn.provider, authType: conn.authType || getAuthGroup(conn.provider), valid: false, - latencyMs, - error: error.name === "TimeoutError" ? "Timeout (20s)" : error.message, + latencyMs: 0, + error: error.message, diagnosis: { type: "network_error", source: "local", code: null, message: error.message }, statusCode: null, testedAt: new Date().toISOString(), diff --git a/src/shared/services/cloudSyncScheduler.js b/src/shared/services/cloudSyncScheduler.js index 66b3ff87b0..83cea61bb5 100644 --- a/src/shared/services/cloudSyncScheduler.js +++ b/src/shared/services/cloudSyncScheduler.js @@ -2,7 +2,10 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { isCloudEnabled } from "@/lib/localDb"; const INTERNAL_BASE_URL = - process.env.BASE_URL || process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:20128"; + process.env.BASE_URL || + process.env.NEXT_PUBLIC_BASE_URL || + process.env.NEXT_PUBLIC_APP_URL || + "http://localhost:20128"; /** * Cloud sync scheduler