From 4cbbe1e75e26e3695f711332f53605ac6989bec4 Mon Sep 17 00:00:00 2001 From: elan40213-eng Date: Sat, 25 Jul 2026 12:59:03 +0000 Subject: [PATCH 01/15] fix: alias/systemPrompt delete, disabled models, alias bare names, projectId persistence, DuckDuckGo browser profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ModelRow: empty draft on Save now deletes the alias instead of silently no-op - allowedModels: isModelAllowed enforces global list even without apiKeyInfo; appendUserAliasEntries surfaces bare alias names; cache invalidated on alias/disabled mutations - v1/models: alias entries pass through with owned_by=alias - chat.js + chatCore + stream/nonStreaming: echo client-facing model id (alias) back in /v1/chat/completions response.model and SSE chunks - projectId: cache TTL 30d bound to Google account not OAuth token; skip re-fetch when DB already has projectId (seedProjectIdCache) - settingsRepo + systemInject + chatCore: injectDefaultSystemPrompt only when request has no system message yet - DuckDuckGo: undici Agent (HTTP/2) + stable Chrome 131 header set (Sec-CH-UA client hints, full Accept)、 replacing the bot UA - registry/index.js: register duckduckgo provider - duckduckgo.png icon --- next.config.mjs | 40 +- open-sse/executors/gemini-cli.js | 328 +++-- open-sse/handlers/chatCore.js | 13 +- .../handlers/chatCore/nonStreamingHandler.js | 33 +- .../handlers/chatCore/sseToJsonHandler.js | 13 +- .../handlers/chatCore/streamingHandler.js | 12 +- open-sse/handlers/chatCore/webSearchInject.js | 125 ++ .../handlers/search/adapters/duckduckgo.js | 162 +++ open-sse/handlers/search/index.js | 53 + open-sse/providers/capabilities.js | 12 +- open-sse/providers/registry/alicode-intl.js | 2 +- open-sse/providers/registry/duckduckgo.js | 30 + open-sse/providers/registry/gemini-cli.js | 4 +- open-sse/providers/registry/index.js | 4 +- open-sse/providers/thinkingLevels.js | 4 + open-sse/rtk/systemInject.js | 55 + open-sse/services/projectId.js | 32 +- .../translator/request/openai-to-gemini.js | 39 +- open-sse/utils/stream.js | 29 +- package.json | 4 +- pnpm-lock.yaml | 46 +- postcss.config.mjs | 21 +- public/i18n/literals/ru.json | 406 +++--- public/providers/duckduckgo.png | Bin 0 -> 50088 bytes public/providers/duckduckgo.webp | Bin 0 -> 4776 bytes scripts/dev-prewarm.mjs | 91 ++ .../dashboard/endpoint/EndpointPageClient.js | 103 +- .../media-providers/[kind]/MediaKindClient.js | 21 +- .../dashboard/profile/ProfileClient.js | 1156 ----------------- src/app/(dashboard)/dashboard/profile/page.js | 53 + .../dashboard/providers/[id]/ModelRow.js | 141 +- src/app/api/combos/[id]/route.js | 16 +- src/app/api/combos/route.js | 20 +- src/app/api/headroom/status/route.js | 12 +- src/app/api/keys/[id]/route.js | 5 +- src/app/api/models/alias/route.js | 26 +- src/app/api/models/disabled/route.js | 3 + src/app/api/provider-nodes/route.js | 18 +- .../api/providers/kilo/free-models/route.js | 6 +- .../translator/console-logs/stream/route.js | 5 +- src/app/api/v1/models/route.js | 5 + src/app/masuk/MasukClient.js | 39 +- .../005-add-allowed-models-column.js | 13 + .../migrations/006-add-combos-alias-column.js | 15 + src/lib/db/migrations/index.js | 6 +- src/lib/db/repos/apiKeysRepo.js | 10 +- src/lib/db/repos/combosRepo.js | 16 +- src/lib/db/repos/settingsRepo.js | 1 + src/lib/headroom/detect.js | 59 +- src/lib/localDb.js | 2 +- src/lib/network/connectionProxy.js | 40 + src/sse/handlers/chat.js | 29 +- src/sse/services/allowedModels.js | 28 +- src/sse/services/tokenRefresh.js | 13 + tests/__baseline__/providers-baseline.json | 2 +- 55 files changed, 1806 insertions(+), 1615 deletions(-) create mode 100644 open-sse/handlers/chatCore/webSearchInject.js create mode 100644 open-sse/handlers/search/adapters/duckduckgo.js create mode 100644 open-sse/providers/registry/duckduckgo.js create mode 100644 public/providers/duckduckgo.png create mode 100644 public/providers/duckduckgo.webp create mode 100644 scripts/dev-prewarm.mjs delete mode 100644 src/app/(dashboard)/dashboard/profile/ProfileClient.js create mode 100644 src/lib/db/migrations/005-add-allowed-models-column.js create mode 100644 src/lib/db/migrations/006-add-combos-alias-column.js diff --git a/next.config.mjs b/next.config.mjs index 58fabbcbf3..472813677b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -14,6 +14,10 @@ const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE const nextConfig = { distDir: process.env.NEXT_DIST_DIR || ".next", output: "standalone", + // Dev-only: allow HMR / static chunks when the browser hits 127.0.0.1 + // while the server is bound to 0.0.0.0 (or vice versa). Without this, + // Next 16 blocks cross-origin /_next/* and the UI freezes after login. + allowedDevOrigins: ["127.0.0.1", "localhost"], serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite", "dompurify", "chalk"], turbopack: { root: tracingRoot @@ -36,7 +40,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 +120,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/gemini-cli.js b/open-sse/executors/gemini-cli.js index aa15c70fa3..c6215f4aed 100644 --- a/open-sse/executors/gemini-cli.js +++ b/open-sse/executors/gemini-cli.js @@ -1,99 +1,231 @@ -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; - +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; +async function resolvePrivilegedUserId() { + if (!PRIVILEGED_USER_ID_ENABLED) return null; + if (_privilegedUserIdCache) return _privilegedUserIdCache; + try { + _privilegedUserIdCache = await getConsistentMachineId("gemini-cli-privileged-user-id"); + } catch { + _privilegedUserIdCache = null; + } + return _privilegedUserIdCache; +} + +// 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; + } + + transformRequest(model, body, stream, credentials) { + // Store model for use in buildHeaders (called by base.execute after transformRequest) + this._currentModel = model; + // Kick off privileged-user-id resolution in the background (no await). By the + // second request the cache is warm; the first request simply skips the header. + if (PRIVILEGED_USER_ID_ENABLED && _privilegedUserIdCache === null) { + resolvePrivilegedUserId().then(v => { _privilegedUserIdCache = v ?? ""; }).catch(() => {}); + } + // Cloud Code Assist wraps the Gemini payload: { project, model, request: } + const isEnvelope = body && body.request && body.model; + if (isEnvelope) { + // Gemini 3 rejects replayed turns where a functionCall part has no thoughtSignature, + // and rejects thought-only parts (thought=true with no text/functionCall). Clients + // (Claude Code, IDE) don't persist thoughtSignature in their history, so we scrub + // thought-only parts and backfill the canonical signature on bare functionCall parts. + // Also normalize `parameters` → `parametersJsonSchema` (translator already does this for + // OpenAI→CLI, but native/passthrough bodies may still carry the legacy field). + const request = body.request; + if (Array.isArray(request.contents)) { + for (const turn of request.contents) { + if (!Array.isArray(turn.parts)) continue; + // Drop thought-only parts: assistant reasoning echo can't be replayed to Cloud Code + // and triggers a 400 with "thought part without matching functionCall". + turn.parts = turn.parts.filter(p => !(p?.thought === true && !p.text && !p.functionCall)); + } + // Backfill thoughtSignature on functionCall parts missing it (Gemini 3 requirement). + 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; + } + } + } + } + // chatCore may re-inject OpenAI-only fields (reasoning_effort, thinking, …) + // onto the translated envelope. Cloud Code rejects unknown top-level keys. + stripOpenAILeakFields(body); + if (body.request && typeof body.request === "object") { + stripOpenAILeakFields(body.request); + // user_prompt_id is not a v1internal request field — drop if present. + delete body.request.user_prompt_id; + } + applyIncludeReasoningToGemini(body); + delete body.include_reasoning; + 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; - } - } -} - + stripOpenAILeakFields(body); + applyIncludeReasoningToGemini(body); + delete body.include_reasoning; + 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; + } + } +} + 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/chatCore/webSearchInject.js b/open-sse/handlers/chatCore/webSearchInject.js new file mode 100644 index 0000000000..606c41445e --- /dev/null +++ b/open-sse/handlers/chatCore/webSearchInject.js @@ -0,0 +1,125 @@ +/** + * When web search is enabled for a chat request: + * 1) Append an OpenAI-style web_search function tool (for agentic clients) + * 2) Optionally pre-fetch DuckDuckGo results for the latest user message and + * inject them as a system note so streaming chat still gets live context + * without a multi-turn tool loop. + */ + +import { searchDuckDuckGo } from "../search/adapters/duckduckgo.js"; + +export const WEB_SEARCH_TOOL = { + type: "function", + function: { + name: "web_search", + description: "Search the public web for current information. Returns titles, URLs, and snippets.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + max_results: { type: "integer", minimum: 1, maximum: 10 }, + }, + required: ["query"], + }, + }, +}; + +function lastUserText(messages) { + if (!Array.isArray(messages)) return ""; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.role !== "user") continue; + const c = m.content; + if (typeof c === "string") return c.trim(); + if (Array.isArray(c)) { + return c + .map((p) => (typeof p === "string" ? p : p?.text || p?.content || "")) + .filter(Boolean) + .join(" ") + .trim(); + } + } + return ""; +} + +function formatResults(results) { + if (!results?.length) return "No web results found."; + return results + .map((r, i) => `${i + 1}. ${r.title || r.url}\n ${r.url}\n ${r.snippet || ""}`) + .join("\n\n"); +} + +/** + * @param {object} body - OpenAI-style request body (mutated) + * @param {{ enabled?: boolean, maxResults?: number, preFetch?: boolean, log?: object }} opts + * @returns {Promise<{ injectedTool: boolean, prefetched: boolean, resultCount: number }>} + */ +export async function applyWebSearchToBody(body, opts = {}) { + const enabled = !!opts.enabled; + const summary = { injectedTool: false, prefetched: false, resultCount: 0 }; + if (!enabled || !body || typeof body !== "object") return summary; + + // Strip the flag itself so upstream providers that don't recognize it + // (e.g. NVIDIA NIM) don't reject it with 400 "Unsupported parameter". + delete body.web_search; + delete body.webSearch; + if (body.extra_body) delete body.extra_body.web_search; + + // Do NOT inject the web_search function tool: models that see it in the tools + // list tend to call it instead of using the prefetched results, which creates a + // tool_call response the proxy can't execute (no server-side tool loop yet). + // Prefetching results into the system prompt is sufficient for v1. + + // Prefetch context so streaming completions still see live web data. + if (opts.preFetch !== false) { + const query = lastUserText(body.messages); + if (query) { + try { + const { results } = await searchDuckDuckGo({ + query: query.slice(0, 400), + maxResults: opts.maxResults || 5, + }); + summary.resultCount = results.length; + summary.prefetched = true; + const block = `[Web search results for: ${query.slice(0, 120)}]\n\n${formatResults(results)}\n\nUse these results when relevant. Cite URLs when possible.`; + const messages = Array.isArray(body.messages) ? [...body.messages] : []; + messages.unshift({ role: "system", content: block }); + body.messages = messages; + opts.log?.info?.("WEBSEARCH", `prefetched ${results.length} results for "${query.slice(0, 60)}"`); + } catch (err) { + opts.log?.warn?.("WEBSEARCH", `prefetch failed: ${err?.message || err}`); + } + } + } + + return summary; +} + +/** Parse request-level web search flag from body + headers + settings. + * provider arg (optional) restricts default-on to Gemini CLI / Vertex (mirrors Kick). */ +export function resolveWebSearchEnabled(body, request, settings = {}, provider = null) { + if (body?.web_search === true || body?.webSearch === true) return true; + if (body?.web_search === false || body?.webSearch === false) return false; + if (body?.extra_body?.web_search === true) return true; + const header = request?.headers?.get?.("x-web-search") || request?.headers?.get?.("X-Web-Search"); + if (header === "1" || header === "true" || header === "yes") return true; + if (header === "0" || header === "false") return false; + // Default-on only for Gemini CLI / Vertex (built-in googleSearch grounding). + if (!settings.webSearchEnabled) return false; + if (provider && provider !== "gemini-cli" && provider !== "vertex") return false; + // Kick gating: only when no tools declared and no controlled (JSON) generation. + if (requestDeclaresTools(body) || requestUsesControlledGeneration(body)) return false; + return true; +} + +function requestDeclaresTools(body) { + const t = body?.tools; + return Array.isArray(t) && t.length > 0; +} + +function requestUsesControlledGeneration(body) { + const rf = body?.response_format; + const t = rf && typeof rf === "object" ? rf.type : ""; + if (t === "json_schema" || t === "json_object") return true; + return false; +} 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..6bc96a09c6 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" || provider.id === "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..2689061d37 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,15 @@ "@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", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@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", 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/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 0000000000000000000000000000000000000000..69527886a42dec8c369b859155e43dcf5ae7e064 GIT binary patch literal 50088 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%0A0Ku-ZY}Q#)Fw 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

3_9){;hgYtKV9Y33a+M@XYf`%)~`F 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

DKefIi0gZDq^Fdo4OUP*3MZnPHuJfM2nV z*F)<1L@!>ra>-;E3DaLA;5$*d9{smO-~7o|X0$Z)<@nZ!tKk2(U_ee(yMoY=VCS;g z$1CAIM9%eOtCXTrDGq|FTWf2n{v_)6%_qWes{X4)6dlY8z-v=iSmB|TJ{$vB7{TE$ zz=A-*4_Hv(u-mZE;@}4?IB+=Zuy6u6tcQgL4L@K(!?*t{|62*5LH{=r|2roCAIr%q Z6(JW`Kcs<-T0WfYt6Bz^3pH$?{2wYK4OsvH literal 0 HcmV?d00001 diff --git a/public/providers/duckduckgo.webp b/public/providers/duckduckgo.webp new file mode 100644 index 0000000000000000000000000000000000000000..d13beb4c67ceb38604e763d3bddd431b239ac0f0 GIT binary patch literal 4776 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 C2TP#< literal 0 HcmV?d00001 diff --git a/scripts/dev-prewarm.mjs b/scripts/dev-prewarm.mjs new file mode 100644 index 0000000000..29aeb2598e --- /dev/null +++ b/scripts/dev-prewarm.mjs @@ -0,0 +1,91 @@ +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 || "123456"; +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/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 76125dcca6..18c58ef1ed 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 : (Array.isArray(data) ? data : []); + 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,12 +1580,12 @@ export default function APIPageClient({ machineId }) {

Paused

)} {/* ACL badges */} - {(key.allowedProviders || key.allowedCombos || key.allowedKinds) && ( + {(key.allowedProviders || key.allowedCombos || key.allowedKinds || key.allowedModels) && (
{key.allowedProviders && ( {key.allowedProviders.length === 0 - ? "No providers" + ? "no providers" : key.allowedProviders.map((stored) => { // Try providerList first (has connections) const p = providerList.find((pp) => pp.id === stored || pp.alias === stored || pp.prefix === stored); @@ -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/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..da077a4fe1 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -27,6 +27,8 @@ export default function ProfilePage() { 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 [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); const [passStatus, setPassStatus] = useState({ type: "", message: "" }); const [passLoading, setPassLoading] = useState(false); @@ -70,6 +72,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 +1084,56 @@ 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. +

+