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= + try { + const u = new URL(href.startsWith("//") ? `https:${href}` : href); + if (u.hostname.includes("duckduckgo.com") && u.searchParams.get("uddg")) { + href = decodeURIComponent(u.searchParams.get("uddg")); + } + } catch { /* keep href */ } + results.push({ + title: decodeHtml(m[2]), + url: href, + snippet: decodeHtml(m[3]), + published_at: null, + }); + } + if (results.length === 0) { + // looser fallback: any result__a + const re2 = /class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi; + while ((m = re2.exec(html)) && results.length < limit) { + let href = m[1]; + try { + const u = new URL(href.startsWith("//") ? `https:${href}` : href); + if (u.hostname.includes("duckduckgo.com") && u.searchParams.get("uddg")) { + href = decodeURIComponent(u.searchParams.get("uddg")); + } + } catch { /* keep */ } + results.push({ title: decodeHtml(m[2]), url: href, snippet: "", published_at: null }); + } + } + return results.filter((r) => r.url && !r.url.includes("duckduckgo.com/y.js")); +} + +/** + * @param {{ query: string, maxResults?: number, searchType?: string, signal?: AbortSignal }} opts + */ +export async function searchDuckDuckGo({ query, maxResults = 5, searchType = "web", signal } = {}) { + if (!query || typeof query !== "string") { + throw new Error("DuckDuckGo search requires a non-empty query"); + } + const limit = Math.min(Math.max(Number(maxResults) || 5, 1), 20); + + if (signal?.aborted) { + const err = new Error("DuckDuckGo search aborted"); + err.name = "AbortError"; + throw err; + } + + // Primary: HTML scraper with full Chrome headers — stays under DDG's bot + // filter without an API key. Only fall back to the library if the HTML + // endpoint fails outright (rate limit / layout change). + try { + const results = await searchHtmlLite(query, limit, signal); + if (results.length > 0) return { results, totalResults: results.length }; + } catch (err) { + if (err?.name === "AbortError") throw err; + } + + // Fallback: duck-duck-scrape library (its own UA, may be throttled). + try { + let raw; + if (searchType === "news" && typeof DDG.searchNews === "function") { + raw = await DDG.searchNews(query, { safeSearch: DDG.SafeSearchType?.MODERATE }); + } else { + raw = await DDG.search(query, { safeSearch: DDG.SafeSearchType?.MODERATE }); + } + const items = Array.isArray(raw?.results) ? raw.results : Array.isArray(raw) ? raw : []; + const results = mapScrapeResults(items, limit); + if (results.length > 0) return { results, totalResults: results.length }; + } catch (err) { + if (err?.name === "AbortError") throw err; + } + + return { results: [], totalResults: 0 }; +} diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.js index 9a08c79c67..8a0421b804 100644 --- a/open-sse/handlers/search/index.js +++ b/open-sse/handlers/search/index.js @@ -10,6 +10,7 @@ import { buildSearchRequest } from "./callers.js"; import { normalizeSearchResponse } from "./normalizers.js"; import { handleChatSearch } from "./chatSearch.js"; +import { searchDuckDuckGo } from "./adapters/duckduckgo.js"; const GLOBAL_TIMEOUT_MS = 15000; const NON_RETRIABLE = new Set([400, 401, 403, 404]); @@ -84,6 +85,58 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential providerSpecificData: credentials?.providerSpecificData }; + // In-process free search (no HTTP upstream). Used by DuckDuckGo. + if (providerConfig.executor === "duckduckgo") { + const remaining = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime); + const timeout = Math.min(providerConfig.timeoutMs || 12000, Math.max(remaining, 1000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + log?.info?.("SEARCH", `duckduckgo | "${params.query.slice(0, 80)}" | type=${params.searchType}`); + try { + const { results, totalResults } = await searchDuckDuckGo({ + query: params.query, + maxResults: params.maxResults, + searchType: params.searchType, + signal: controller.signal, + }); + clearTimeout(timer); + const duration = Date.now() - startTime; + const now = new Date().toISOString(); + const normalized = results.map((item, idx) => ({ + title: item.title || "", + url: item.url || "", + display_url: item.url ? String(item.url).replace(/^https?:\/\/(www\.)?/, "").split("?")[0] : undefined, + snippet: item.snippet || "", + position: idx + 1, + score: null, + published_at: item.published_at || null, + favicon_url: null, + content: null, + metadata: { author: null, language: null, source_type: null, image_url: null }, + citation: { provider: "duckduckgo", retrieved_at: now, rank: idx + 1 }, + provider_raw: null, + })); + return { + success: true, + data: { + provider: "duckduckgo", + query: params.query, + results: normalized.slice(0, params.maxResults), + answer: null, + usage: { queries_used: 1, search_cost_usd: 0 }, + metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: totalResults }, + errors: [], + }, + }; + } catch (err) { + clearTimeout(timer); + const isTimeout = err?.name === "AbortError"; + const status = isTimeout ? 504 : 502; + log?.error?.("SEARCH", `duckduckgo ${isTimeout ? "timeout" : "error"}: ${err?.message || err}`); + return { success: false, status, error: `duckduckgo ${isTimeout ? "timeout" : "error"}: ${err?.message || err}` }; + } + } + let url, init; try { ({ url, init } = buildSearchRequest({ id: provider.id, ...providerConfig }, params)); diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 2052b8e536..cc19a93bb5 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -129,13 +129,17 @@ export const PROVIDER_CAPABILITIES = { "glm-5.2": { reasoning: true, thinkingFormat: "claude-budget", thinkingCanDisable: true, contextWindow: 128000, maxOutput: 128000 }, "gpt-5.5": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 256000, maxOutput: 128000 }, }, - // NVIDIA NIM is OpenAI-compatible → rejects MiniMax/GLM native `thinking` field. - // Force openai reasoning_effort format for its reasoning models. #issue + // NVIDIA NIM hosts models from different providers (Z.ai GLM, DeepSeek, etc). + // Each model family needs its native thinking wire-format per testing: + // - GLM (z-ai): thinking:{type:"enabled"} (zai format) → returns reasoning_content + // - DeepSeek v4 Pro: thinking:{type:"enabled"} + reasoning_effort (deepseek format) + // - DeepSeek v4 Flash: no thinking param, only reasoning_effort (openai format) + // - MiniMax/Kimi/Nemotron: openai reasoning_effort nvidia: { "minimaxai/minimax-m2.7": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 }, "minimaxai/minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 131072 }, - "z-ai/glm-5.2": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 128000 }, - "deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 }, + "z-ai/glm-5.2": { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 }, + "deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 65536 }, "deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 }, }, "kiro": { diff --git a/open-sse/providers/registry/alicode-intl.js b/open-sse/providers/registry/alicode-intl.js index 45d4f21d91..0359bda494 100644 --- a/open-sse/providers/registry/alicode-intl.js +++ b/open-sse/providers/registry/alicode-intl.js @@ -14,7 +14,7 @@ export default { }, category: "apikey", transport: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", headers: {}, quirks: { preserveCacheControl: true }, }, diff --git a/open-sse/providers/registry/duckduckgo.js b/open-sse/providers/registry/duckduckgo.js new file mode 100644 index 0000000000..49dd218b76 --- /dev/null +++ b/open-sse/providers/registry/duckduckgo.js @@ -0,0 +1,30 @@ +export default { + id: "duckduckgo", + alias: "ddg", + display: { + name: "DuckDuckGo", + icon: "travel_explore", + color: "#DE5833", + textIcon: "DDG", + website: "https://duckduckgo.com", + }, + category: "freeTier", + authType: "none", + serviceKinds: ["webSearch"], + noAuth: true, + searchConfig: { + // In-process adapter (duck-duck-scrape), not an HTTP base URL. + baseUrl: "internal:duckduckgo", + method: "INTERNAL", + authType: "none", + authHeader: "none", + costPerQuery: 0, + freeMonthlyQuota: 999999, + searchTypes: ["web", "news"], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 12000, + cacheTTLMs: 180000, + executor: "duckduckgo", + }, +}; diff --git a/open-sse/providers/registry/gemini-cli.js b/open-sse/providers/registry/gemini-cli.js index 3e4a94c291..5e960f919c 100644 --- a/open-sse/providers/registry/gemini-cli.js +++ b/open-sse/providers/registry/gemini-cli.js @@ -21,8 +21,8 @@ export default { transport: { baseUrl: "https://cloudcode-pa.googleapis.com/v1internal", format: "gemini-cli", - cliVersion: "0.34.0", - apiClient: "google-genai-sdk/1.41.0 gl-node/v22.19.0", + cliVersion: "0.44.0", + apiClient: "gl-node/26.1.0", usage: { quotaUrl: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", loadCodeAssistUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 4ce0f855c7..cf138922e4 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -126,6 +126,7 @@ import p124 from "./a6api.js"; import p125 from "./alims-intl.js"; import p126 from "./zenmux.js"; import p127 from "./tokenrouter.js"; +import p128 from "./duckduckgo.js"; export default [ p0, @@ -255,5 +256,6 @@ export default [ p124, p125, p126, - p127 + p127, + p128 ]; diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index ba998ee7c9..9ab34dd053 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -44,5 +44,9 @@ export function getThinkingLevels(provider, model) { const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none"); + // Gemini CLI / Vertex: drop "minimal" — too weak for useful CoT and confuses the picker. + if (provider === "gemini-cli" || provider === "vertex") { + levels = levels.filter((l) => l !== "minimal"); + } return levels; } diff --git a/open-sse/rtk/systemInject.js b/open-sse/rtk/systemInject.js index 0d5af728b2..cecb8b2433 100644 --- a/open-sse/rtk/systemInject.js +++ b/open-sse/rtk/systemInject.js @@ -6,6 +6,61 @@ import { FORMATS } from "../translator/formats.js"; const SEP = "\n\n"; +/** + * Detect whether a request body already carries a system message/prompt so the + * chat handler knows when NOT to inject a default. Detects every shape we + * support across OpenAI/Claude/Gemini/Responses. Returns true when any system + * slot already has non-empty text. + */ +export function hasSystemPrompt(body, format) { + if (!body) return false; + + switch (format) { + case FORMATS.CLAUDE: { + if (typeof body.system === "string") return body.system.trim().length > 0; + if (Array.isArray(body.system)) { + return body.system.some(b => b?.type === "text" && typeof b.text === "string" && b.text.trim().length > 0); + } + return false; + } + case FORMATS.GEMINI: + case FORMATS.GEMINI_CLI: + case FORMATS.VERTEX: + case FORMATS.ANTIGRAVITY: { + const target = body.request && typeof body.request === "object" ? body.request : body; + const sys = target?.system_instruction ?? target?.systemInstruction; + if (sys && Array.isArray(sys.parts)) { + return sys.parts.some(p => typeof p?.text === "string" && p.text.trim().length > 0); + } + return false; + } + default: { + // OpenAI Responses API + if (typeof body.instructions === "string" && body.instructions.trim().length > 0) return true; + const arr = Array.isArray(body.messages) ? body.messages : Array.isArray(body.input) ? body.input : null; + if (!arr) return false; + return arr.some(m => m && (m.role === "system" || m.role === "developer") && hasOpenAIMessageContent(m)); + } + } +} + +function hasOpenAIMessageContent(msg) { + const c = msg?.content; + if (typeof c === "string") return c.trim().length > 0; + if (Array.isArray(c)) return c.some(p => (p?.type === "input_text" || p?.type === "text") && typeof p.text === "string" && p.text.trim().length > 0); + return false; +} + +/** + * Inject the default system prompt ONLY when the request does not already carry + * one. Honors the same shape dispatch as injectSystemPrompt. + */ +export function injectDefaultSystemPrompt(body, format, prompt) { + if (!body || !prompt) return; + if (hasSystemPrompt(body, format)) return; + injectSystemPrompt(body, format, prompt); +} + export function injectSystemPrompt(body, format, prompt) { if (!body || !prompt) return; diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js index ffb965e39a..d3a7131388 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -7,14 +7,19 @@ * This significantly reduces the risk of being flagged by Google's anti-abuse systems. */ -import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js"; +import { CLOUD_CODE_API, GEMINI_CLI_LOAD_CODE_ASSIST_HEADERS, GEMINI_CLI_LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js"; // ─── Cache ──────────────────────────────────────────────────────────────────── // connectionId -> { projectId: string, fetchedAt: number } const projectIdCache = new Map(); -/** How long a cached project ID is considered fresh (1 hour). */ -const CACHE_TTL_MS = 60 * 60 * 1000; +/** How long a cached project ID is considered fresh (30 days). + * The projectId is bound to the Google account, not the OAuth token, so it + * does not change as long as the connection stays the same. The + * in-memory cache is also backed by the DB (see chat.js:405 + tokenRefresh), + * but a long TTL here avoids unnecessary re-fetches on cold misses within a + * running process. ponytail: 30-day cap is generous — re-validate on revoke. */ +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // ─── Pending-fetch deduplication ───────────────────────────────────────────── // connectionId -> { promise: Promise, controller: AbortController, startedAt: number } @@ -129,6 +134,19 @@ export function invalidateProjectId(connectionId) { projectIdCache.delete(connectionId); } +/** + * Seed the in-memory cache with an already-known projectId (e.g. loaded from + * the DB) so getProjectIdForConnection returns it without an upstream fetch. + * Uses the current timestamp for fetchedAt; the long TTL keeps it valid. + * + * @param {string} connectionId + * @param {string} projectId + */ +export function seedProjectIdCache(connectionId, projectId) { + if (!connectionId || !projectId) return; + projectIdCache.set(connectionId, { projectId, fetchedAt: Date.now() }); +} + /** * Fully remove a connection: abort any in-flight fetch and delete its cached project ID. * Wire this into your connection close / disconnect lifecycle events to prevent memory leaks. @@ -158,8 +176,8 @@ export function removeConnection(connectionId) { async function fetchProjectId(accessToken, signal) { const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { method: "POST", - headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, - body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), + headers: { ...GEMINI_CLI_LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, + body: JSON.stringify({ metadata: GEMINI_CLI_LOAD_CODE_ASSIST_METADATA }), signal }); @@ -199,7 +217,7 @@ async function fetchProjectId(accessToken, signal) { async function onboardUser(accessToken, tierID, externalSignal) { console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); - const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; + const reqBody = { tierId: tierID, metadata: GEMINI_CLI_LOAD_CODE_ASSIST_METADATA }; const MAX_ATTEMPTS = 5; for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { @@ -215,7 +233,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { try { const response = await fetch(CLOUD_CODE_API.onboardUser, { method: "POST", - headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, + headers: { ...GEMINI_CLI_LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify(reqBody), signal: localCtrl.signal }); diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 0e28f8d4e2..04d0974639 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -239,23 +239,25 @@ export function openaiToGeminiRequest(model, body, stream) { // OpenAI -> Gemini CLI (Cloud Code Assist) export function openaiToGeminiCLIRequest(model, body, stream) { const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE); + + // Carry Google-native web search flag through to the envelope builder. + if (body.web_search === true || body.webSearch === true || body.extra_body?.google?.web_search === true) { + gemini._googleWebSearch = true; + } // Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation. - // Clean schema for tools - if (gemini.tools?.[0]?.functionDeclarations) { - for (const fn of gemini.tools[0].functionDeclarations) { - if (fn.parameters) { - const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters); - fn.parameters = cleanedSchema; - // if (isClaude) { - // fn.parameters = cleanedSchema; - // } else { - // fn.parametersJsonSchema = cleanedSchema; - // delete fn.parameters; - // } - } +// Cloud Code Assist requires parametersJsonSchema (not parameters). The "parameters" +// alias is silently accepted by the API but ignored in VALIDATED mode, so tool args +// lose their names/schema enforcement and some calls come back with empty args. +if (gemini.tools?.[0]?.functionDeclarations) { + for (const fn of gemini.tools[0].functionDeclarations) { + if (fn.parameters) { + const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters); + fn.parametersJsonSchema = cleanedSchema; + delete fn.parameters; } } +} return gemini; } @@ -264,6 +266,9 @@ export function openaiToGeminiCLIRequest(model, body, stream) { function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) { const projectId = credentials?.projectId || generateProjectId(); + // Cloud Code Assist v1internal generateContent accepts only a fixed set of + // request fields. Extra keys (e.g. user_prompt_id) are rejected with 400 + // "Unknown name ... Cannot find field". Keep the request payload strict. const envelope = { project: projectId, model: model, @@ -292,6 +297,14 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra }; } + // Google-native web search: inject {googleSearch: {}} as a separate tools + // entry so Gemini's own grounding search runs alongside function tools. + // The flag is set when the user enables Web Search for this provider. + if (geminiCLI._googleWebSearch) { + if (!envelope.request.tools) envelope.request.tools = []; + envelope.request.tools.push({ googleSearch: {} }); + } + return envelope; } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 94225107e7..827f81369f 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -89,9 +89,14 @@ export function createSSEStream(options = {}) { body = null, onStreamComplete = null, apiKey = null, - normalizeKimiToolCalls = null + normalizeKimiToolCalls = null, + responseModel = null } = options; + // Echo the client-facing model id (alias or original provider/model) in SSE + // chunks instead of the upstream modelVersion. ponytail: fallback to model. + const echoModel = responseModel || model; + let buffer = ""; let usage = null; @@ -103,7 +108,7 @@ export function createSSEStream(options = {}) { // 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: echoModel } : null; let totalContentLength = 0; let accumulatedContent = ""; @@ -161,6 +166,9 @@ export function createSSEStream(options = {}) { const idFixed = fixInvalidId(parsed); + // Echo the client-facing model id in every chunk. + if (echoModel) parsed.model = echoModel; + // Ensure OpenAI-required fields are present on streaming chunks (Letta compat) let fieldsInjected = false; if (parsed.choices !== undefined) { @@ -279,6 +287,9 @@ export function createSSEStream(options = {}) { const parsed = parseSSELine(trimmed, targetFormat); if (!parsed) continue; + // Echo the client-facing model id in every chunk. + if (echoModel) parsed.model = echoModel; + // Responses API same-format passthrough: preserve event framing + track terminal state const isOpenAIResponsesStream = targetFormat === FORMATS.OPENAI_RESPONSES; const keepsOpenAIResponsesFormat = isOpenAIResponsesStream && sourceFormat === FORMATS.OPENAI_RESPONSES; @@ -411,7 +422,7 @@ export function createSSEStream(options = {}) { // chunk so the client sees the correct finish_reason. const isFinishChunk = item.type === "message_delta" || item.choices?.[0]?.finish_reason; if (kimiToolCalls && isFinishChunk && sourceFormat === FORMATS.OPENAI) { - const emitted = emitKimiToolCallsChunk(controller, kimiToolCalls, state, model, sourceFormat, reqLogger); + const emitted = emitKimiToolCallsChunk(controller, kimiToolCalls, state, echoModel, sourceFormat, reqLogger); if (emitted) { sseEmittedCount++; kimiToolCallsEmitted = true; @@ -543,7 +554,7 @@ export function createSSEStream(options = {}) { content: accumulatedContent, }); if (hasTools) { - const emitted = emitKimiToolCallsChunk(controller, normalized.tool_calls, state, model, sourceFormat, reqLogger); + const emitted = emitKimiToolCallsChunk(controller, normalized.tool_calls, state, echoModel, sourceFormat, reqLogger); if (emitted) { sseEmittedCount++; kimiToolCallsEmitted = true; @@ -591,7 +602,7 @@ export function createSSEStream(options = {}) { }); } -export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, normalizeKimiToolCalls = null) { +export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, normalizeKimiToolCalls = null, responseModel = null) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, targetFormat, @@ -604,11 +615,12 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p body, onStreamComplete, apiKey, - normalizeKimiToolCalls + normalizeKimiToolCalls, + responseModel }); } -export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, normalizeKimiToolCalls = null) { +export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, normalizeKimiToolCalls = null, responseModel = null) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, provider, @@ -618,6 +630,7 @@ export function createPassthroughStreamWithLogger(provider = null, reqLogger = n body, onStreamComplete, apiKey, - normalizeKimiToolCalls + normalizeKimiToolCalls, + responseModel }); } diff --git a/package.json b/package.json index 845406bfee..fff2b43702 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,14 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", - "@swc/helpers": "^0.5.15", "@next/env": "^16.1.6", "@next/third-parties": "^16.2.9", + "@swc/helpers": "^0.5.15", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", "confbox": "^0.2.4", "dompurify": "^3.2.7", + "duck-duck-scrape": "^2.2.7", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", @@ -55,9 +56,9 @@ "zustand": "^5.0.10" }, "optionalDependencies": { - "better-sqlite3": "^12.6.2" + "better-sqlite3": "^12.6.2", + "@tailwindcss/oxide-wasm32-wasi": "^4.3.3" }, - "comment_better_sqlite3": "kept in optionalDependencies so npm install doesn't fail on systems without build tools — sql.js is used as fallback at runtime", "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "eslint": "^9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 298e0cf0ee..610a661e2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@swc/helpers': specifier: ^0.5.15 version: 0.5.15 + '@tailwindcss/oxide-wasm32-wasi': + specifier: 4.3.0 + version: 4.3.0 '@xyflow/react': specifier: ^12.10.1 version: 12.11.0(immer@11.1.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -44,6 +47,9 @@ importers: dompurify: specifier: ^3.2.7 version: 3.2.7 + duck-duck-scrape: + specifier: ^2.2.7 + version: 2.2.7 express: specifier: ^5.2.1 version: 5.2.1 @@ -1371,6 +1377,9 @@ packages: dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + duck-duck-scrape@2.2.7: + resolution: {integrity: sha512-BEcglwnfx5puJl90KQfX+Q2q5vCguqyMpZcSRPBWk8OY55qWwV93+E+7DbIkrGDW4qkqPfUvtOUdi0lXz6lEMQ==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1754,6 +1763,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1766,6 +1778,10 @@ packages: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2197,6 +2213,11 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + needle@3.5.0: + resolution: {integrity: sha512-jaQyPKKk2YokHrEg+vFDYxXIHTCBgiZwSHOoVx/8V3GIBS8/VN6NdVRmg8q1ERtPkMvmOvebsgga4sAj5hls/w==} + engines: {node: '>= 4.4.x'} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -2526,6 +2547,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3485,8 +3510,7 @@ snapshots: '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.0': - optional: true + '@tailwindcss/oxide-wasm32-wasi@4.3.0': {} '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true @@ -4164,6 +4188,11 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + duck-duck-scrape@2.2.7: + dependencies: + html-entities: 2.6.0 + needle: 3.5.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4714,6 +4743,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-entities@2.6.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4741,6 +4772,10 @@ snapshots: transitivePeerDependencies: - debug + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5102,6 +5137,11 @@ snapshots: natural-compare@1.4.0: {} + needle@3.5.0: + dependencies: + iconv-lite: 0.6.3 + sax: 1.6.0 + negotiator@1.0.0: {} next@16.2.9(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -5493,6 +5533,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.6.0: {} + scheduler@0.27.0: {} selfsigned@5.5.0: diff --git a/postcss.config.mjs b/postcss.config.mjs index fedc5ae9e3..b678b4814c 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,12 +1,9 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const projectRoot = path.dirname(fileURLToPath(import.meta.url)); - -export default { - plugins: { - "@tailwindcss/postcss": { - base: projectRoot, - }, - }, -}; +export default { + plugins: { + // Scanning is owned entirely by @source directives in globals.css + // (`@import "tailwindcss" source(none)` + explicit @source paths). + // Leaving `base` unset avoids the base/@source double-conflict that + // forced Tailwind to re-walk repo-root neighbors on every CSS rebuild. + "@tailwindcss/postcss": {}, + }, +}; diff --git a/public/i18n/literals/id.json b/public/i18n/literals/id.json index 8d984e5c41..ad6006aa3d 100644 --- a/public/i18n/literals/id.json +++ b/public/i18n/literals/id.json @@ -191,5 +191,21 @@ "Click to add, click again to remove. Changes are saved automatically.": "Klik untuk menambah, klik lagi untuk menghapus. Perubahan disimpan secara otomatis.", "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Pemberitahuan Risiko: Penyedia ini menggunakan sesi langganan/OAuth yang tidak dilisensikan secara resmi untuk penggunaan proxy/router. Akun mungkin dibatasi atau diblokir. Gunakan dengan risiko Anda sendiri.", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM mencegat lalu lintas HTTPS alat IDE (Antigravity, GitHub Copilot, Kiro) melalui CA lokal untuk mengalihkan permintaan ke penyedia Anda. Mungkin melanggar ToS → risiko ban akun. Gunakan dengan risiko Anda sendiri.", - "Endpoint is exposed without an API key.": "Endpoint terekspos tanpa kunci API." + "Endpoint is exposed without an API key.": "Endpoint terekspos tanpa kunci API.", + "Sign in with OIDC": "Masuk dengan OIDC", + "Invalid password": "Password salah", + "Something went wrong. Please try again.": "Terjadi kesalahan. Silakan coba lagi.", + "Sign in with OIDC to access the dashboard": "Masuk dengan OIDC provider untuk mengakses dashboard", + "Enter password to access the dashboard": "Masukkan password untuk mengakses dashboard", + "OIDC is enabled but issuer/client is not configured. Password login is still available.": "OIDC login aktif, tapi issuer/client belum dikonfigurasi. Password login masih tersedia.", + "Password and OIDC login are both available.": "Password dan OIDC login keduanya aktif.", + "Enter password": "Masukkan password", + "Locked. Try again in": "Terkunci. Coba lagi dalam", + "Forgot password? Open the": "Lupa password? Buka", + "CLI on the host": "CLI di host", + "Default password is": "Password default adalah", + "No custom password set. The default above works until you change it.": "Custom password belum diset. Password default di atas akan berfungsi sampai diganti.", + "Sign in": "Masuk", + "Wait": "Tunggu", + "Loading...": "Memuat..." } diff --git a/public/i18n/literals/ru.json b/public/i18n/literals/ru.json index 97c23b6ee9..30605ea2bc 100644 --- a/public/i18n/literals/ru.json +++ b/public/i18n/literals/ru.json @@ -1,195 +1,211 @@ -{ - "Cancel": "Отменить", - "Delete": "Удалить", - "Edit": "Редактировать", - "Save": "Сохранить", - "Close": "Закрыть", - "Add": "Добавить", - "Remove": "Удалить", - "Settings": "Настройки", - "Profile": "Профиль", - "Dashboard": "Панель управления", - "Logout": "Выход", - "Login": "Вход", - "Providers": "Провайдеры", - "Usage": "Статистика", - "API Key": "Ключ API", - "Connected": "Подключено", - "Disconnected": "Отключено", - "Active": "Активно", - "Inactive": "Неактивно", - "Success": "Успех", - "Failed": "Ошибка", - "Error": "Ошибка", - "Warning": "Предупреждение", - "Info": "Информация", - "Loading": "Загрузка", - "Search": "Поиск", - "Filter": "Фильтр", - "Sort": "Сортировка", - "Export": "Экспорт", - "Import": "Импорт", - "Refresh": "Обновить", - "Back": "Назад", - "Next": "Далее", - "Previous": "Назад", - "Submit": "Отправить", - "Confirm": "Подтвердить", - "Yes": "Да", - "No": "Нет", - "OK": "OK", - "Apply": "Применить", - "Reset": "Сбросить", - "Clear": "Очистить", - "Select": "Выбрать", - "Upload": "Загрузить", - "Download": "Скачать", - "Copy": "Копировать", - "Paste": "Вставить", - "Cut": "Вырезать", - "Undo": "Отменить", - "Redo": "Повторить", - "Name": "Имя", - "Description": "Описание", - "Status": "Статус", - "Type": "Тип", - "Date": "Дата", - "Time": "Время", - "Created": "Создано", - "Updated": "Обновлено", - "Actions": "Действия", - "Details": "Подробности", - "View": "Просмотр", - "New": "Новый", - "Total": "Всего", - "Count": "Количество", - "Price": "Цена", - "Cost": "Стоимость", - "Free": "Бесплатно", - "Paid": "Платно", - "Enable": "Включить", - "Disable": "Отключить", - "Enabled": "Включено", - "Disabled": "Отключено", - "Online": "Онлайн", - "Offline": "Офлайн", - "Available": "Доступно", - "Unavailable": "Недоступно", - "Required": "Обязательно", - "Optional": "Опционально", - "Default": "По умолчанию", - "Custom": "Пользовательский", - "Advanced": "Дополнительно", - "Basic": "Основной", - "Help": "Справка", - "Support": "Поддержка", - "Documentation": "Документация", - "Version": "Версия", - "Language": "Язык", - "Theme": "Тема", - "Light": "Светлая", - "Dark": "Темная", - "Auto": "Автоматически", - "Endpoint": "Конечная точка", - "Combos": "Комбинации", - "Quota Tracker": "Отслеживание квоты", - "MITM": "MITM", - "CLI Tools": "Инструменты CLI", - "Console Log": "Журнал консоли", - "System": "Система", - "Debug": "Отладка", - "Shutdown": "Завершение", - "Close Proxy": "Закрыть прокси", - "Are you sure you want to close the proxy server?": "Вы уверены, что хотите закрыть прокси-сервер?", - "Server Disconnected": "Сервер отключен", - "The proxy server has been stopped.": "Прокси-сервер был остановлен.", - "Reload Page": "Перезагрузить страницу", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Сервис работает в терминале. Вы можете закрыть эту веб-страницу. Завершение остановит сервис.", - "Manage your AI provider connections": "Управляйте своими подключениями провайдера ИИ", - "Model combos with fallback": "Комбинации моделей с резервным вариантом", - "Monitor your API usage, token consumption, and request logs": "Мониторьте использование API, потребление токенов и журналы запросов", - "Intercept CLI tool traffic and route through VansAI": "Перехватите трафик инструмента CLI и маршрутизируйте через VansAI", - "Configure CLI tools": "Настройка инструментов CLI", - "API endpoint configuration": "Конфигурация конечной точки API", - "Manage your preferences": "Управляйте своими предпочтениями", - "Debug translation flow between formats": "Отладка потока трансляции между форматами", - "Live server console output": "Вывод консоли сервера в реальном времени", - "Create model combos with fallback support": "Создание комбинаций моделей с поддержкой резервного варианта", - "Local Mode": "Локальный режим", - "Running on your machine": "Работает на вашем компьютере", - "Database Location": "Расположение базы данных", - "Download Backup": "Загрузить резервную копию", - "Import Backup": "Импортировать резервную копию", - "Database backup downloaded": "Резервная копия базы данных загружена", - "Database imported successfully": "База данных успешно импортирована", - "Security": "Безопасность", - "Require login": "Требовать вход", - "When ON, dashboard requires password. When OFF, access without login.": "Когда ВКЛЮЧЕНО, панель управления требует пароль. Когда ОТКЛЮЧЕНО, доступ без входа.", - "Current Password": "Текущий пароль", - "Enter current password": "Введите текущий пароль", - "New Password": "Новый пароль", - "Enter new password": "Введите новый пароль", - "Confirm New Password": "Подтвердить новый пароль", - "Confirm new password": "Подтвердите новый пароль", - "Update Password": "Обновить пароль", - "Set Password": "Установить пароль", - "Password updated successfully": "Пароль успешно обновлен", - "Passwords do not match": "Пароли не совпадают", - "Routing Strategy": "Стратегия маршрутизации", - "Round Robin": "Циклическая выборка", - "Cycle through accounts to distribute load": "Чередование аккаунтов для распределения нагрузки", - "Sticky Limit": "Липкий предел", - "Calls per account before switching": "Вызовов на аккаунт перед переключением", - "Network": "Сеть", - "Outbound Proxy": "Исходящий прокси", - "Enable proxy for OAuth + provider outbound requests.": "Включить прокси для OAuth + исходящих запросов провайдера.", - "Proxy URL": "URL прокси", - "Leave empty to inherit existing env proxy (if any).": "Оставьте пусто, чтобы унаследовать существующий прокси env (если есть).", - "No Proxy": "Без прокси", - "Comma-separated hostnames/domains to bypass the proxy.": "Разделенные запятыми имена хостов/домены для обхода прокси.", - "Test proxy URL": "Протестировать URL прокси", - "Proxy settings applied": "Параметры прокси применены", - "Proxy enabled": "Прокси включен", - "Proxy disabled": "Прокси отключен", - "Proxy test OK": "Тест прокси OK", - "Proxy test failed": "Тест прокси не пройден", - "Please enter a Proxy URL to test": "Пожалуйста, введите URL прокси для тестирования", - "Observability": "Наблюдаемость", - "Enable Observability": "Включить наблюдаемость", - "Turn request detail recording on/off globally": "Включить/отключить запись деталей запроса глобально", - "Max Records": "Максимум записей", - "Maximum request detail records to keep (older records are auto-deleted)": "Максимальное количество записей деталей запроса для сохранения (старые записи автоматически удаляются)", - "Batch Size": "Размер пакета", - "Number of items to accumulate before writing to database (higher = better performance)": "Количество элементов для накопления перед записью в базу данных (выше = лучшая производительность)", - "Flush Interval (ms)": "Интервал очистки (мс)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Максимальное время ожидания перед очисткой буфера (предотвращает потерю данных при низком трафике)", - "Max JSON Size (KB)": "Максимальный размер JSON (KB)", - "Maximum size for each JSON field (request/response) before truncation": "Максимальный размер каждого поля JSON (запрос/ответ) перед усечением", - "All data stored on your machine": "Все данные хранятся на вашем компьютере", - "MITM Server": "Сервер MITM", - "Running": "Работает", - "Stopped": "Остановлено", - "Cert": "Сертификат", - "Server": "Сервер", - "Purpose:": "Назначение:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from VansAI": "Используйте Antigravity IDE и GitHub Copilot → с ЛЮБЫМ провайдером/моделью от VansAI", - "How it works:": "Как это работает:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Запрос Antigravity/Copilot IDE → Перенаправление DNS на localhost:443 → Прокси MITM перехватывает → 9Router → ответ для Antigravity/Copilot", - "No API keys — create one in Keys page": "Нет ключей API — создайте один на странице ключей", - "sk_9router (default)": "sk_9router (по умолчанию)", - "Server started": "Сервер запущен", - "Failed to start server": "Ошибка при запуске сервера", - "Server stopped — all DNS cleared": "Сервер остановлен — все DNS очищено", - "Failed to stop server": "Ошибка при остановке сервера", - "Sudo password is required": "Требуется пароль sudo", - "Stop Server": "Остановить сервер", - "Start Server": "Запустить сервер", - "Enable DNS per tool below to activate interception": "Включите DNS для каждого инструмента ниже, чтобы активировать перехват", - "Sudo Password Required": "Требуется пароль Sudo", - "Enter your sudo password to start/stop MITM server": "Введите пароль sudo для запуска/остановки сервера MITM", - "Sudo Password": "Пароль sudo", - "Click to add, click again to remove. Changes are saved automatically.": "Нажмите, чтобы добавить, нажмите ещё раз, чтобы удалить. Изменения сохраняются автоматически.", - "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Уведомление о риске: Этот провайдер использует сессию подписки/OAuth, не имеющую официальной лицензии для использования через прокси/маршрутизатор. Аккаунт может быть ограничен или заблокирован. Используйте на свой страх и риск.", - "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM перехватывает HTTPS-трафик IDE-инструментов (Antigravity, GitHub Copilot, Kiro) через локальный CA для перенаправления запросов вашим провайдерам. Может нарушить ToS → риск блокировки аккаунта. Используйте на свой страх и риск.", - "Endpoint is exposed without an API key.": "Эндпоинт открыт без API-ключа." -} +{ + "Cancel": "Отменить", + "Delete": "Удалить", + "Edit": "Редактировать", + "Save": "Сохранить", + "Close": "Закрыть", + "Add": "Добавить", + "Remove": "Удалить", + "Settings": "Настройки", + "Profile": "Профиль", + "Dashboard": "Dashboard", + "Logout": "Выход", + "Login": "Вход", + "Providers": "Providers", + "Usage": "Статистика", + "API Key": "API Key", + "Connected": "Подключено", + "Disconnected": "Отключено", + "Active": "Активно", + "Inactive": "Неактивно", + "Success": "Успех", + "Failed": "Ошибка", + "Error": "Ошибка", + "Warning": "Предупреждение", + "Info": "Информация", + "Loading": "Загрузка", + "Search": "Поиск", + "Filter": "Фильтр", + "Sort": "Сортировка", + "Export": "Экспорт", + "Import": "Импорт", + "Refresh": "Обновить", + "Back": "Назад", + "Next": "Далее", + "Previous": "Назад", + "Submit": "Отправить", + "Confirm": "Подтвердить", + "Yes": "Да", + "No": "Нет", + "OK": "OK", + "Apply": "Применить", + "Reset": "Сбросить", + "Clear": "Очистить", + "Select": "Выбрать", + "Upload": "Загрузить", + "Download": "Скачать", + "Copy": "Копировать", + "Paste": "Вставить", + "Cut": "Вырезать", + "Undo": "Отменить", + "Redo": "Повторить", + "Name": "Имя", + "Description": "Описание", + "Status": "Статус", + "Type": "Тип", + "Date": "Дата", + "Time": "Время", + "Created": "Создано", + "Updated": "Обновлено", + "Actions": "Действия", + "Details": "Подробности", + "View": "Просмотр", + "New": "Новый", + "Total": "Всего", + "Count": "Количество", + "Price": "Цена", + "Cost": "Стоимость", + "Free": "Бесплатно", + "Paid": "Платно", + "Enable": "Включить", + "Disable": "Отключить", + "Enabled": "Включено", + "Disabled": "Отключено", + "Online": "Онлайн", + "Offline": "Офлайн", + "Available": "Доступно", + "Unavailable": "Недоступно", + "Required": "Обязательно", + "Optional": "Опционально", + "Default": "По умолчанию", + "Custom": "Пользовательский", + "Advanced": "Дополнительно", + "Basic": "Основной", + "Help": "Справка", + "Support": "Поддержка", + "Documentation": "Документация", + "Version": "Версия", + "Language": "Язык", + "Theme": "Тема", + "Light": "Светлая", + "Dark": "Темная", + "Auto": "Автоматически", + "Endpoint": "Endpoint", + "Combos": "Combos", + "Quota Tracker": "Quota Tracker", + "MITM": "MITM", + "CLI Tools": "CLI Tools", + "Console Log": "Console Log", + "System": "System", + "Debug": "Debug", + "Shutdown": "Завершение", + "Close Proxy": "Закрыть прокси", + "Are you sure you want to close the proxy server?": "Вы уверены, что хотите закрыть прокси-сервер?", + "Server Disconnected": "Сервер отключен", + "The proxy server has been stopped.": "Прокси-сервер был остановлен.", + "Reload Page": "Перезагрузить страницу", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Сервис работает в терминале. Вы можете закрыть эту веб-страницу. Завершение остановит сервис.", + "Manage your AI provider connections": "Управление подключениями AI Providers", + "Model combos with fallback": "Combos моделей с fallback", + "Monitor your API usage, token consumption, and request logs": "Статистика API: tokens, запросы и логи", + "Intercept CLI tool traffic and route through VansAI": "Перехватите трафик инструмента CLI и маршрутизируйте через VansAI", + "Configure CLI tools": "Настройка CLI Tools", + "API endpoint configuration": "Настройка Endpoint", + "Manage your preferences": "Управляйте своими предпочтениями", + "Debug translation flow between formats": "Отладка потока трансляции между форматами", + "Live server console output": "Живой вывод консоли сервера", + "Create model combos with fallback support": "Создание Combos с поддержкой fallback", + "Local Mode": "Локальный режим", + "Running on your machine": "Работает на вашем компьютере", + "Database Location": "Расположение базы данных", + "Download Backup": "Загрузить резервную копию", + "Import Backup": "Импортировать резервную копию", + "Database backup downloaded": "Резервная копия базы данных загружена", + "Database imported successfully": "База данных успешно импортирована", + "Security": "Безопасность", + "Require login": "Требовать вход", + "When ON, dashboard requires password. When OFF, access without login.": "Когда ВКЛЮЧЕНО, панель управления требует пароль. Когда ОТКЛЮЧЕНО, доступ без входа.", + "Current Password": "Текущий пароль", + "Enter current password": "Введите текущий пароль", + "New Password": "Новый пароль", + "Enter new password": "Введите новый пароль", + "Confirm New Password": "Подтвердить новый пароль", + "Confirm new password": "Подтвердите новый пароль", + "Update Password": "Обновить пароль", + "Set Password": "Установить пароль", + "Password updated successfully": "Пароль успешно обновлен", + "Passwords do not match": "Пароли не совпадают", + "Routing Strategy": "Стратегия маршрутизации", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Чередовать аккаунты для распределения нагрузки", + "Sticky Limit": "Sticky Limit", + "Calls per account before switching": "Вызовов на аккаунт перед переключением", + "Network": "Сеть", + "Outbound Proxy": "Исходящий прокси", + "Enable proxy for OAuth + provider outbound requests.": "Включить прокси для OAuth + исходящих запросов провайдера.", + "Proxy URL": "Proxy URL", + "Leave empty to inherit existing env proxy (if any).": "Оставьте пусто, чтобы унаследовать существующий прокси env (если есть).", + "No Proxy": "Без прокси", + "Comma-separated hostnames/domains to bypass the proxy.": "Разделенные запятыми имена хостов/домены для обхода прокси.", + "Test proxy URL": "Протестировать URL прокси", + "Proxy settings applied": "Параметры прокси применены", + "Proxy enabled": "Прокси включен", + "Proxy disabled": "Прокси отключен", + "Proxy test OK": "Тест прокси OK", + "Proxy test failed": "Тест прокси не пройден", + "Please enter a Proxy URL to test": "Пожалуйста, введите URL прокси для тестирования", + "Observability": "Observability", + "Enable Observability": "Включить Observability", + "Turn request detail recording on/off globally": "Включить/отключить запись деталей запроса глобально", + "Max Records": "Максимум записей", + "Maximum request detail records to keep (older records are auto-deleted)": "Максимальное количество записей деталей запроса для сохранения (старые записи автоматически удаляются)", + "Batch Size": "Batch Size", + "Number of items to accumulate before writing to database (higher = better performance)": "Количество элементов для накопления перед записью в базу данных (выше = лучшая производительность)", + "Flush Interval (ms)": "Flush Interval (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Максимальное время ожидания перед очисткой буфера (предотвращает потерю данных при низком трафике)", + "Max JSON Size (KB)": "Max JSON Size (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Максимальный размер каждого поля JSON (запрос/ответ) перед усечением", + "All data stored on your machine": "Все данные хранятся на вашем компьютере", + "MITM Server": "Сервер MITM", + "Running": "Работает", + "Stopped": "Остановлено", + "Cert": "Cert", + "Server": "Сервер", + "Purpose:": "Назначение:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from VansAI": "Используйте Antigravity IDE и GitHub Copilot → с ЛЮБЫМ провайдером/моделью от VansAI", + "How it works:": "Как это работает:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Запрос Antigravity/Copilot IDE → Перенаправление DNS на localhost:443 → Прокси MITM перехватывает → 9Router → ответ для Antigravity/Copilot", + "No API keys — create one in Keys page": "Нет ключей API — создайте один на странице ключей", + "sk_9router (default)": "sk_9router (по умолчанию)", + "Server started": "Сервер запущен", + "Failed to start server": "Ошибка при запуске сервера", + "Server stopped — all DNS cleared": "Сервер остановлен — все DNS очищено", + "Failed to stop server": "Ошибка при остановке сервера", + "Sudo password is required": "Требуется пароль sudo", + "Stop Server": "Остановить сервер", + "Start Server": "Запустить сервер", + "Enable DNS per tool below to activate interception": "Включите DNS для каждого инструмента ниже, чтобы активировать перехват", + "Sudo Password Required": "Требуется пароль Sudo", + "Enter your sudo password to start/stop MITM server": "Введите пароль sudo для запуска/остановки сервера MITM", + "Sudo Password": "Пароль sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Нажмите, чтобы добавить, нажмите ещё раз, чтобы удалить. Изменения сохраняются автоматически.", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Уведомление о риске: Этот провайдер использует сессию подписки/OAuth, не имеющую официальной лицензии для использования через прокси/маршрутизатор. Аккаунт может быть ограничен или заблокирован. Используйте на свой страх и риск.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM перехватывает HTTPS-трафик IDE-инструментов (Antigravity, GitHub Copilot, Kiro) через локальный CA для перенаправления запросов вашим провайдерам. Может нарушить ToS → риск блокировки аккаунта. Используйте на свой страх и риск.", + "Endpoint is exposed without an API key.": "Endpoint открыт без API Key.", + "No combos yet": "Combos пока нет", + "No connections yet": "Подключений пока нет", + "No providers match your search": "Нет Providers по этому поиску", + "No providers": "Нет Providers", + "Failed to create combo": "Не удалось создать Combo", + "Failed to update combo": "Не удалось обновить Combo", + "Failed to save connection": "Не удалось сохранить подключение", + "Test Connection": "Test Connection", + "Web Search": "Web Search", + "Thinking": "Thinking", + "Sign in": "Войти", + "Invalid password": "Неверный пароль", + "Enter password": "Введите пароль", + "Enter password to access the dashboard": "Введите пароль для доступа к Dashboard", + "Something went wrong. Please try again.": "Что-то пошло не так. Попробуйте снова.", + "Default password is": "Пароль по умолчанию:" +} diff --git a/public/providers/duckduckgo.png b/public/providers/duckduckgo.png new file mode 100644 index 0000000000..69527886a4 Binary files /dev/null and b/public/providers/duckduckgo.png differ diff --git a/public/providers/duckduckgo.webp b/public/providers/duckduckgo.webp new file mode 100644 index 0000000000..d13beb4c67 Binary files /dev/null and b/public/providers/duckduckgo.webp differ diff --git a/scripts/dev-prewarm.mjs b/scripts/dev-prewarm.mjs new file mode 100644 index 0000000000..9bcd938360 --- /dev/null +++ b/scripts/dev-prewarm.mjs @@ -0,0 +1,95 @@ +import { setTimeout as sleep } from "node:timers/promises"; + +const PORT = process.env.PORT || 20127; +const HOST = process.env.HOST || "127.0.0.1"; +const PASSWORD = process.env.VANS_PREWARM_PASSWORD; +if (!PASSWORD) { + console.error("VANS_PREWARM_PASSWORD is required (set it to the dashboard password)"); + process.exit(1); +} +const base = `http://${HOST}:${PORT}`; + +const publicPaths = ["/", "/login", "/api/auth/status"]; +const authPaths = [ + "/dashboard", + "/dashboard/providers", + "/dashboard/basic-chat", + "/dashboard/console-log", + "/dashboard/endpoint", + "/api/providers", + "/api/settings", + "/api/models/availability", +]; + +async function waitReady(maxMs = 60000) { + const t0 = Date.now(); + while (Date.now() - t0 < maxMs) { + try { + const r = await fetch(`${base}/api/auth/status`, { redirect: "manual" }); + if (r.status < 500) return true; + } catch { + /* not up yet */ + } + await sleep(500); + } + return false; +} + +async function login() { + const res = await fetch(`${base}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: PASSWORD }), + redirect: "manual", + }); + const setCookie = res.headers.getSetCookie?.() || []; + const raw = setCookie.length + ? setCookie.map((c) => c.split(";")[0]).join("; ") + : (res.headers.get("set-cookie") || "").split(",").map((c) => c.split(";")[0].trim()).filter(Boolean).join("; "); + return raw; +} + +async function warm(path, cookie) { + const t0 = Date.now(); + try { + const res = await fetch(base + path, { + redirect: "manual", + headers: cookie ? { cookie } : {}, + }); + const dt = Date.now() - t0; + const ok = res.status < 500; + console.log(`${ok ? "warm" : "FAIL"} ${path} -> ${res.status} in ${dt}ms`); + return ok; + } catch (e) { + console.error(`FAIL ${path} -> ${e.message}`); + return false; + } +} + +if (!(await waitReady())) { + console.error("server not ready"); + process.exit(1); +} + +let ok = 0; +let fail = 0; +for (const p of publicPaths) { + (await warm(p, null)) ? ok++ : fail++; + await sleep(30); +} + +let cookie = ""; +try { + cookie = await login(); + console.log(cookie ? "login ok" : "login: no cookie"); +} catch (e) { + console.error("login failed:", e.message); +} + +for (const p of authPaths) { + (await warm(p, cookie)) ? ok++ : fail++; + await sleep(30); +} + +console.log(`prewarm done: ${ok} ok, ${fail} fail`); +process.exit(fail > 0 ? 1 : 0); diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 591c8a72fc..03ea1ba61e 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -10,9 +10,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { fetchCached } from "@/shared/utils/fetchCache"; import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; - -// Validate combo name: only a-z, A-Z, 0-9, -, _ -const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; +import { COMBO_NAME_REGEX } from "@/shared/constants/comboValidation.js"; export default function CombosPage() { const [combos, setCombos] = useState([]); @@ -527,7 +525,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF setNameError("Name is required"); return false; } - if (!VALID_NAME_REGEX.test(value)) { + if (!COMBO_NAME_REGEX.test(value)) { setNameError("Only letters, numbers, -, _ and . allowed"); return false; } diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 76125dcca6..cf64e3642b 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -38,10 +38,14 @@ export default function APIPageClient({ machineId }) { const [editKindsAll, setEditKindsAll] = useState(true); // null = all const [editProvidersAll, setEditProvidersAll] = useState(true); const [editCombosAll, setEditCombosAll] = useState(true); + const [editModelsAll, setEditModelsAll] = useState(true); + const [editModels, setEditModels] = useState([]); // selected model ids (provider/model) + const [editModelsByProvider, setEditModelsByProvider] = useState({}); // providerId → [modelId] const [editSaving, setEditSaving] = useState(false); const [providerList, setProviderList] = useState([]); const [aliasMap, setAliasMap] = useState({}); // alias → provider ID const [comboList, setComboList] = useState([]); + const [allModelsCatalog, setAllModelsCatalog] = useState([]); // {id, provider, model}[] const [requireApiKey, setRequireApiKey] = useState(false); const [allowRemoteNoApiKey, setAllowRemoteNoApiKey] = useState(false); @@ -475,17 +479,21 @@ export default function APIPageClient({ machineId }) { }).sort((a, b) => a.displayName.localeCompare(b.displayName)); }; - const handleOpenEditKey = (key) => { + const handleOpenEditKey = async (key) => { setEditingKey(key); setEditName(key.name || ""); const ap = key.allowedProviders; const ac = key.allowedCombos; const ak = key.allowedKinds; + const am = key.allowedModels; setEditProvidersAll(!ap); setEditCombosAll(!ac); setEditKindsAll(!ak); + setEditModelsAll(!am); setEditCombos(ac || []); setEditKinds(ak || []); + setEditModels(am || []); + setEditModelsByProvider({}); // Resolve stored ACL provider values to provider IDs in our list // Stored values can be: full provider ID, prefix (e.g. "tr"), or alias (e.g. "oc", "qd", "kc") @@ -508,6 +516,23 @@ export default function APIPageClient({ machineId }) { } else { setEditProviders([]); } + + // Lazy-load model catalog for per-provider model ACL + if (allModelsCatalog.length === 0) { + try { + const res = await fetch("/api/models", { cache: "no-store" }); + if (res.ok) { + const data = await res.json(); + const list = Array.isArray(data?.models) ? data.models : []; + setAllModelsCatalog(list.map((m) => ({ + id: m.id || `${m.provider}/${m.model}`, + provider: m.provider || (m.id || "").split("/")[0], + model: m.model || (m.id || "").split("/").slice(1).join("/"), + alias: m.alias || null, + }))); + } + } catch { /* ignore */ } + } }; const handleSaveEditKey = async () => { @@ -518,6 +543,7 @@ export default function APIPageClient({ machineId }) { allowedProviders: editProvidersAll ? null : editProviders, allowedCombos: editCombosAll ? null : editCombos, allowedKinds: editKindsAll ? null : editKinds, + allowedModels: editModelsAll ? null : editModels, }; const res = await fetch(`/api/keys/${editingKey.id}`, { method: "PUT", @@ -548,6 +574,16 @@ export default function APIPageClient({ machineId }) { setEditKinds((prev) => prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind]); }; + const toggleEditModel = (modelId) => { + setEditModels((prev) => prev.includes(modelId) ? prev.filter((m) => m !== modelId) : [...prev, modelId]); + }; + + const modelsForProvider = (providerId) => { + const p = providerList.find((x) => x.id === providerId); + const aliases = new Set([providerId, p?.alias, p?.prefix].filter(Boolean)); + return allModelsCatalog.filter((m) => aliases.has(m.provider) || (m.id || "").startsWith(`${providerId}/`) || (p?.alias && (m.id || "").startsWith(`${p.alias}/`))); + }; + const fetchData = async () => { try { const [keysRes, providersRes, combosRes, nodesRes] = await Promise.all([ @@ -1544,7 +1580,7 @@ export default function APIPageClient({ machineId }) {

Paused

)} {/* ACL badges */} - {(key.allowedProviders || key.allowedCombos || key.allowedKinds) && ( + {(key.allowedProviders || key.allowedCombos || key.allowedKinds || key.allowedModels) && (
{key.allowedProviders && ( @@ -1571,6 +1607,11 @@ export default function APIPageClient({ machineId }) { {key.allowedKinds.length === 0 ? "No kinds" : key.allowedKinds.join(", ")} )} + {key.allowedModels && ( + + {key.allowedModels.length === 0 ? "No models" : `${key.allowedModels.length} models`} + + )}
)} @@ -1754,6 +1795,62 @@ export default function APIPageClient({ machineId }) { {editProvidersAll &&

This key can access all providers ({providerList.length}).

} + {/* Per-provider models */} +
+
+ + +
+ {!editModelsAll && ( +
+ {(editProvidersAll ? providerList : providerList.filter((p) => editProviders.includes(p.id))).length === 0 ? ( +

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 ( +
+ + {open && ( +
+ {models.length === 0 ? ( +

No models listed for this provider.

+ ) : ( + models.map((m) => { + const checked = editModels.includes(m.id); + return ( + + ); + }) + )} +
+ )} +
+ ); + }) + )} +
+ )} + {editModelsAll &&

This key can access all models of allowed providers.

} +
+ {/* Combos */}
diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/MediaKindClient.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/MediaKindClient.js index d4669e7627..ed406bd65a 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/MediaKindClient.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/MediaKindClient.js @@ -78,11 +78,14 @@ function MediaProviderCard({ provider, kind, connections, isCustom, onToggle })
{total > 0 && ( - + )} @@ -147,15 +150,17 @@ export default function MediaKindClient({ initialConnections, initialNodes, init const [combos, setCombos] = useState(initialCombos || []); const [showAddCustomEmbedding, setShowAddCustomEmbedding] = useState(false); - // webSearch/webFetch listing pages are merged into /web — return null and let server handle + // webSearch/webFetch listing pages are merged into /web — redirect via effect (not during render). const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); const isEmbedding = kind === "embedding"; const supportsCombo = COMBO_KINDS.has(kind); + const needsWebRedirect = kind === "webSearch" || kind === "webFetch"; + + useEffect(() => { + if (needsWebRedirect) router.replace("/dashboard/media-providers/web"); + }, [needsWebRedirect, router]); - if (kind === "webSearch" || kind === "webFetch") { - router.replace("/dashboard/media-providers/web"); - return null; - } + if (needsWebRedirect) return null; if (!kindConfig) return notFound(); const providers = getProvidersByKind(kind); diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index aa6857e5b0..c2d36308ca 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -7,6 +7,7 @@ import Image from "next/image"; import { Card, Button, Input, Toggle, ModelSelectModal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; +import { COMBO_NAME_REGEX } from "@/shared/constants/comboValidation"; // Parse "providerId/model" or just "providerId" → { providerId, model } function parseModelEntry(entry) { @@ -16,8 +17,6 @@ function parseModelEntry(entry) { return { providerId: entry.slice(0, idx), model: entry.slice(idx + 1) }; } -const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; - const KIND_LABELS = { webSearch: "Web Search", webFetch: "Web Fetch", @@ -168,7 +167,7 @@ export default function ComboDetailPage() { const validateName = (v) => { if (!v.trim()) { setNameError("Name is required"); return false; } - if (!VALID_NAME_REGEX.test(v)) { setNameError("Only letters, numbers, -, _ and ."); return false; } + if (!COMBO_NAME_REGEX.test(v)) { setNameError("Only letters, numbers, -, _ and ."); return false; } setNameError(""); return true; }; diff --git a/src/app/(dashboard)/dashboard/profile/ProfileClient.js b/src/app/(dashboard)/dashboard/profile/ProfileClient.js deleted file mode 100644 index f1fc77081c..0000000000 --- a/src/app/(dashboard)/dashboard/profile/ProfileClient.js +++ /dev/null @@ -1,1156 +0,0 @@ -"use client"; - -import { useState, useRef } from "react"; -import { useRouter } from "next/navigation"; -import { Card, Button, Toggle, Input } from "@/shared/components"; -import Modal, { ConfirmModal } from "@/shared/components/Modal"; -import LanguageSwitcher from "@/shared/components/LanguageSwitcher"; -import { useTheme } from "@/shared/hooks/useTheme"; -import { cn } from "@/shared/utils/cn"; -import { APP_CONFIG } from "@/shared/constants/config"; -import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; -import { LOCALE_FLAGS } from "@/shared/constants/locales"; - -function getLocaleFromCookie() { - if (typeof document === "undefined") return "en"; - const cookie = document.cookie - .split(";") - .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); - const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; - return normalizeLocale(value); -} - -export default function ProfileClient({ initialSettings }) { - const router = useRouter(); - const { theme, setTheme, isDark } = useTheme(); - const [locale, setLocale] = useState(getLocaleFromCookie()); - const [langOpen, setLangOpen] = useState(false); - const [shutdownOpen, setShutdownOpen] = useState(false); - const [isShuttingDown, setIsShuttingDown] = useState(false); - const [settings, setSettings] = useState(initialSettings || { fallbackStrategy: "fill-first" }); - const [loading, setLoading] = useState(false); - const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); - const [passStatus, setPassStatus] = useState({ type: "", message: "" }); - const [passLoading, setPassLoading] = useState(false); - const [dbLoading, setDbLoading] = useState(false); - const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); - const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); - const pendingImportRef = useRef(null); - const [oidcForm, setOidcForm] = useState(() => ({ - authMode: initialSettings?.authMode || "password", - oidcIssuerUrl: initialSettings?.oidcIssuerUrl || "", - oidcClientId: initialSettings?.oidcClientId || "", - oidcScopes: initialSettings?.oidcScopes || "openid profile email", - oidcLoginLabel: initialSettings?.oidcLoginLabel || "Sign in with OIDC", - })); - const [oidcClientSecret, setOidcClientSecret] = useState(""); - const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); - const [oidcLoading, setOidcLoading] = useState(false); - const [oidcTestLoading, setOidcTestLoading] = useState(false); - const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" }); - const [oidcRedirectUri, setOidcRedirectUri] = useState(() => { - if (typeof window !== "undefined") return `${window.location.origin}/api/auth/oidc/callback`; - return "/api/auth/oidc/callback"; - }); - const [oidcExpanded, setOidcExpanded] = useState( - initialSettings?.authMode === "oidc" || initialSettings?.authMode === "both" - ); - const importFileRef = useRef(null); - const [proxyForm, setProxyForm] = useState(() => ({ - outboundProxyEnabled: initialSettings?.outboundProxyEnabled === true, - outboundProxyUrl: initialSettings?.outboundProxyUrl || "", - outboundNoProxy: initialSettings?.outboundNoProxy || "", - })); - const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); - const [proxyLoading, setProxyLoading] = useState(false); - const [proxyTestLoading, setProxyTestLoading] = useState(false); - - const updateOutboundProxy = async (e) => { - e.preventDefault(); - if (settings.outboundProxyEnabled !== true) return; - setProxyLoading(true); - setProxyStatus({ type: "", message: "" }); - - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - outboundProxyUrl: proxyForm.outboundProxyUrl, - outboundNoProxy: proxyForm.outboundNoProxy, - }), - }); - - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setProxyStatus({ type: "success", message: "Proxy settings applied" }); - } else { - setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyLoading(false); - } - }; - - const testOutboundProxy = async () => { - if (settings.outboundProxyEnabled !== true) return; - - const proxyUrl = (proxyForm.outboundProxyUrl || "").trim(); - if (!proxyUrl) { - setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" }); - return; - } - - setProxyTestLoading(true); - setProxyStatus({ type: "", message: "" }); - - try { - const res = await fetch("/api/settings/proxy-test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxyUrl }), - }); - - const data = await res.json(); - if (res.ok && data?.ok) { - setProxyStatus({ - type: "success", - message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`, - }); - } else { - setProxyStatus({ - type: "error", - message: data?.error || "Proxy test failed", - }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyTestLoading(false); - } - }; - - const updateOutboundProxyEnabled = async (outboundProxyEnabled) => { - setProxyLoading(true); - setProxyStatus({ type: "", message: "" }); - - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ outboundProxyEnabled }), - }); - - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true })); - setProxyStatus({ - type: "success", - message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled", - }); - } else { - setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyLoading(false); - } - }; - - const handlePasswordChange = async (e) => { - e.preventDefault(); - if (passwords.new !== passwords.confirm) { - setPassStatus({ type: "error", message: "Passwords do not match" }); - return; - } - - setPassLoading(true); - setPassStatus({ type: "", message: "" }); - - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword: passwords.current, - newPassword: passwords.new, - }), - }); - - const data = await res.json(); - - if (res.ok) { - setPassStatus({ type: "success", message: "Password updated successfully" }); - setPasswords({ current: "", new: "", confirm: "" }); - } else { - setPassStatus({ type: "error", message: data.error || "Failed to update password" }); - } - } catch (err) { - setPassStatus({ type: "error", message: "An error occurred" }); - } finally { - setPassLoading(false); - } - }; - - const updateFallbackStrategy = async (strategy) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fallbackStrategy: strategy }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, fallbackStrategy: strategy })); - } - } catch (err) { - console.error("Failed to update settings:", err); - } - }; - - const updateComboStrategy = async (strategy) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStrategy: strategy }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, comboStrategy: strategy })); - } - } catch (err) { - console.error("Failed to update combo strategy:", err); - } - }; - - const updateStickyLimit = async (limit) => { - const numLimit = parseInt(limit); - if (isNaN(numLimit) || numLimit < 1) return; - - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, stickyRoundRobinLimit: numLimit })); - } - } catch (err) { - console.error("Failed to update sticky limit:", err); - } - }; - - const updateComboStickyLimit = async (limit) => { - const numLimit = parseInt(limit); - if (isNaN(numLimit) || numLimit < 1) return; - - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, comboStickyRoundRobinLimit: numLimit })); - } - } catch (err) { - console.error("Failed to update combo sticky limit:", err); - } - }; - - const updateRequireLogin = async (requireLogin) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requireLogin }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, requireLogin })); - } - } catch (err) { - console.error("Failed to update require login:", err); - } - }; - - const updateOidcForm = (field, value) => { - setOidcForm((prev) => ({ ...prev, [field]: value })); - }; - - const saveOidcSettings = async (authMode = oidcForm.authMode || "password") => { - const issuerUrl = oidcForm.oidcIssuerUrl.trim(); - const clientId = oidcForm.oidcClientId.trim(); - const scopes = oidcForm.oidcScopes.trim(); - const loginLabel = oidcForm.oidcLoginLabel.trim(); - const secret = oidcClientSecret.trim(); - - if (authMode !== "password" && (!issuerUrl || !clientId || !secret) && !settings.oidcConfigured) { - setOidcStatus({ type: "error", message: "Issuer URL, client ID, and client secret are required to enable OIDC." }); - return; - } - - setOidcLoading(true); - setOidcStatus({ type: "", message: "" }); - setOidcTestStatus({ type: "", message: "" }); - - try { - const payload = { - authMode, - oidcIssuerUrl: issuerUrl, - oidcClientId: clientId, - oidcScopes: scopes || "openid profile email", - oidcLoginLabel: loginLabel || "Sign in with OIDC", - }; - if (secret) { - payload.oidcClientSecret = secret; - } - - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setOidcForm({ - authMode: data?.authMode || authMode, - oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl, - oidcClientId: data?.oidcClientId || clientId, - oidcScopes: data?.oidcScopes || scopes || "openid profile email", - oidcLoginLabel: data?.oidcLoginLabel || loginLabel || "Sign in with OIDC", - }); - setOidcClientSecret(""); - setOidcStatus({ - type: "success", - message: - authMode === "oidc" - ? "OIDC login enabled" - : authMode === "both" - ? "Password and OIDC login enabled" - : "OIDC settings saved", - }); - } else { - setOidcStatus({ type: "error", message: data.error || "Failed to save OIDC settings" }); - } - } catch (err) { - setOidcStatus({ type: "error", message: "An error occurred" }); - } finally { - setOidcLoading(false); - } - }; - - const testOidcConnection = async () => { - const issuerUrl = oidcForm.oidcIssuerUrl.trim(); - const clientId = oidcForm.oidcClientId.trim(); - const scopes = oidcForm.oidcScopes.trim(); - const secret = oidcClientSecret.trim(); - - if (!issuerUrl || !clientId) { - setOidcTestStatus({ type: "error", message: "Issuer URL and client ID are required to test the connection." }); - return; - } - - setOidcTestLoading(true); - setOidcStatus({ type: "", message: "" }); - setOidcTestStatus({ type: "", message: "" }); - - try { - const saveRes = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - authMode: oidcForm.authMode || settings.authMode || "password", - oidcIssuerUrl: issuerUrl, - oidcClientId: clientId, - oidcScopes: scopes || "openid profile email", - oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC", - ...(secret ? { oidcClientSecret: secret } : {}), - }), - }); - - const saved = await saveRes.json().catch(() => ({})); - if (!saveRes.ok) { - setOidcTestStatus({ - type: "error", - message: saved.error || "Failed to save OIDC settings before testing", - }); - return; - } - - const res = await fetch("/api/auth/oidc/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - issuerUrl: saved.oidcIssuerUrl || issuerUrl, - clientId: saved.oidcClientId || clientId, - scopes: saved.oidcScopes || scopes || "openid profile email", - }), - }); - - const data = await res.json().catch(() => ({})); - if (res.ok && data?.ok) { - const statusMessage = data.clientSecretTested - ? data.clientSecretValid === true - ? `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret validated too.` - : `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret was not checked.` - : `Connection OK. Discovery loaded from ${data.issuerUrl}.`; - setOidcTestStatus({ - type: "success", - message: statusMessage, - }); - } else { - setOidcTestStatus({ type: "error", message: data.error || "OIDC connection test failed" }); - } - } catch (err) { - setOidcTestStatus({ type: "error", message: "An error occurred" }); - } finally { - setOidcTestLoading(false); - } - }; - - const updateObservabilityEnabled = async (enabled) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enableObservability: enabled }), - }); - if (res.ok) { - setSettings(prev => ({ ...prev, enableObservability: enabled })); - } - } catch (err) { - console.error("Failed to update enableObservability:", err); - } - }; - - const reloadSettings = async () => { - try { - const res = await fetch("/api/settings"); - if (!res.ok) return; - const data = await res.json(); - setSettings(data); - } catch (err) { - console.error("Failed to reload settings:", err); - } - }; - - const handleExportDatabase = async (password) => { - setDbLoading(true); - setDbStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings/database", { - headers: { "x-9r-password": password }, - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "Failed to export database"); - } - - const payload = await res.json(); - const content = JSON.stringify(payload, null, 2); - const blob = new Blob([content], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - const stamp = new Date().toISOString().replace(/[.:]/g, "-"); - anchor.href = url; - anchor.download = `VansRoute-backup-${stamp}.json`; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); - - setDbStatus({ type: "success", message: "Database backup downloaded" }); - } catch (err) { - setDbStatus({ type: "error", message: err.message || "Failed to export database" }); - } finally { - setDbLoading(false); - } - }; - - const handleImportDatabase = (event) => { - const file = event.target.files?.[0]; - if (importFileRef.current) importFileRef.current.value = ""; - if (!file) return; - pendingImportRef.current = file; - setDbStatus({ type: "", message: "" }); - setDbAuth({ open: true, mode: "import", password: "" }); - }; - - const runImportDatabase = async (password) => { - const file = pendingImportRef.current; - if (!file) return; - setDbLoading(true); - try { - const raw = await file.text(); - const payload = JSON.parse(raw); - - const res = await fetch("/api/settings/database", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...payload, password }), - }); - - const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.error || "Failed to import database"); - } - - await reloadSettings(); - setDbStatus({ type: "success", message: "Database imported successfully" }); - } catch (err) { - setDbStatus({ type: "error", message: err.message || "Invalid backup file" }); - } finally { - pendingImportRef.current = null; - setDbLoading(false); - } - }; - - // Confirm password modal, then run export or import. - const handleDbAuthConfirm = async () => { - const { mode, password } = dbAuth; - setDbAuth({ open: false, mode: "", password: "" }); - if (mode === "export") await handleExportDatabase(password); - else if (mode === "import") await runImportDatabase(password); - }; - - const observabilityEnabled = settings.enableObservability === true; - - const handleShutdown = async () => { - setIsShuttingDown(true); - try { - await fetch("/api/version/shutdown", { method: "POST" }); - } catch (e) { - // Expected to fail as server shuts down; ignore error - } - setIsShuttingDown(false); - setShutdownOpen(false); - }; - - const handleLogout = async () => { - try { - const res = await fetch("/api/auth/logout", { method: "POST" }); - if (res.ok) { - router.push("/login"); - router.refresh(); - } - } catch (err) { - console.error("Failed to logout:", err); - } - }; - - return ( -
-
- {/* Local Mode Info */} - -
-
-
- computer -
-
-

Local Mode

-

Running on your machine

-
-
-
- {["light", "dark", "system"].map((option) => ( - - ))} -
-
-
-
-
-

Database Location

-

~/.9router/db/data.sqlite

-
-
-
- - - -
- {dbStatus.message && ( -

- {dbStatus.message} -

- )} -
-
- - {/* Language */} - -
-
- language -
-

Language

-
- -
- - {/* Security */} - -
-
- shield -
-

Security

-
-
-
-
-

Require login

-

- When ON, dashboard requires password. When OFF, access without login. -

-
- updateRequireLogin(!settings.requireLogin)} - disabled={loading} - /> -
- {settings.requireLogin === true && ( -
- {settings.hasPassword && ( -
- - setPasswords({ ...passwords, current: e.target.value })} - required - /> -
- )} - {/* {!settings.hasPassword && ( -
-

- Setting password for the first time. Leave current password empty or use default: 123456 -

-
- )} */} -
-
- - setPasswords({ ...passwords, new: e.target.value })} - required - /> -
-
- - setPasswords({ ...passwords, confirm: e.target.value })} - required - /> -
-
- - {passStatus.message && ( -

- {passStatus.message} -

- )} - -
- -
-
- )} -
-
- - {/* OIDC */} - - - {oidcExpanded && ( -
-

- 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. -

- -
- Auth Mode -
- {[ - { - value: "password", - title: "Password only", - desc: "Keep the legacy password login.", - }, - { - value: "oidc", - title: "OIDC only", - desc: "Require OIDC for dashboard access.", - }, - { - value: "both", - title: "Both", - desc: "Allow either password or OIDC.", - }, - ].map((option) => { - const active = oidcForm.authMode === option.value; - return ( - - ); - })} -
-
- -
-
- - updateOidcForm("oidcIssuerUrl", e.target.value)} - disabled={loading || oidcLoading} - /> -
- -
- - updateOidcForm("oidcClientId", e.target.value)} - disabled={loading || oidcLoading} - /> -
- -
- - setOidcClientSecret(e.target.value)} - disabled={loading || oidcLoading} - /> -

This value is write-only after saving.

-
- -
- - updateOidcForm("oidcScopes", e.target.value)} - disabled={loading || oidcLoading} - /> -
- -
- - updateOidcForm("oidcLoginLabel", e.target.value)} - disabled={loading || oidcLoading} - /> -
-
- -
-

Redirect URI

- {oidcRedirectUri} -
- -
- - -
- - {oidcTestStatus.message && ( -

- {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. -

- )} -
- )} -
- - {/* Routing Preferences */} - -
-
- route -
-

Routing Strategy

-
-
-
-
-

Round Robin

-

- Cycle through accounts to distribute load -

-
- updateFallbackStrategy(settings.fallbackStrategy === "round-robin" ? "fill-first" : "round-robin")} - disabled={loading} - /> -
- - {/* Sticky Round Robin Limit */} - {settings.fallbackStrategy === "round-robin" && ( -
-
-

Sticky Limit

-

- Calls per account before switching -

-
- updateStickyLimit(e.target.value)} - disabled={loading} - className="w-16 sm:w-20 text-center shrink-0" - /> -
- )} - - {/* Combo Round Robin */} -
-
-

Combo Round Robin

-

- Cycle through providers in combos instead of always starting with first -

-
- updateComboStrategy(settings.comboStrategy === "round-robin" ? "fallback" : "round-robin")} - disabled={loading} - /> -
- - {/* Combo Sticky Round Robin Limit */} - {settings.comboStrategy === "round-robin" && ( -
-
-

Combo Sticky Limit

-

- Calls per combo model before switching -

-
- updateComboStickyLimit(e.target.value)} - disabled={loading} - className="w-20 text-center" - /> -
- )} - -

- {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."} -

-
-
- - {/* Network */} - -
-
- wifi -
-

Network

-
- -
-
-
-

Outbound Proxy

-

Enable proxy for OAuth + provider outbound requests.

-
- updateOutboundProxyEnabled(!(settings.outboundProxyEnabled === true))} - disabled={loading || proxyLoading} - /> -
- - {settings.outboundProxyEnabled === true && ( -
-
- - setProxyForm((prev) => ({ ...prev, outboundProxyUrl: e.target.value }))} - disabled={loading || proxyLoading} - /> -

Leave empty to inherit existing env proxy (if any).

-
- -
- - setProxyForm((prev) => ({ ...prev, outboundNoProxy: e.target.value }))} - disabled={loading || proxyLoading} - /> -

Comma-separated hostnames/domains to bypass the proxy.

-
- -
- - -
-
- )} - - {proxyStatus.message && ( -

- {proxyStatus.message} -

- )} -
-
- - {/* Observability Settings */} - -
-
- monitoring -
-

Observability

-
-
-
-

Enable Observability

-

- Record request details for inspection in the logs view -

-
- -
-
- - {/* Account actions */} -
- - -
- - {/* App Info */} -
-

{APP_CONFIG.name} v{APP_CONFIG.version}

-

Local Mode - All data stored on your machine

-
-
- - { - setLangOpen(false); - setLocale(next); - }} - /> - setShutdownOpen(false)} - onConfirm={handleShutdown} - title="Close Proxy" - message="Are you sure you want to close the proxy server?" - confirmText="Close" - cancelText="Cancel" - variant="danger" - loading={isShuttingDown} - /> - - setDbAuth({ open: false, mode: "", password: "" })} - title="Confirm Password" - size="sm" - footer={ - <> - - - - } - > -

- 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 - /> -
-
- ); -} diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 2d0ccf2076..55d60cc716 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -20,13 +20,16 @@ function getLocaleFromCookie() { } export default function ProfilePage() { - const { theme, setTheme, isDark } = useTheme(); + const { theme, setTheme } = useTheme(); const [locale, setLocale] = useState("en"); const [langOpen, setLangOpen] = useState(false); const [shutdownOpen, setShutdownOpen] = useState(false); const [isShuttingDown, setIsShuttingDown] = useState(false); const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); const [loading, setLoading] = useState(true); + const [systemPromptDraft, setSystemPromptDraft] = useState(""); + const [systemPromptSaving, setSystemPromptSaving] = useState(false); + const [systemPromptStatus, setSystemPromptStatus] = useState({ type: "", message: "" }); const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); const [passStatus, setPassStatus] = useState({ type: "", message: "" }); const [passLoading, setPassLoading] = useState(false); @@ -70,6 +73,7 @@ export default function ProfilePage() { .then((res) => res.json()) .then((data) => { setSettings(data); + setSystemPromptDraft(data?.systemPrompt || ""); setOidcForm({ authMode: data?.authMode || "password", oidcIssuerUrl: data?.oidcIssuerUrl || "", @@ -1081,6 +1085,66 @@ export default function ProfilePage() { + {/* System Prompt */} + +
+
+ smart_toy +
+

Default System Prompt

+
+
+

+ Optional. This text is prepended to the system message of every chat request + that does not already carry one. Leave empty to disable. +

+