diff --git a/open-sse/translator/helpers/openaiHelper.js b/open-sse/translator/helpers/openaiHelper.js index 90879b93..1e18d263 100644 --- a/open-sse/translator/helpers/openaiHelper.js +++ b/open-sse/translator/helpers/openaiHelper.js @@ -4,6 +4,15 @@ export const VALID_OPENAI_CONTENT_TYPES = ["text", "image_url", "image"]; export const VALID_OPENAI_MESSAGE_TYPES = ["text", "image_url", "image", "tool_calls", "tool_result"]; +function normalizeOpenAIContent(content) { + const textOnly = content.every((block) => block.type === "text"); + if (textOnly) { + return content.map((block) => block.text || "").join("\n"); + } + + return content; +} + // Filter messages to OpenAI standard format // Remove: thinking, redacted_thinking, signature, and other non-OpenAI blocks export function filterToOpenAIFormat(body) { @@ -47,7 +56,7 @@ export function filterToOpenAIFormat(body) { filteredContent.push({ type: "text", text: "" }); } - return { ...msg, content: filteredContent }; + return { ...msg, content: normalizeOpenAIContent(filteredContent) }; } return msg; diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index d9545933..1ce128bf 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -73,6 +73,17 @@ export function claudeToOpenAIRequest(model, body, stream) { return result; } +function normalizeOpenAIContent(parts) { + if (parts.length === 0) return ""; + + const textOnly = parts.every((part) => part.type === "text"); + if (textOnly) { + return parts.map((part) => part.text || "").join("\n"); + } + + return parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts; +} + // Fix missing tool responses - add empty responses for tool_calls without responses function fixMissingToolResponses(messages) { for (let i = 0; i < messages.length; i++) { @@ -177,9 +188,7 @@ function convertClaudeMessage(msg) { // If has tool results, return array of tool messages if (toolResults.length > 0) { if (parts.length > 0) { - const textContent = parts.length === 1 && parts[0].type === "text" - ? parts[0].text - : parts; + const textContent = normalizeOpenAIContent(parts); return [...toolResults, { role: "user", content: textContent }]; } return toolResults; @@ -189,9 +198,7 @@ function convertClaudeMessage(msg) { if (toolCalls.length > 0) { const result = { role: "assistant" }; if (parts.length > 0) { - result.content = parts.length === 1 && parts[0].type === "text" - ? parts[0].text - : parts; + result.content = normalizeOpenAIContent(parts); } result.tool_calls = toolCalls; return result; @@ -201,7 +208,7 @@ function convertClaudeMessage(msg) { if (parts.length > 0) { return { role, - content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts + content: normalizeOpenAIContent(parts) }; } diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.js index a7a19180..9bac2824 100644 --- a/open-sse/utils/streamHelpers.js +++ b/open-sse/utils/streamHelpers.js @@ -4,9 +4,18 @@ import { FORMATS } from "../translator/formats.js"; export function parseSSELine(line, format = null) { if (!line) return null; + const trimmed = line.trim(); + + if (trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed); + } catch (error) { + return null; + } + } + // NDJSON format (Ollama): raw JSON lines without "data:" prefix if (format === FORMATS.OLLAMA) { - const trimmed = line.trim(); if (trimmed.startsWith("{")) { try { return JSON.parse(trimmed); diff --git a/src/app/api/oauth/cursor/auto-import/route.js b/src/app/api/oauth/cursor/auto-import/route.js index 91f9fb65..b36d6022 100644 --- a/src/app/api/oauth/cursor/auto-import/route.js +++ b/src/app/api/oauth/cursor/auto-import/route.js @@ -39,6 +39,14 @@ function getCandidatePaths(platform) { ]; } +function getPlatformError(platform, candidates) { + if (platform === "darwin") { + return `Cursor database not found in known macOS locations:\n${candidates.join("\n")}\n\nMake sure Cursor IDE is installed and opened at least once.`; + } + + return "Cursor database not found. Make sure Cursor IDE is installed and you are logged in."; +} + /** Extract tokens using better-sqlite3 (stream-based, no RAM limit) */ function extractTokens(db) { const desiredKeys = [...ACCESS_TOKEN_KEYS, ...MACHINE_ID_KEYS]; @@ -134,23 +142,31 @@ async function extractTokensViaCLI(dbPath) { export async function GET() { try { const platform = process.platform; + if (!["darwin", "linux", "win32"].includes(platform)) { + return NextResponse.json({ found: false, error: "Unsupported platform" }, { status: 400 }); + } + const candidates = getCandidatePaths(platform); let dbPath = null; - for (const candidate of candidates) { - try { - await access(candidate, constants.R_OK); - dbPath = candidate; - break; - } catch { - // Try next candidate + if (platform === "darwin" || platform === "win32") { + for (const candidate of candidates) { + try { + await access(candidate, constants.R_OK); + dbPath = candidate; + break; + } catch { + // Try next candidate + } } + } else { + [dbPath] = candidates; } if (!dbPath) { return NextResponse.json({ found: false, - error: `Cursor database not found. Checked locations:\n${candidates.join("\n")}\n\nMake sure Cursor IDE is installed and opened at least once.`, + error: getPlatformError(platform, candidates), }); } @@ -178,8 +194,15 @@ export async function GET() { if (tokens.accessToken && tokens.machineId) { return NextResponse.json({ found: true, accessToken: tokens.accessToken, machineId: tokens.machineId }); } - } catch { + } catch (error) { db?.close(); + + if (platform === "darwin") { + return NextResponse.json({ + found: false, + error: `Cursor database found at ${dbPath}, but could not open it: ${error.message}`, + }); + } } } @@ -192,7 +215,21 @@ export async function GET() { } catch { /* sqlite3 CLI not available */ } // Strategy 3: ask user to paste manually - return NextResponse.json({ found: false, windowsManual: true, dbPath }); + if (platform === "win32") { + return NextResponse.json({ found: false, windowsManual: true, dbPath }); + } + + if (platform === "linux") { + return NextResponse.json({ + found: false, + error: getPlatformError(platform, candidates), + }); + } + + return NextResponse.json({ + found: false, + error: "Please login to Cursor IDE first and then reopen Cursor before retrying auto-import.", + }); } catch (error) { console.log("Cursor auto-import error:", error); return NextResponse.json({ found: false, error: error.message }, { status: 500 }); diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index b8b3eb9c..f0f2cade 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -7,7 +7,6 @@ import { extractApiKey, isValidApiKey, } from "../services/auth.js"; -import { getSettings } from "@/lib/localDb"; import { getModelInfo, getComboModels } from "../services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; @@ -17,6 +16,7 @@ import { detectFormatByEndpoint } from "open-sse/translator/formats.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { getProjectIdForConnection } from "open-sse/services/projectId.js"; +import { createRequestContext, getRequestSettings } from "../services/requestContext.js"; /** * Handle chat completion request @@ -24,6 +24,7 @@ import { getProjectIdForConnection } from "open-sse/services/projectId.js"; * Format detection and translation handled by translator */ export async function handleChat(request, clientRawRequest = null) { + const requestContext = createRequestContext(); let body; try { body = await request.json(); @@ -63,7 +64,7 @@ export async function handleChat(request, clientRawRequest = null) { } // Enforce API key if enabled in settings - const settings = await getSettings(); + const settings = await getRequestSettings(requestContext); if (settings.requireApiKey) { if (!apiKey) { log.warn("AUTH", "Missing API key (requireApiKey=true)"); @@ -82,36 +83,36 @@ export async function handleChat(request, clientRawRequest = null) { } // Check if model is a combo (has multiple models with fallback) - const comboModels = await getComboModels(modelStr); + const comboModels = await getComboModels(modelStr, requestContext); if (comboModels) { log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models`); return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, requestContext), log }); } // Single model request - return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey); + return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey, requestContext); } /** * Handle single model chat request */ -async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) { - const modelInfo = await getModelInfo(modelStr); +async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, requestContext = null) { + const modelInfo = await getModelInfo(modelStr, requestContext); // If provider is null, this might be a combo name - check and handle if (!modelInfo.provider) { - const comboModels = await getComboModels(modelStr); + const comboModels = await getComboModels(modelStr, requestContext); if (comboModels) { log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models`); return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, requestContext), log }); } @@ -137,7 +138,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + const credentials = await getProviderCredentials(provider, excludeConnectionId, model, requestContext); // All accounts unavailable if (!credentials || credentials.allRateLimited) { @@ -171,7 +172,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re } // Use shared chatCore - const chatSettings = await getSettings(); + const chatSettings = await getRequestSettings(requestContext); const result = await handleChatCore({ body: { ...body, model: `${provider}/${model}` }, modelInfo: { provider, model }, diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 15e055c0..73102cb9 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -3,6 +3,7 @@ import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js"; import { resolveProviderId } from "@/shared/constants/providers.js"; import * as log from "../utils/logger.js"; +import { getRequestSettings } from "./requestContext.js"; // Mutex to prevent race conditions during account selection let selectionMutex = Promise.resolve(); @@ -14,11 +15,7 @@ let selectionMutex = Promise.resolve(); * @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, excludeConnectionIds = null, model = null) { - // Normalize to Set for consistent handling - const excludeSet = excludeConnectionIds instanceof Set - ? excludeConnectionIds - : (excludeConnectionIds ? new Set([excludeConnectionIds]) : new Set()); +export async function getProviderCredentials(provider, excludeConnectionId = null, model = null, requestContext = null) { // Acquire mutex to prevent race conditions const currentMutex = selectionMutex; let resolveMutex; @@ -75,7 +72,9 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu return null; } - const settings = await getSettings(); + const settings = requestContext + ? await getRequestSettings(requestContext) + : await getSettings(); // Per-provider strategy overrides global setting const providerOverride = (settings.providerStrategies || {})[providerId] || {}; const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first"; diff --git a/src/sse/services/model.js b/src/sse/services/model.js index 838966bb..0c7fbb1c 100644 --- a/src/sse/services/model.js +++ b/src/sse/services/model.js @@ -1,34 +1,41 @@ // Re-export from open-sse with localDb integration import { getModelAliases, getComboByName, getProviderNodes } from "@/lib/localDb"; import { parseModel, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js"; +import { getRequestComboByName, getRequestModelAliases, getRequestProviderNodes } from "./requestContext.js"; export { parseModel }; /** * Resolve model alias from localDb */ -export async function resolveModelAlias(alias) { - const aliases = await getModelAliases(); +export async function resolveModelAlias(alias, requestContext = null) { + const aliases = requestContext + ? await getRequestModelAliases(requestContext) + : await getModelAliases(); return resolveModelAliasFromMap(alias, aliases); } /** * Get full model info (parse or resolve) */ -export async function getModelInfo(modelStr) { +export async function getModelInfo(modelStr, requestContext = null) { const parsed = parseModel(modelStr); if (!parsed.isAlias) { if (parsed.provider === parsed.providerAlias) { // Check OpenAI Compatible nodes - const openaiNodes = await getProviderNodes({ type: "openai-compatible" }); + const openaiNodes = requestContext + ? await getRequestProviderNodes("openai-compatible", requestContext) + : await getProviderNodes({ type: "openai-compatible" }); const matchedOpenAI = openaiNodes.find((node) => node.prefix === parsed.providerAlias); if (matchedOpenAI) { return { provider: matchedOpenAI.id, model: parsed.model }; } // Check Anthropic Compatible nodes - const anthropicNodes = await getProviderNodes({ type: "anthropic-compatible" }); + const anthropicNodes = requestContext + ? await getRequestProviderNodes("anthropic-compatible", requestContext) + : await getProviderNodes({ type: "anthropic-compatible" }); const matchedAnthropic = anthropicNodes.find((node) => node.prefix === parsed.providerAlias); if (matchedAnthropic) { return { provider: matchedAnthropic.id, model: parsed.model }; @@ -42,25 +49,32 @@ export async function getModelInfo(modelStr) { // Check if this is a combo name before resolving as alias // This prevents combo names from being incorrectly routed to providers - const combo = await getComboByName(parsed.model); + const combo = requestContext + ? await getRequestComboByName(parsed.model, requestContext) + : await getComboByName(parsed.model); if (combo) { // Return null provider to signal this should be handled as combo // The caller (handleChat) will detect this and handle it as combo return { provider: null, model: parsed.model }; } - return getModelInfoCore(modelStr, getModelAliases); + return getModelInfoCore( + modelStr, + requestContext ? () => getRequestModelAliases(requestContext) : getModelAliases, + ); } /** * Check if model is a combo and get models list * @returns {Promise} Array of models or null if not a combo */ -export async function getComboModels(modelStr) { +export async function getComboModels(modelStr, requestContext = null) { // Only check if it's not in provider/model format if (modelStr.includes("/")) return null; - const combo = await getComboByName(modelStr); + const combo = requestContext + ? await getRequestComboByName(modelStr, requestContext) + : await getComboByName(modelStr); if (combo && combo.models && combo.models.length > 0) { return combo.models; } diff --git a/src/sse/services/requestContext.js b/src/sse/services/requestContext.js new file mode 100644 index 00000000..53e3af68 --- /dev/null +++ b/src/sse/services/requestContext.js @@ -0,0 +1,47 @@ +import { getSettings, getProviderNodes, getComboByName, getModelAliases } from "../../lib/localDb.js"; + +function getCachedValue(requestContext, key, loader) { + if (!requestContext) { + return loader(); + } + + if (!requestContext[key]) { + requestContext[key] = loader(); + } + + return requestContext[key]; +} + +export function createRequestContext() { + return {}; +} + +export async function getRequestSettings(requestContext) { + return getCachedValue(requestContext, "settingsPromise", () => getSettings()); +} + +export async function getRequestProviderNodes(type, requestContext) { + const nodes = await getCachedValue(requestContext, "providerNodesPromise", () => getProviderNodes()); + if (!type) return nodes; + return nodes.filter((node) => node.type === type); +} + +export async function getRequestComboByName(name, requestContext) { + const combosByName = await getCachedValue(requestContext, "combosByNamePromise", async () => new Map()); + + if (combosByName.has(name)) { + return combosByName.get(name); + } + + const comboPromise = getComboByName(name).then((combo) => { + combosByName.set(name, combo || null); + return combo || null; + }); + + combosByName.set(name, comboPromise); + return comboPromise; +} + +export async function getRequestModelAliases(requestContext) { + return getCachedValue(requestContext, "modelAliasesPromise", () => getModelAliases()); +} diff --git a/tests/unit/request-context.test.js b/tests/unit/request-context.test.js new file mode 100644 index 00000000..2c30dc96 --- /dev/null +++ b/tests/unit/request-context.test.js @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const localDbMocks = vi.hoisted(() => ({ + getSettings: vi.fn(), + getProviderNodes: vi.fn(), + getComboByName: vi.fn(), + getModelAliases: vi.fn(), +})); + +vi.mock("../../src/lib/localDb.js", () => localDbMocks); + +import { + createRequestContext, + getRequestComboByName, + getRequestModelAliases, + getRequestProviderNodes, + getRequestSettings, +} from "../../src/sse/services/requestContext.js"; + +describe("requestContext caching", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reuses settings within the same request context", async () => { + const context = createRequestContext(); + localDbMocks.getSettings.mockResolvedValue({ requireApiKey: true }); + + const first = await getRequestSettings(context); + const second = await getRequestSettings(context); + + expect(first).toEqual({ requireApiKey: true }); + expect(second).toBe(first); + expect(localDbMocks.getSettings).toHaveBeenCalledTimes(1); + }); + + it("reuses provider nodes and filters them in memory", async () => { + const context = createRequestContext(); + localDbMocks.getProviderNodes.mockResolvedValue([ + { id: "oa-1", type: "openai-compatible", prefix: "oa" }, + { id: "an-1", type: "anthropic-compatible", prefix: "an" }, + ]); + + const openaiNodes = await getRequestProviderNodes("openai-compatible", context); + const anthropicNodes = await getRequestProviderNodes("anthropic-compatible", context); + const allNodes = await getRequestProviderNodes(null, context); + + expect(openaiNodes).toEqual([{ id: "oa-1", type: "openai-compatible", prefix: "oa" }]); + expect(anthropicNodes).toEqual([{ id: "an-1", type: "anthropic-compatible", prefix: "an" }]); + expect(allNodes).toHaveLength(2); + expect(localDbMocks.getProviderNodes).toHaveBeenCalledTimes(1); + }); + + it("memoizes combo lookups by name within a request", async () => { + const context = createRequestContext(); + const combo = { name: "fast", models: ["a", "b"] }; + localDbMocks.getComboByName.mockResolvedValue(combo); + + const first = await getRequestComboByName("fast", context); + const second = await getRequestComboByName("fast", context); + + expect(first).toBe(combo); + expect(second).toBe(combo); + expect(localDbMocks.getComboByName).toHaveBeenCalledTimes(1); + }); + + it("reuses model aliases within the same request context", async () => { + const context = createRequestContext(); + const aliases = { sonnet: "claude/sonnet-4" }; + localDbMocks.getModelAliases.mockResolvedValue(aliases); + + const first = await getRequestModelAliases(context); + const second = await getRequestModelAliases(context); + + expect(first).toBe(aliases); + expect(second).toBe(aliases); + expect(localDbMocks.getModelAliases).toHaveBeenCalledTimes(1); + }); +});