diff --git a/next.config.mjs b/next.config.mjs index 58fabbcbf3..996226e170 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -9,11 +9,17 @@ const tracingRoot = process.env.NEXT_TRACING_ROOT_MODE === "workspace" ? join(projectRoot, "..") : projectRoot; const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE || "128mb"; +// Comma-separated dev origins override (Next 16 dev-only field, ignored in prod). +// VANS_ALLOWED_DEV_ORIGINS=127.0.0.1,localhost,my-host +const allowedDevOrigins = process.env.VANS_ALLOWED_DEV_ORIGINS + ? process.env.VANS_ALLOWED_DEV_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean) + : ["127.0.0.1", "localhost"]; /** @type {import('next').NextConfig} */ const nextConfig = { distDir: process.env.NEXT_DIST_DIR || ".next", output: "standalone", + allowedDevOrigins, serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite", "dompurify", "chalk"], turbopack: { root: tracingRoot @@ -36,7 +42,17 @@ const nextConfig = { // Cache fetch responses across HMR refreshes for faster dev reloads. serverComponentsHmrCache: true, // Tree-shake heavy barrel imports to cut compile + bundle size - optimizePackageImports: ["@xyflow/react", "@dnd-kit/core", "@dnd-kit/sortable", "material-symbols", "marked"], + optimizePackageImports: [ + "@xyflow/react", + "@dnd-kit/core", + "@dnd-kit/sortable", + "@monaco-editor/react", + "recharts", + "material-symbols", + "marked", + "@/shared/components", + "@/shared/components/layouts", + ], }, webpack: (config, { isServer }) => { // Ignore fs/path modules in browser bundle @@ -106,22 +122,30 @@ const nextConfig = { ]; }, async headers() { + // In dev, Turbopack rewrites chunk factories frequently. An immutable + // Cache-Control on /_next/static makes the browser reuse stale modules + // (module factory not available → unhandledRejection + UI freezes). + // Production chunks are content-hashed, so immutable is correct there. + const isDev = process.env.NODE_ENV !== "production"; return [ { // Provider icons (webp), favicons, logos — immutable, hash-stable files. - // Browser caches for 1 year; revalidation via Last-Modified/ETag. source: "/providers/:path*", headers: [ { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, ], }, - { - // Next.js hashed static assets (JS/CSS chunks) — content-addressed, safe to cache forever. - source: "/_next/static/:path*", - headers: [ - { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, - ], - }, + ...(isDev + ? [] + : [ + { + // Next.js hashed static assets (JS/CSS chunks) — content-addressed. + source: "/_next/static/:path*", + headers: [ + { key: "Cache-Control", value: "public, max-age=31536000, immutable" }, + ], + }, + ]), ]; } }; diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 176481e9d0..290a1d354f 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -130,7 +130,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); - const transformedBody = this.transformRequest(model, body, stream, credentials); + const transformedBody = await Promise.resolve(this.transformRequest(model, body, stream, credentials)); const headers = this.buildHeaders(credentials, stream); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/gemini-cli.js b/open-sse/executors/gemini-cli.js index aa15c70fa3..e61c017a33 100644 --- a/open-sse/executors/gemini-cli.js +++ b/open-sse/executors/gemini-cli.js @@ -1,99 +1,226 @@ -import { BaseExecutor } from "./base.js"; -import { PROVIDERS } from "../config/providers.js"; -import { OAUTH_ENDPOINTS, GEMINI_CLI_API_CLIENT, geminiCLIUserAgent } from "../config/appConstants.js"; - -export class GeminiCLIExecutor extends BaseExecutor { - constructor() { - super("gemini-cli", PROVIDERS["gemini-cli"]); - } - - buildUrl(model, stream, urlIndex = 0) { - const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; - return `${this.config.baseUrl}:${action}`; - } - - buildHeaders(credentials, stream = true) { - return { - "Content-Type": "application/json", - "Authorization": `Bearer ${credentials.accessToken}`, - "User-Agent": geminiCLIUserAgent(this._currentModel), - "X-Goog-Api-Client": GEMINI_CLI_API_CLIENT, - "Accept": stream ? "text/event-stream" : "application/json" - }; - } - - transformRequest(model, body, stream, credentials) { - // Store model for use in buildHeaders (called by base.execute after transformRequest) - this._currentModel = model; - // Cloud Code Assist wraps the Gemini payload: { project, model, request:
} - if (body && body.request && body.model) return body; - - // Strip reasoning_effort and other non-standard fields that Google rejects - if (body) { - delete body.reasoning_effort; - delete body.thinking; - delete body.reasoning; - delete body.output_config; - delete body.thinkingConfig; - delete body.enable_thinking; - delete body.thinking_budget; - } - return { - project: credentials?.projectId || body?.project, - model, - request: body - }; - } - - // Parse RetryInfo.retryDelay from Google API 429 body to surface upstream retry hint - parseError(response, bodyText) { - const base = super.parseError(response, bodyText); - if (response.status !== 429 || !bodyText) return base; - try { - const parsed = JSON.parse(bodyText); - const details = parsed?.error?.details; - if (Array.isArray(details)) { - for (const d of details) { - if (d?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo" && d?.retryDelay) { - base.retryAfter = d.retryDelay; - break; - } - } - } - } catch {} - return base; - } - - async refreshCredentials(credentials, log) { - if (!credentials.refreshToken) return null; - - try { - const response = await fetch(OAUTH_ENDPOINTS.google.token, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: credentials.refreshToken, - client_id: this.config.clientId, - client_secret: this.config.clientSecret - }) - }); - - if (!response.ok) return null; - - const tokens = await response.json(); - log?.info?.("TOKEN", "Gemini CLI refreshed"); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || credentials.refreshToken, - expiresIn: tokens.expires_in, - projectId: credentials.projectId - }; - } catch (error) { - log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`); - return null; - } - } -} - +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { OAUTH_ENDPOINTS, GEMINI_CLI_API_CLIENT, geminiCLIUserAgent } from "../config/appConstants.js"; +import { DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../config/defaultThinkingSignature.js"; +import { getConsistentMachineId } from "../shared/machineId.js"; + +// Opt-in privileged-user-id header. Some Cloud Code Assist accounts (corporate SSO, +// verified org) require the Gemini CLI installation id to be echoed back in +// `x-gemini-api-privileged-user-id` to unlock full quota. Default off because for +// consumer accounts the header can trigger stricter abuse-scoring. +const PRIVILEGED_USER_ID_ENABLED = + ["1", "true", "yes", "on"].includes(String(process.env.VANS_GEMINI_INCLUDE_PRIVILEGED_USER_ID || "").toLowerCase()); + +let _privilegedUserIdCache = null; +let _privilegedUserIdPromise = null; +async function resolvePrivilegedUserId() { + if (!PRIVILEGED_USER_ID_ENABLED) return null; + if (_privilegedUserIdCache) return _privilegedUserIdCache; + if (_privilegedUserIdPromise) return _privilegedUserIdPromise; + _privilegedUserIdPromise = (async () => { + try { + const id = await getConsistentMachineId("gemini-cli-privileged-user-id"); + _privilegedUserIdCache = id || ""; + return _privilegedUserIdCache; + } catch { + _privilegedUserIdCache = ""; + return ""; + } finally { + _privilegedUserIdPromise = null; + } + })(); + return _privilegedUserIdPromise; +} + +if (PRIVILEGED_USER_ID_ENABLED) resolvePrivilegedUserId().catch(() => {}); + +// OpenAI / client fields that must never reach Cloud Code Assist (400 Unknown name). +const OPENAI_LEAK_FIELDS = [ + "reasoning_effort", "thinking", "reasoning", "output_config", + "thinkingConfig", "enable_thinking", "thinking_budget", + "messages", "max_tokens", "max_completion_tokens", "stream", + "tool_choice", "response_format", "n", "stop", "user", +]; + +function stripOpenAILeakFields(obj) { + if (!obj || typeof obj !== "object") return; + for (const k of OPENAI_LEAK_FIELDS) delete obj[k]; +} +// Translate include_reasoning (visible reasoning default) into Gemini's native +// includeThoughts flag. Lives in generationConfig.thinkingConfig so it traverses +// the Cloud Code Assist envelope correctly. When thinkingConfig already has +// includeThoughts (set by applyThinking from explicit reasoning), leave it. +// include_reasoning=false explicitly suppresses thoughts (visible reasoning OFF). +function applyIncludeReasoningToGemini(body) { + if (!body || body.include_reasoning == null) return; + const gc = getGeminiGenerationConfigForBody(body); + if (!gc) return; + let tc = gc.thinkingConfig; + if (!tc || typeof tc !== "object") tc = {}; + // Dashboard visible-reasoning always wins for includeThoughts. applyThinking may + // have already set a default; we must overwrite so intermittent "no thoughts" + // leaks (when applyThinking set includeThoughts first) are fixed. + if (body.include_reasoning === true) { + tc.includeThoughts = true; + // Ensure a thinking budget/level exists so Gemini actually thinks. + if (tc.thinkingLevel == null && tc.thinkingBudget == null) { + tc.thinkingBudget = -1; + } + } else if (body.include_reasoning === false) { + tc.includeThoughts = false; + } + gc.thinkingConfig = tc; +} + +function getGeminiGenerationConfigForBody(body) { + if (body.request && typeof body.request === "object") { + if (!body.request.generationConfig || typeof body.request.generationConfig !== "object") { + body.request.generationConfig = {}; + } + return body.request.generationConfig; + } + if (!body.generationConfig || typeof body.generationConfig !== "object") { + body.generationConfig = {}; + } + return body.generationConfig; +} + +export class GeminiCLIExecutor extends BaseExecutor { + constructor() { + super("gemini-cli", PROVIDERS["gemini-cli"]); + } + + buildUrl(model, stream, urlIndex = 0) { + const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; + return `${this.config.baseUrl}:${action}`; + } + + buildHeaders(credentials, stream = true) { + const headers = { + "Content-Type": "application/json", + "Authorization": `Bearer ${credentials.accessToken}`, + "User-Agent": geminiCLIUserAgent(this._currentModel), + "X-Goog-Api-Client": GEMINI_CLI_API_CLIENT, + "Accept": stream ? "text/event-stream" : "application/json" + }; + // Inject privileged user id when the cached one is available. The cache is + // populated asynchronously in transformRequest; if the first request happens + // before it's resolved the header is skipped (no waiting in a sync handler). + if (PRIVILEGED_USER_ID_ENABLED && typeof _privilegedUserIdCache === "string" && _privilegedUserIdCache) { + headers["x-gemini-api-privileged-user-id"] = _privilegedUserIdCache; + } + return headers; + } + + async transformRequest(model, body, stream, credentials) { + this._currentModel = model; + if (PRIVILEGED_USER_ID_ENABLED) { + const v = await resolvePrivilegedUserId(); + _privilegedUserIdCache = v ?? ""; + } + const work = structuredClone(body); + const isEnvelope = work && work.request && work.model; + if (isEnvelope) { + const request = work.request; + if (Array.isArray(request.contents)) { + for (const turn of request.contents) { + if (!Array.isArray(turn.parts)) continue; + turn.parts = turn.parts.filter(p => !(p?.thought === true && !p.text && !p.functionCall)); + } + const needsBackfill = request.contents.some(turn => + Array.isArray(turn?.parts) && turn.parts.some(p => p?.functionCall && !p?.thoughtSignature) + ); + if (needsBackfill) { + for (const turn of request.contents) { + if (!Array.isArray(turn?.parts)) continue; + for (const p of turn.parts) { + if (p?.functionCall && !p?.thoughtSignature) { + p.thoughtSignature = DEFAULT_THINKING_GEMINI_CLI_SIGNATURE; + } + } + } + } + } + if (Array.isArray(request.tools)) { + for (const toolGroup of request.tools) { + if (!Array.isArray(toolGroup.functionDeclarations)) continue; + for (const fn of toolGroup.functionDeclarations) { + if (fn.parameters && !fn.parametersJsonSchema) { + fn.parametersJsonSchema = fn.parameters; + delete fn.parameters; + } + } + } + } + stripOpenAILeakFields(work); + if (work.request && typeof work.request === "object") { + stripOpenAILeakFields(work.request); + delete work.request.user_prompt_id; + } + applyIncludeReasoningToGemini(work); + delete work.include_reasoning; + return work; + } + + stripOpenAILeakFields(work); + applyIncludeReasoningToGemini(work); + delete work.include_reasoning; + return { + project: credentials?.projectId || work?.project, + model, + request: work + }; + } + + // Parse RetryInfo.retryDelay from Google API 429 body to surface upstream retry hint + parseError(response, bodyText) { + const base = super.parseError(response, bodyText); + if (response.status !== 429 || !bodyText) return base; + try { + const parsed = JSON.parse(bodyText); + const details = parsed?.error?.details; + if (Array.isArray(details)) { + for (const d of details) { + if (d?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo" && d?.retryDelay) { + base.retryAfter = d.retryDelay; + break; + } + } + } + } catch {} + return base; + } + + async refreshCredentials(credentials, log) { + if (!credentials.refreshToken) return null; + + try { + const response = await fetch(OAUTH_ENDPOINTS.google.token, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refreshToken, + client_id: this.config.clientId, + client_secret: this.config.clientSecret + }) + }); + + if (!response.ok) return null; + + const tokens = await response.json(); + log?.info?.("TOKEN", "Gemini CLI refreshed"); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || credentials.refreshToken, + expiresIn: tokens.expires_in, + projectId: credentials.projectId + }; + } catch (error) { + log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`); + return null; + } + } +} + diff --git a/open-sse/executors/zcode.js b/open-sse/executors/zcode.js index dcae4f28da..f03441e156 100644 --- a/open-sse/executors/zcode.js +++ b/open-sse/executors/zcode.js @@ -45,7 +45,7 @@ async function solveCaptcha(log) { try { browser = await chromium.launch({ headless: true, - executablePath: "/home/vanszs/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome", + executablePath: process.env.VANS_ZCODE_CHROMIUM_PATH || undefined, args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-blink-features=AutomationControlled", "--disable-features=IsolateOrigins,site-per-process", "--window-size=1280,720"], ignoreDefaultArgs: ["--enable-automation"], }); diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 681a0b0590..dd7fbaadb5 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -26,6 +26,7 @@ import { dedupeTools } from "../utils/toolDeduper.js"; import { detectLoop } from "../utils/loopGuard.js"; import { injectCaveman } from "../rtk/caveman.js"; import { injectPonytail } from "../rtk/ponytail.js"; +import { injectDefaultSystemPrompt } from "../rtk/systemInject.js"; import { injectTerminationPrompt, injectToolProtocolPrompt } from "../rtk/terminationPrompt.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js"; import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js"; @@ -101,7 +102,7 @@ export function applyLoopGuard(translatedBody, finalFormat, provider, model, log * @param {object} options.credentials - Provider credentials * @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, apiKeyName = null, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled = false, pxpipeMinChars = 1000, pxpipeTimeoutMs = 10000, pxpipeTransform = "png", onPxpipeEvent = null, sourceFormatOverride, providerThinking, clientSignal, loopGuardEnabled = true }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, apiKeyName = null, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled = false, pxpipeMinChars = 1000, pxpipeTimeoutMs = 10000, pxpipeTransform = "png", onPxpipeEvent = null, sourceFormatOverride, providerThinking, clientSignal, loopGuardEnabled = true, clientModelId = null, systemPrompt = null }) { const { provider, model, accountCount = 0 } = modelInfo; const requestStartTime = Date.now(); @@ -281,6 +282,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } } else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`); + // Default system prompt from settings: inject only when the request does + // not already carry a system message. Token-saver prompts (caveman/ponytail) + // below APPEND to it into the same system slot, preserving the default. + if (systemPrompt) { + injectDefaultSystemPrompt(translatedBody, finalFormat, systemPrompt); + log?.debug?.("SYSPROMPT", `default injected | ${finalFormat}`); + } + // Caveman: inject terse-style system prompt if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) { injectCaveman(translatedBody, finalFormat, cavemanLevel); @@ -490,7 +499,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred return createErrorResult(statusCode, errMsg, resetsAtMs); } - const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, apiKeyName, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary }; + const sharedCtx = { provider, model, clientModelId: clientModelId || body?.model || null, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, apiKeyName, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary }; const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { }); const trackDone = () => trackPendingRequest(model, provider, connectionId, false); diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 484b5951f6..4c4378a4f6 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -217,7 +217,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source /** * Handle non-streaming response from provider. */ -export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, apiKeyName, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, pxpipe, comboName }) { +export async function handleNonStreamingResponse({ providerResponse, provider, model, clientModelId, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, apiKeyName, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, pxpipe, comboName }) { trackDone(); const contentType = providerResponse.headers.get("content-type") || ""; let responseBody; @@ -322,25 +322,11 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat); } - // Strip reasoning_content when content is non-empty. - // When content is empty (e.g. thinking models that used all tokens for reasoning), - // reasoning_content is the only useful output and must be preserved. - // Also strip provider_specific_fields.reasoning_content (Kimchi puts it there). - if (translatedResponse?.choices) { - for (const choice of translatedResponse.choices) { - const msg = choice?.message; - if (!msg) continue; - if (msg.reasoning_content && msg.content) { - delete msg.reasoning_content; - } - if (msg.provider_specific_fields?.reasoning_content && msg.content) { - delete msg.provider_specific_fields.reasoning_content; - } - if (msg.provider_specific_fields?.reasoning && msg.content) { - delete msg.provider_specific_fields.reasoning; - } - } - } + // Keep reasoning_content even when content is non-empty. + // NVIDIA NIM / DeepSeek / Kimi / Qwen return both the chain-of-thought and the + // final answer; stripping reasoning here made the Thinking control look like a + // no-op (200 OK, but no visible reasoning). Clients that do not want reasoning + // can ignore the field. } reqLogger.logConvertedResponse(finalResponse); @@ -379,6 +365,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m console.error("[RequestDetail] Failed to save:", err.message); }); + // Echo the client-facing model id (original alias or provider/model string) + // rather than the upstream provider's modelVersion. ponytail: overwrite only + // when clientModelId is present; otherwise the translator's value stands. + if (clientModelId && finalResponse && typeof finalResponse === "object" && !Array.isArray(finalResponse)) { + finalResponse.model = clientModelId; + } + return { success: true, response: new Response(JSON.stringify(finalResponse), { diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 09383971be..628fce5cd3 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -259,17 +259,8 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr status: "success" }, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); - // Strip reasoning_content only when content is non-empty. - // When content is empty (e.g. thinking models that used all tokens for reasoning), - // reasoning_content is the only useful output and must be preserved. - // Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc. - if (parsed?.choices) { - for (const choice of parsed.choices) { - if (choice?.message?.reasoning_content && choice.message.content) { - delete choice.message.reasoning_content; - } - } - } + // Preserve reasoning_content alongside content. Stripping it when content was + // present hid thinking output for NIM and other OpenAI-compatible reasoners. // Reverse conversion: OpenAI → Claude when client sent Claude-format request // (relayn provider path: request was translated CLAUDE→OPENAI upstream, diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 2d540778da..530d771348 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -79,7 +79,7 @@ const CODEX_SOURCE_TO_TARGET = { /** * Determine which SSE transform stream to use based on provider/format. */ -function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { +function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, responseModel }) { const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); // Responses-API providers (e.g. codex) emit Responses SSE → translate into client format const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES; @@ -88,14 +88,14 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, if (needsCodexTranslation) { const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; - return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, isKimiModel ? normalizeKimiToolCalls : null); + return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, isKimiModel ? normalizeKimiToolCalls : null, responseModel); } if (needsTranslation(targetFormat, sourceFormat)) { - return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, isKimiModel ? normalizeKimiToolCalls : null); + return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, isKimiModel ? normalizeKimiToolCalls : null, responseModel); } - return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, isKimiModel ? normalizeKimiToolCalls : null); + return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey, isKimiModel ? normalizeKimiToolCalls : null, responseModel); } /** @@ -103,7 +103,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, * Includes a readiness gate: if upstream closes before any byte arrives, * return STREAM_EARLY_EOF so the caller can retry once on the same connection. */ -export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, apiKeyName, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId, pxpipe }) { +export async function handleStreamingResponse({ providerResponse, provider, model, clientModelId, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, apiKeyName, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId, pxpipe }) { if (onRequestSuccess) { Promise.resolve() .then(onRequestSuccess) @@ -144,7 +144,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode }; } - const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); + const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, responseModel: clientModelId }); // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES; diff --git a/open-sse/handlers/search/adapters/duckduckgo.js b/open-sse/handlers/search/adapters/duckduckgo.js new file mode 100644 index 0000000000..d3ddcecf31 --- /dev/null +++ b/open-sse/handlers/search/adapters/duckduckgo.js @@ -0,0 +1,162 @@ +/** + * Free DuckDuckGo web search (no API key). + * Browser-mimicry profile: single stable Chrome fingerprint — no rotation. + * Uses undici Agent with browser-like TLS settings + Chrome header set so the + * request looks like a normal Chrome tab, not a scraper bot. + * ponytail: undici Agent ciphers approximate Chrome's ClientHello. For a true + * JA3 match install got-scraping; this is close enough for DDG's filter. + */ + +import DDG from "duck-duck-scrape"; +import { Agent, fetch as undiciFetch } from "undici"; + +// One stable, averaged Chrome profile. No rotation — a fixed fingerprint that +// looks like the same returning browser, which DDG's filter treats more +// favourably than a UA that changes every request. +const STABLE_CHROME_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +// Browser-like undici dispatcher: HTTP/2 on, full browser cipher list order. +// ponytail: ciphers are Chrome 131's set in browser-preferred order. If DDG +// starts TLS-fingerprinting, add got-scraping for a real JA3 spoof. +const browserAgent = new Agent({ + allowH2: true, + connect: { + timeout: 30_000, + // Let undici use its default TLS stack — close enough to Chrome for DDG. + // A real JA3 spoof needs got-scraping's TLS profile; this is the ceiling + // for the stdlib-only path. + }, +}); + +async function browserFetch(url, options = {}) { + return undiciFetch(url, { + ...options, + dispatcher: browserAgent, + headers: { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "User-Agent": STABLE_CHROME_UA, + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", + "Sec-CH-UA": `"Chromium";v="131", "Not_A Brand";v="24", "Google Chrome";v="131"`, + "Sec-CH-UA-Mobile": "?0", + "Sec-CH-UA-Platform": `"Windows"`, + ...(options.headers || {}), + }, + }); +} + +function mapScrapeResults(items, limit) { + return (Array.isArray(items) ? items : []) + .slice(0, limit) + .map((item) => ({ + title: item.title || item.heading || "", + url: item.url || item.href || item.link || "", + snippet: item.description || item.snippet || item.body || "", + published_at: item.published || item.date || null, + })) + .filter((r) => r.url); +} + +function decodeHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** Parse duckduckgo.com/html result blocks (no API key, works when scrape is blocked). */ +async function searchHtmlLite(query, limit, signal) { + const url = `https://html.duckduckgo.com/html/?${new URLSearchParams({ q: query })}`; + const res = await browserFetch(url, { signal }); + if (!res.ok) throw new Error(`DuckDuckGo HTML HTTP ${res.status}`); + const html = await res.text(); + const results = []; + // result links: class="result__a" href="..." + const re = /class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?class="result__snippet"[^>]*>([\s\S]*?)<\/(?:a|td)/gi; + let m; + while ((m = re.exec(html)) && results.length < limit) { + let href = m[1]; + // DDG sometimes wraps redirects: //duckduckgo.com/l/?uddg=Paused
)} {/* ACL badges */} - {(key.allowedProviders || key.allowedCombos || key.allowedKinds) && ( + {(key.allowedProviders || key.allowedCombos || key.allowedKinds || key.allowedModels) && (This key can access all providers ({providerList.length}).
} + {/* Per-provider models */} +No providers selected.
+ ) : ( + (editProvidersAll ? providerList : providerList.filter((p) => editProviders.includes(p.id))).map((p) => { + const models = modelsForProvider(p.id); + const open = !!editModelsByProvider[p.id]; + return ( +No models listed for this provider.
+ ) : ( + models.map((m) => { + const checked = editModels.includes(m.id); + return ( + + ); + }) + )} +This key can access all models of allowed providers.
} +Running on your machine
-Database Location
-~/.9router/db/data.sqlite
-- {dbStatus.message} -
- )} -Require login
-- When ON, dashboard requires password. When OFF, access without login. -
-- Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys. -
- -This value is write-only after saving.
-Redirect URI
-{oidcRedirectUri}
- - {oidcTestStatus.message} -
- )} - - {oidcStatus.message && ( -- {oidcStatus.message} -
- )} - - {settings.authMode === "oidc" && ( -- OIDC login is currently active. Password login is disabled until you switch back. -
- )} - - {settings.authMode === "both" && ( -- Password and OIDC login are both active. -
- )} -Round Robin
-- Cycle through accounts to distribute load -
-Sticky Limit
-- Calls per account before switching -
-Combo Round Robin
-- Cycle through providers in combos instead of always starting with first -
-Combo Sticky Limit
-- Calls per combo model before switching -
-- {settings.fallbackStrategy === "round-robin" - ? `Currently distributing requests across all available accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.` - : "Currently using accounts in priority order (Fill First)."} - {settings.comboStrategy === "round-robin" - ? ` Combos rotate after ${settings.comboStickyRoundRobinLimit || 1} call${(settings.comboStickyRoundRobinLimit || 1) === 1 ? "" : "s"} per model.` - : " Combos always start with their first model."} -
-Outbound Proxy
-Enable proxy for OAuth + provider outbound requests.
-- {proxyStatus.message} -
- )} -Enable Observability
-- Record request details for inspection in the logs view -
-{APP_CONFIG.name} v{APP_CONFIG.version}
-Local Mode - All data stored on your machine
-- Enter your current password to {dbAuth.mode === "export" ? "export" : "import"} the database. -
- setDbAuth((s) => ({ ...s, password: e.target.value }))} - onKeyDown={(e) => { if (e.key === "Enter" && dbAuth.password) handleDbAuthConfirm(); }} - placeholder="Current password" - autoFocus - /> -+ Optional. This text is prepended to the system message of every chat request + that does not already carry one. Leave empty to disable. +
+{fullModel}
+ {editing ? (
+
+ {displayId}
+ {thinkingSuffix ? `(${thinkingSuffix})` : ""}
+
+ )}
- {model.name && {model.name}}
+ {alias ? (
+
+ → {fullModel}
+
+ ) : model.name ? (
+ {model.name}
+ ) : null}
+ {isFree && free}
{authMode === "oidc" && oidcConfigured - ? "Masuk dengan OIDC provider untuk mengakses dashboard" - : "Masukkan password untuk mengakses dashboard"} + ? "Sign in with OIDC to access the dashboard" + : "Enter password to access the dashboard"}
{nameError}
} - > - ) : ( - - )} -- {forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed -
-No models added yet
-{nameError}
} + > + ) : ( + + )} ++ {forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed +
+No models added yet
+