From c3da148fa0fb2d5e7c6ff11ab79d1bf1c36de916 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 19 Feb 2026 16:33:43 -0500 Subject: [PATCH 001/110] =?UTF-8?q?=F0=9F=93=9D=20docs:=20Add=20AGENTS.md?= =?UTF-8?q?=20for=20Project=20Structure=20and=20Coding=20Standards=20(#118?= =?UTF-8?q?66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📝 docs: Add AGENTS.md for project structure and coding standards - Introduced AGENTS.md to outline project workspaces, coding standards, and development commands. - Defined workspace boundaries for backend and frontend development, emphasizing TypeScript usage. - Established guidelines for code style, iteration performance, type safety, and import order. - Updated CONTRIBUTING.md to reference AGENTS.md for coding standards and project conventions. - Modified package.json to streamline build commands, consolidating frontend and backend build processes. * chore: Update build commands and improve smart reinstall process - Modified AGENTS.md to clarify the purpose of `npm run smart-reinstall` and other build commands, emphasizing Turborepo's role in dependency management and builds. - Updated package.json to streamline build commands, replacing the legacy frontend build with a Turborepo-based approach for improved performance. - Enhanced the smart reinstall script to fully delegate build processes to Turborepo, including cache management and dependency checks, ensuring a more efficient build workflow. --- .github/CONTRIBUTING.md | 79 +++++++++---------- AGENTS.md | 158 ++++++++++++++++++++++++++++++++++++++ config/smart-reinstall.js | 100 ++++++------------------ package.json | 1 + 4 files changed, 217 insertions(+), 121 deletions(-) create mode 100644 AGENTS.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ad0a75ab9b..ae9e6d8e4b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -26,18 +26,14 @@ Project maintainers have the right and responsibility to remove, edit, or reject ## 1. Development Setup -1. Use Node.JS 20.x. -2. Install typescript globally: `npm i -g typescript`. -3. Run `npm ci` to install dependencies. -4. Build the data provider: `npm run build:data-provider`. -5. Build data schemas: `npm run build:data-schemas`. -6. Build API methods: `npm run build:api`. -7. Setup and run unit tests: +1. Use Node.js v20.19.0+ or ^22.12.0 or >= 23.0.0. +2. Run `npm run smart-reinstall` to install dependencies (uses Turborepo). Use `npm run reinstall` for a clean install, or `npm ci` for a fresh lockfile-based install. +3. Build all compiled code: `npm run build`. +4. Setup and run unit tests: - Copy `.env.test`: `cp api/test/.env.test.example api/test/.env.test`. - Run backend unit tests: `npm run test:api`. - Run frontend unit tests: `npm run test:client`. -8. Setup and run integration tests: - - Build client: `cd client && npm run build`. +5. Setup and run integration tests: - Create `.env`: `cp .env.example .env`. - Install [MongoDB Community Edition](https://www.mongodb.com/docs/manual/administration/install-community/), ensure that `mongosh` connects to your local instance. - Run: `npx install playwright`, then `npx playwright install`. @@ -48,11 +44,11 @@ Project maintainers have the right and responsibility to remove, edit, or reject ## 2. Development Notes 1. Before starting work, make sure your main branch has the latest commits with `npm run update`. -3. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning. +2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning. 3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works. - Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating. 4. Clear web app localStorage and cookies before and after changes. -5. For frontend changes, compile typescript before and after changes to check for introduced errors: `cd client && npm run build`. +5. To check for introduced errors, build all compiled code: `npm run build`. 6. Run backend unit tests: `npm run test:api`. 7. Run frontend unit tests: `npm run test:client`. 8. Run integration tests: `npm run e2e`. @@ -118,50 +114,45 @@ Apply the following naming conventions to branches, labels, and other Git-relate - **JS/TS:** Directories and file names: Descriptive and camelCase. First letter uppercased for React files (e.g., `helperFunction.ts, ReactComponent.tsx`). - **Docs:** Directories and file names: Descriptive and snake_case (e.g., `config_files.md`). -## 7. TypeScript Conversion +## 7. Coding Standards + +For detailed coding conventions, workspace boundaries, and architecture guidance, refer to the [`AGENTS.md`](../AGENTS.md) file at the project root. It covers code style, type safety, import ordering, iteration/performance expectations, frontend rules, testing, and development commands. + +## 8. TypeScript Conversion 1. **Original State**: The project was initially developed entirely in JavaScript (JS). -2. **Frontend Transition**: - - We are in the process of transitioning the frontend from JS to TypeScript (TS). - - The transition is nearing completion. - - This conversion is feasible due to React's capability to intermix JS and TS prior to code compilation. It's standard practice to compile/bundle the code in such scenarios. +2. **Frontend**: Fully transitioned to TypeScript. -3. **Backend Considerations**: - - Transitioning the backend to TypeScript would be a more intricate process, especially for an established Express.js server. - - - **Options for Transition**: - - **Single Phase Overhaul**: This involves converting the entire backend to TypeScript in one go. It's the most straightforward approach but can be disruptive, especially for larger codebases. - - - **Incremental Transition**: Convert parts of the backend progressively. This can be done by: - - Maintaining a separate directory for TypeScript files. - - Gradually migrating and testing individual modules or routes. - - Using a build tool like `tsc` to compile TypeScript files independently until the entire transition is complete. - - - **Compilation Considerations**: - - Introducing a compilation step for the server is an option. This would involve using tools like `ts-node` for development and `tsc` for production builds. - - However, this is not a conventional approach for Express.js servers and could introduce added complexity, especially in terms of build and deployment processes. - - - **Current Stance**: At present, this backend transition is of lower priority and might not be pursued. +3. **Backend**: + - The legacy Express.js server remains in `/api` as JavaScript. + - All new backend code is written in TypeScript under `/packages/api`, which is compiled and consumed by `/api`. + - Shared database logic lives in `/packages/data-schemas` (TypeScript). + - Shared frontend/backend API types and services live in `/packages/data-provider` (TypeScript). + - Minimize direct changes to `/api`; prefer adding TypeScript code to `/packages/api` and importing it. -## 8. Module Import Conventions +## 9. Module Import Conventions -- `npm` packages first, - - from longest line (top) to shortest (bottom) +Imports are organized into three sections (in order): -- Followed by typescript types (pertains to data-provider and client workspaces) - - longest line (top) to shortest (bottom) - - types from package come first +1. **Package imports** — sorted from shortest to longest line length. + - `react` is always the first import. + - Multi-line (stacked) imports count their total character length across all lines for sorting. -- Lastly, local imports - - longest line (top) to shortest (bottom) - - imports with alias `~` treated the same as relative import with respect to line length +2. **`import type` imports** — sorted from longest to shortest line length. + - Package type imports come first, then local type imports. + - Line length sorting resets between the package and local sub-groups. + +3. **Local/project imports** — sorted from longest to shortest line length. + - Multi-line (stacked) imports count their total character length across all lines for sorting. + - Imports with alias `~` are treated the same as relative imports with respect to line length. + +- Consolidate value imports from the same module as much as possible. +- Always use standalone `import type { ... }` for type imports; never use inline `type` keyword inside value imports (e.g., `import { Foo, type Bar }` is wrong). **Note:** ESLint will automatically enforce these import conventions when you run `npm run lint --fix` or through pre-commit hooks. ---- - -Please ensure that you adapt this summary to fit the specific context and nuances of your project. +For the full set of coding standards, see [`AGENTS.md`](../AGENTS.md). --- diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..23b5fc0fbb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,158 @@ +# LibreChat + +## Project Overview + +LibreChat is a monorepo with the following key workspaces: + +| Workspace | Language | Side | Dependency | Purpose | +|---|---|---|---|---| +| `/api` | JS (legacy) | Backend | `packages/api`, `packages/data-schemas`, `packages/data-provider`, `@librechat/agents` | Express server — minimize changes here | +| `/packages/api` | **TypeScript** | Backend | `packages/data-schemas`, `packages/data-provider` | New backend code lives here (TS only, consumed by `/api`) | +| `/packages/data-schemas` | TypeScript | Backend | `packages/data-provider` | Database models/schemas, shareable across backend projects | +| `/packages/data-provider` | TypeScript | Shared | — | Shared API types, endpoints, data-service — used by both frontend and backend | +| `/client` | TypeScript/React | Frontend | `packages/data-provider`, `packages/client` | Frontend SPA | +| `/packages/client` | TypeScript | Frontend | `packages/data-provider` | Shared frontend utilities | + +The source code for `@librechat/agents` (major backend dependency, same team) is at `/home/danny/agentus`. + +--- + +## Workspace Boundaries + +- **All new backend code must be TypeScript** in `/packages/api`. +- Keep `/api` changes to the absolute minimum (thin JS wrappers calling into `/packages/api`). +- Database-specific shared logic goes in `/packages/data-schemas`. +- Frontend/backend shared API logic (endpoints, types, data-service) goes in `/packages/data-provider`. +- Build data-provider from project root: `npm run build:data-provider`. + +--- + +## Code Style + +### Structure and Clarity + +- **Never-nesting**: early returns, flat code, minimal indentation. Break complex operations into well-named helpers. +- **Functional first**: pure functions, immutable data, `map`/`filter`/`reduce` over imperative loops. Only reach for OOP when it clearly improves domain modeling or state encapsulation. +- **No dynamic imports** unless absolutely necessary. + +### DRY + +- Extract repeated logic into utility functions. +- Reusable hooks / higher-order components for UI patterns. +- Parameterized helpers instead of near-duplicate functions. +- Constants for repeated values; configuration objects over duplicated init code. +- Shared validators, centralized error handling, single source of truth for business rules. +- Shared typing system with interfaces/types extending common base definitions. +- Abstraction layers for external API interactions. + +### Iteration and Performance + +- **Minimize looping** — especially over shared data structures like message arrays, which are iterated frequently throughout the codebase. Every additional pass adds up at scale. +- Consolidate sequential O(n) operations into a single pass whenever possible; never loop over the same collection twice if the work can be combined. +- Choose data structures that reduce the need to iterate (e.g., `Map`/`Set` for lookups instead of `Array.find`/`Array.includes`). +- Avoid unnecessary object creation; consider space-time tradeoffs. +- Prevent memory leaks: careful with closures, dispose resources/event listeners, no circular references. + +### Type Safety + +- **Never use `any`**. Explicit types for all parameters, return values, and variables. +- **Limit `unknown`** — avoid `unknown`, `Record`, and `as unknown as T` assertions. A `Record` almost always signals a missing explicit type definition. +- **Don't duplicate types** — before defining a new type, check whether it already exists in the project (especially `packages/data-provider`). Reuse and extend existing types rather than creating redundant definitions. +- Use union types, generics, and interfaces appropriately. +- All TypeScript and ESLint warnings/errors must be addressed — do not leave unresolved diagnostics. + +### Comments and Documentation + +- Write self-documenting code; no inline comments narrating what code does. +- JSDoc only for complex/non-obvious logic or intellisense on public APIs. +- Single-line JSDoc for brief docs, multi-line for complex cases. +- Avoid standalone `//` comments unless absolutely necessary. + +### Import Order + +Imports are organized into three sections: + +1. **Package imports** — sorted shortest to longest line length (`react` always first). +2. **`import type` imports** — sorted longest to shortest (package types first, then local types; length resets between sub-groups). +3. **Local/project imports** — sorted longest to shortest. + +Multi-line imports count total character length across all lines. Consolidate value imports from the same module. Always use standalone `import type { ... }` — never inline `type` inside value imports. + +### JS/TS Loop Preferences + +- **Limit looping as much as possible.** Prefer single-pass transformations and avoid re-iterating the same data. +- `for (let i = 0; ...)` for performance-critical or index-dependent operations. +- `for...of` for simple array iteration. +- `for...in` only for object property enumeration. + +--- + +## Frontend Rules (`client/src/**/*`) + +### Localization + +- All user-facing text must use `useLocalize()`. +- Only update English keys in `client/src/locales/en/translation.json` (other languages are automated externally). +- Semantic key prefixes: `com_ui_`, `com_assistants_`, etc. + +### Components + +- TypeScript for all React components with proper type imports. +- Semantic HTML with ARIA labels (`role`, `aria-label`) for accessibility. +- Group related components in feature directories (e.g., `SidePanel/Memories/`). +- Use index files for clean exports. + +### Data Management + +- Feature hooks: `client/src/data-provider/[Feature]/queries.ts` → `[Feature]/index.ts` → `client/src/data-provider/index.ts`. +- React Query (`@tanstack/react-query`) for all API interactions; proper query invalidation on mutations. +- QueryKeys and MutationKeys in `packages/data-provider/src/keys.ts`. + +### Data-Provider Integration + +- Endpoints: `packages/data-provider/src/api-endpoints.ts` +- Data service: `packages/data-provider/src/data-service.ts` +- Types: `packages/data-provider/src/types/queries.ts` +- Use `encodeURIComponent` for dynamic URL parameters. + +### Performance + +- Prioritize memory and speed efficiency at scale. +- Cursor pagination for large datasets. +- Proper dependency arrays to avoid unnecessary re-renders. +- Leverage React Query caching and background refetching. + +--- + +## Development Commands + +| Command | Purpose | +|---|---| +| `npm run smart-reinstall` | Install deps (if lockfile changed) + build via Turborepo | +| `npm run reinstall` | Clean install — wipe `node_modules` and reinstall from scratch | +| `npm run backend` | Start the backend server | +| `npm run backend:dev` | Start backend with file watching (development) | +| `npm run build` | Build all compiled code via Turborepo (parallel, cached) | +| `npm run frontend` | Build all compiled code sequentially (legacy fallback) | +| `npm run frontend:dev` | Start frontend dev server with HMR (port 3090, requires backend running) | +| `npm run build:data-provider` | Rebuild `packages/data-provider` after changes | + +- Node.js: v20.19.0+ or ^22.12.0 or >= 23.0.0 +- Database: MongoDB +- Backend runs on `http://localhost:3080/`; frontend dev server on `http://localhost:3090/` + +--- + +## Testing + +- Framework: **Jest**, run per-workspace. +- Run tests from their workspace directory: `cd api && npx jest `, `cd packages/api && npx jest `, etc. +- Frontend tests: `__tests__` directories alongside components; use `test/layout-test-utils` for rendering. +- Cover loading, success, and error states for UI/data flows. +- Mock data-provider hooks and external dependencies. + +--- + +## Formatting + +Fix all formatting lint errors (trailing spaces, tabs, newlines, indentation) using auto-fix when available. All TypeScript/ESLint warnings and errors **must** be resolved. diff --git a/config/smart-reinstall.js b/config/smart-reinstall.js index 18fe689127..f22bb25151 100644 --- a/config/smart-reinstall.js +++ b/config/smart-reinstall.js @@ -9,10 +9,9 @@ * Skips npm ci entirely when the lockfile hasn't changed. * * Package builds (Turborepo): - * Turbo hashes each package's source/config inputs, caches build - * outputs (dist/), and restores from cache when inputs match. - * Turbo v2 uses a global cache (~/.cache/turbo) that survives - * npm ci and is shared across worktrees. + * Turbo hashes each package's source/config inputs (including the + * lockfile), caches build outputs (dist/), and restores from cache + * when inputs match. This script delegates entirely to turbo for builds. * * Usage: * npm run smart-reinstall # Smart cached mode @@ -27,11 +26,8 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -// Adds console.green, console.purple, etc. require('./helpers'); -// ─── Configuration ─────────────────────────────────────────────────────────── - const ROOT_DIR = path.resolve(__dirname, '..'); const DEPS_HASH_MARKER = path.join(ROOT_DIR, 'node_modules', '.librechat-deps-hash'); @@ -42,7 +38,6 @@ const flags = { verbose: process.argv.includes('--verbose'), }; -// Workspace directories whose node_modules should be cleaned during reinstall const NODE_MODULES_DIRS = [ ROOT_DIR, path.join(ROOT_DIR, 'packages', 'data-provider'), @@ -53,8 +48,6 @@ const NODE_MODULES_DIRS = [ path.join(ROOT_DIR, 'api'), ]; -// ─── Helpers ───────────────────────────────────────────────────────────────── - function hashFile(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex').slice(0, 16); } @@ -63,8 +56,6 @@ function exec(cmd, opts = {}) { execSync(cmd, { cwd: ROOT_DIR, stdio: 'inherit', ...opts }); } -// ─── Dependency Installation ───────────────────────────────────────────────── - function checkDeps() { const lockfile = path.join(ROOT_DIR, 'package-lock.json'); if (!fs.existsSync(lockfile)) { @@ -97,19 +88,15 @@ function installDeps(hash) { fs.writeFileSync(DEPS_HASH_MARKER, hash, 'utf-8'); } -// ─── Turbo Build ───────────────────────────────────────────────────────────── - function runTurboBuild() { const args = ['npx', 'turbo', 'run', 'build']; if (flags.skipClient) { args.push('--filter=!@librechat/frontend'); } - if (flags.force) { args.push('--force'); } - if (flags.verbose) { args.push('--verbosity=2'); } @@ -119,76 +106,41 @@ function runTurboBuild() { exec(cmd); } -/** - * Fallback for when turbo is not installed (e.g., first run before npm ci). - * Runs the same sequential build as the original `npm run frontend`. - */ -function runFallbackBuild() { - console.orange(' turbo not found — using sequential fallback build\n'); - - const scripts = [ - 'build:data-provider', - 'build:data-schemas', - 'build:api', - 'build:client-package', - ]; - - if (!flags.skipClient) { - scripts.push('build:client'); +function cleanTurboCache() { + console.purple('Clearing Turborepo cache...'); + try { + exec('npx turbo daemon stop', { stdio: 'pipe' }); + } catch { + // daemon may not be running } - for (const script of scripts) { - console.purple(` Running ${script}...`); - exec(`npm run ${script}`); + const localTurboCache = path.join(ROOT_DIR, '.turbo'); + if (fs.existsSync(localTurboCache)) { + fs.rmSync(localTurboCache, { recursive: true }); + } + + try { + exec('npx turbo clean', { stdio: 'pipe' }); + console.green('Turbo cache cleared.'); + } catch { + console.gray('Could not clear global turbo cache (may not exist yet).'); } } -function hasTurbo() { - const binDir = path.join(ROOT_DIR, 'node_modules', '.bin'); - return ['turbo', 'turbo.cmd', 'turbo.ps1'].some((name) => fs.existsSync(path.join(binDir, name))); -} - -// ─── Main ──────────────────────────────────────────────────────────────────── - (async () => { const startTime = Date.now(); console.green('\n Smart Reinstall — LibreChat'); console.green('─'.repeat(45)); - // ── Handle --clean-cache ─────────────────────────────────────────────── if (flags.cleanCache) { - console.purple('Clearing Turborepo cache...'); - if (hasTurbo()) { - try { - exec('npx turbo daemon stop', { stdio: 'pipe' }); - } catch { - // ignore — daemon may not be running - } - } - // Clear local .turbo cache dir - const localTurboCache = path.join(ROOT_DIR, '.turbo'); - if (fs.existsSync(localTurboCache)) { - fs.rmSync(localTurboCache, { recursive: true }); - } - // Clear global turbo cache - if (hasTurbo()) { - try { - exec('npx turbo clean', { stdio: 'pipe' }); - console.green('Turbo cache cleared.'); - } catch { - console.gray('Could not clear global turbo cache (may not exist yet).'); - } - } else { - console.gray('turbo not installed — nothing to clear.'); - } - + cleanTurboCache(); if (!flags.force) { return; } } - // ── Step 1: Dependencies ─────────────────────────────────────────────── + // Step 1: Dependencies console.purple('\n[1/2] Checking dependencies...'); if (flags.force) { @@ -208,16 +160,10 @@ function hasTurbo() { } } - // ── Step 2: Build packages ───────────────────────────────────────────── + // Step 2: Build via Turborepo console.purple('\n[2/2] Building packages...'); + runTurboBuild(); - if (hasTurbo()) { - runTurboBuild(); - } else { - runFallbackBuild(); - } - - // ── Done ─────────────────────────────────────────────────────────────── const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(''); console.green('─'.repeat(45)); diff --git a/package.json b/package.json index 6c8e715a1a..9c27a7ea99 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "build:client": "cd client && npm run build", "build:client-package": "cd packages/client && npm run build", "build:packages": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package", + "build": "npx turbo run build", "frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package && cd client && npm run build", "frontend:ci": "npm run build:data-provider && npm run build:client-package && cd client && npm run build:ci", "frontend:dev": "cd client && npm run dev", From a103ce72b4dbcbbd42a428225824b1f70b2b3462 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 20 Feb 2026 01:50:04 -0500 Subject: [PATCH 002/110] =?UTF-8?q?=F0=9F=94=8D=20chore:=20Update=20MeiliS?= =?UTF-8?q?earch=20version=20(#11873)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bumped MeiliSearch image version from v1.12.3 to v1.35.1 in both deploy-compose.yml and docker-compose.yml - Updated volume paths to reflect the new version for data storage consistency. --- deploy-compose.yml | 4 ++-- docker-compose.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy-compose.yml b/deploy-compose.yml index 040783b0b0..d6cbae87e7 100644 --- a/deploy-compose.yml +++ b/deploy-compose.yml @@ -53,7 +53,7 @@ services: command: mongod --noauth meilisearch: container_name: chat-meilisearch - image: getmeili/meilisearch:v1.12.3 + image: getmeili/meilisearch:v1.35.1 restart: always # ports: # Uncomment this to access meilisearch from outside docker # - 7700:7700 # if exposing these ports, make sure your master key is not the default value @@ -63,7 +63,7 @@ services: - MEILI_HOST=http://meilisearch:7700 - MEILI_NO_ANALYTICS=true volumes: - - ./meili_data_v1.12:/meili_data + - ./meili_data_v1.35.1:/meili_data vectordb: image: pgvector/pgvector:0.8.0-pg15-trixie environment: diff --git a/docker-compose.yml b/docker-compose.yml index 8df3044530..bd39de343e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: command: mongod --noauth meilisearch: container_name: chat-meilisearch - image: getmeili/meilisearch:v1.12.3 + image: getmeili/meilisearch:v1.35.1 restart: always user: "${UID}:${GID}" environment: @@ -45,7 +45,7 @@ services: - MEILI_NO_ANALYTICS=true - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} volumes: - - ./meili_data_v1.12:/meili_data + - ./meili_data_v1.35.1:/meili_data vectordb: container_name: vectordb image: pgvector/pgvector:0.8.0-pg15-trixie From 7a1d2969b840089848819b92897b12dcaaba0bba Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 20 Feb 2026 16:21:32 -0500 Subject: [PATCH 003/110] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Gemini=203.1=20P?= =?UTF-8?q?ricing=20and=20Context=20Window=20(#11884)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added support for the new Gemini 3.1 models, including 'gemini-3.1-pro-preview' and 'gemini-3.1-pro-preview-customtools'. - Updated pricing logic to apply standard and premium rates based on token usage thresholds for the new models. - Enhanced tests to validate pricing behavior for both standard and premium scenarios. - Modified configuration files to include Gemini 3.1 models in the default model lists and token value mappings. - Updated environment example file to reflect the new model options. --- .env.example | 4 +- api/models/Transaction.spec.js | 133 +++++++++++++++++++++ api/models/spendTokens.spec.js | 129 ++++++++++++++++++++ api/models/tx.js | 4 + api/models/tx.spec.js | 172 +++++++++++++++++++++++++++ api/utils/tokens.spec.js | 6 + packages/api/src/utils/tokens.ts | 1 + packages/data-provider/src/config.ts | 3 + 8 files changed, 450 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index a6ff6157ce..50229b1997 100644 --- a/.env.example +++ b/.env.example @@ -193,10 +193,10 @@ GOOGLE_KEY=user_provided # GOOGLE_AUTH_HEADER=true # Gemini API (AI Studio) -# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite +# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite # Vertex AI -# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001 +# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001 # GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001 diff --git a/api/models/Transaction.spec.js b/api/models/Transaction.spec.js index 4b478d4dc3..545c7b2755 100644 --- a/api/models/Transaction.spec.js +++ b/api/models/Transaction.spec.js @@ -823,6 +823,139 @@ describe('Premium Token Pricing Integration Tests', () => { expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0); }); + test('spendTokens should apply standard pricing for gemini-3.1-pro-preview below threshold', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gemini-3.1-pro-preview'; + const promptTokens = 100000; + const completionTokens = 500; + + const txData = { + user: userId, + conversationId: 'test-gemini31-below', + model, + context: 'test', + endpointTokenConfig: null, + balance: { enabled: true }, + }; + + await spendTokens(txData, { promptTokens, completionTokens }); + + const standardPromptRate = tokenValues['gemini-3.1'].prompt; + const standardCompletionRate = tokenValues['gemini-3.1'].completion; + const expectedCost = + promptTokens * standardPromptRate + completionTokens * standardCompletionRate; + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + test('spendTokens should apply premium pricing for gemini-3.1-pro-preview above threshold', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gemini-3.1-pro-preview'; + const promptTokens = 250000; + const completionTokens = 500; + + const txData = { + user: userId, + conversationId: 'test-gemini31-above', + model, + context: 'test', + endpointTokenConfig: null, + balance: { enabled: true }, + }; + + await spendTokens(txData, { promptTokens, completionTokens }); + + const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt; + const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion; + const expectedCost = + promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate; + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + test('spendTokens should apply standard pricing for gemini-3.1-pro-preview at exactly the threshold', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gemini-3.1-pro-preview'; + const promptTokens = premiumTokenValues['gemini-3.1'].threshold; + const completionTokens = 500; + + const txData = { + user: userId, + conversationId: 'test-gemini31-exact', + model, + context: 'test', + endpointTokenConfig: null, + balance: { enabled: true }, + }; + + await spendTokens(txData, { promptTokens, completionTokens }); + + const standardPromptRate = tokenValues['gemini-3.1'].prompt; + const standardCompletionRate = tokenValues['gemini-3.1'].completion; + const expectedCost = + promptTokens * standardPromptRate + completionTokens * standardCompletionRate; + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + test('spendStructuredTokens should apply premium pricing for gemini-3.1 when total input exceeds threshold', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gemini-3.1-pro-preview'; + const txData = { + user: userId, + conversationId: 'test-gemini31-structured-premium', + model, + context: 'message', + endpointTokenConfig: null, + balance: { enabled: true }, + }; + + const tokenUsage = { + promptTokens: { + input: 200000, + write: 10000, + read: 5000, + }, + completionTokens: 1000, + }; + + const totalInput = + tokenUsage.promptTokens.input + tokenUsage.promptTokens.write + tokenUsage.promptTokens.read; + + await spendStructuredTokens(txData, tokenUsage); + + const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt; + const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion; + const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }); + const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }); + + const expectedPromptCost = + tokenUsage.promptTokens.input * premiumPromptRate + + tokenUsage.promptTokens.write * writeMultiplier + + tokenUsage.promptTokens.read * readMultiplier; + const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate; + const expectedTotalCost = expectedPromptCost + expectedCompletionCost; + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(totalInput).toBeGreaterThan(premiumTokenValues['gemini-3.1'].threshold); + expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0); + }); + test('non-premium models should not be affected by inputTokenCount regardless of prompt size', async () => { const userId = new mongoose.Types.ObjectId(); const initialBalance = 100000000; diff --git a/api/models/spendTokens.spec.js b/api/models/spendTokens.spec.js index c076d29700..dfeec5ee83 100644 --- a/api/models/spendTokens.spec.js +++ b/api/models/spendTokens.spec.js @@ -878,6 +878,135 @@ describe('spendTokens', () => { expect(result.completion.completion).toBeCloseTo(-expectedCompletionCost, 0); }); + it('should charge standard rates for gemini-3.1-pro-preview when prompt tokens are below threshold', async () => { + const initialBalance = 100000000; + await Balance.create({ + user: userId, + tokenCredits: initialBalance, + }); + + const model = 'gemini-3.1-pro-preview'; + const promptTokens = 100000; + const completionTokens = 500; + + const txData = { + user: userId, + conversationId: 'test-gemini31-standard-pricing', + model, + context: 'test', + balance: { enabled: true }, + }; + + await spendTokens(txData, { promptTokens, completionTokens }); + + const expectedCost = + promptTokens * tokenValues['gemini-3.1'].prompt + + completionTokens * tokenValues['gemini-3.1'].completion; + + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + it('should charge premium rates for gemini-3.1-pro-preview when prompt tokens exceed threshold', async () => { + const initialBalance = 100000000; + await Balance.create({ + user: userId, + tokenCredits: initialBalance, + }); + + const model = 'gemini-3.1-pro-preview'; + const promptTokens = 250000; + const completionTokens = 500; + + const txData = { + user: userId, + conversationId: 'test-gemini31-premium-pricing', + model, + context: 'test', + balance: { enabled: true }, + }; + + await spendTokens(txData, { promptTokens, completionTokens }); + + const expectedCost = + promptTokens * premiumTokenValues['gemini-3.1'].prompt + + completionTokens * premiumTokenValues['gemini-3.1'].completion; + + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + it('should charge premium rates for gemini-3.1-pro-preview-customtools when prompt tokens exceed threshold', async () => { + const initialBalance = 100000000; + await Balance.create({ + user: userId, + tokenCredits: initialBalance, + }); + + const model = 'gemini-3.1-pro-preview-customtools'; + const promptTokens = 250000; + const completionTokens = 500; + + const txData = { + user: userId, + conversationId: 'test-gemini31-customtools-premium', + model, + context: 'test', + balance: { enabled: true }, + }; + + await spendTokens(txData, { promptTokens, completionTokens }); + + const expectedCost = + promptTokens * premiumTokenValues['gemini-3.1'].prompt + + completionTokens * premiumTokenValues['gemini-3.1'].completion; + + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + it('should charge premium rates for structured gemini-3.1 tokens when total input exceeds threshold', async () => { + const initialBalance = 100000000; + await Balance.create({ + user: userId, + tokenCredits: initialBalance, + }); + + const model = 'gemini-3.1-pro-preview'; + const txData = { + user: userId, + conversationId: 'test-gemini31-structured-premium', + model, + context: 'test', + balance: { enabled: true }, + }; + + const tokenUsage = { + promptTokens: { + input: 200000, + write: 10000, + read: 5000, + }, + completionTokens: 1000, + }; + + const result = await spendStructuredTokens(txData, tokenUsage); + + const premiumPromptRate = premiumTokenValues['gemini-3.1'].prompt; + const premiumCompletionRate = premiumTokenValues['gemini-3.1'].completion; + const writeRate = getCacheMultiplier({ model, cacheType: 'write' }); + const readRate = getCacheMultiplier({ model, cacheType: 'read' }); + + const expectedPromptCost = + tokenUsage.promptTokens.input * premiumPromptRate + + tokenUsage.promptTokens.write * writeRate + + tokenUsage.promptTokens.read * readRate; + const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate; + + expect(result.prompt.prompt).toBeCloseTo(-expectedPromptCost, 0); + expect(result.completion.completion).toBeCloseTo(-expectedCompletionCost, 0); + }); + it('should not apply premium pricing to non-premium models regardless of prompt size', async () => { const initialBalance = 100000000; await Balance.create({ diff --git a/api/models/tx.js b/api/models/tx.js index 9a6305ec5c..a13143a862 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -200,6 +200,7 @@ const tokenValues = Object.assign( 'gemini-2.5-flash-image': { prompt: 0.15, completion: 30 }, 'gemini-3': { prompt: 2, completion: 12 }, 'gemini-3-pro-image': { prompt: 2, completion: 120 }, + 'gemini-3.1': { prompt: 2, completion: 12 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2 'grok-beta': { prompt: 5.0, completion: 15.0 }, @@ -330,6 +331,8 @@ const cacheTokenValues = { 'kimi-k2-0711-preview': { write: 0.6, read: 0.15 }, 'kimi-k2-thinking': { write: 0.6, read: 0.15 }, 'kimi-k2-thinking-turbo': { write: 1.15, read: 0.15 }, + // Gemini 3.1 models - cache read: $0.20/1M (<=200k), cache write: standard input price + 'gemini-3.1': { write: 2, read: 0.2 }, }; /** @@ -340,6 +343,7 @@ const cacheTokenValues = { const premiumTokenValues = { 'claude-opus-4-6': { threshold: 200000, prompt: 10, completion: 37.5 }, 'claude-sonnet-4-6': { threshold: 200000, prompt: 6, completion: 22.5 }, + 'gemini-3.1': { threshold: 200000, prompt: 4, completion: 18 }, }; /** diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index df1bec8619..b58afa9c70 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -1345,6 +1345,8 @@ describe('getCacheMultiplier', () => { describe('Google Model Tests', () => { const googleModels = [ 'gemini-3', + 'gemini-3.1-pro-preview', + 'gemini-3.1-pro-preview-customtools', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', @@ -1389,6 +1391,8 @@ describe('Google Model Tests', () => { it('should map to the correct model keys', () => { const expected = { 'gemini-3': 'gemini-3', + 'gemini-3.1-pro-preview': 'gemini-3.1', + 'gemini-3.1-pro-preview-customtools': 'gemini-3.1', 'gemini-2.5-pro': 'gemini-2.5-pro', 'gemini-2.5-flash': 'gemini-2.5-flash', 'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite', @@ -1432,6 +1436,174 @@ describe('Google Model Tests', () => { ).toBe(tokenValues[expected].completion); }); }); + + it('should return correct prompt and completion rates for Gemini 3.1', () => { + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview', + tokenType: 'prompt', + endpoint: EModelEndpoint.google, + }), + ).toBe(tokenValues['gemini-3.1'].prompt); + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview', + tokenType: 'completion', + endpoint: EModelEndpoint.google, + }), + ).toBe(tokenValues['gemini-3.1'].completion); + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview-customtools', + tokenType: 'prompt', + endpoint: EModelEndpoint.google, + }), + ).toBe(tokenValues['gemini-3.1'].prompt); + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview-customtools', + tokenType: 'completion', + endpoint: EModelEndpoint.google, + }), + ).toBe(tokenValues['gemini-3.1'].completion); + }); + + it('should return correct cache rates for Gemini 3.1', () => { + ['gemini-3.1-pro-preview', 'gemini-3.1-pro-preview-customtools'].forEach((model) => { + expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe( + cacheTokenValues['gemini-3.1'].write, + ); + expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe( + cacheTokenValues['gemini-3.1'].read, + ); + }); + }); +}); + +describe('Gemini 3.1 Premium Token Pricing', () => { + const premiumKey = 'gemini-3.1'; + const premiumEntry = premiumTokenValues[premiumKey]; + const { threshold } = premiumEntry; + const belowThreshold = threshold - 1; + const aboveThreshold = threshold + 1; + const wellAboveThreshold = threshold * 2; + + it('should have premium pricing defined for gemini-3.1', () => { + expect(premiumEntry).toBeDefined(); + expect(premiumEntry.threshold).toBeDefined(); + expect(premiumEntry.prompt).toBeDefined(); + expect(premiumEntry.completion).toBeDefined(); + expect(premiumEntry.prompt).toBeGreaterThan(tokenValues[premiumKey].prompt); + expect(premiumEntry.completion).toBeGreaterThan(tokenValues[premiumKey].completion); + }); + + it('should return null from getPremiumRate when inputTokenCount is below or at threshold', () => { + expect(getPremiumRate(premiumKey, 'prompt', belowThreshold)).toBeNull(); + expect(getPremiumRate(premiumKey, 'completion', belowThreshold)).toBeNull(); + expect(getPremiumRate(premiumKey, 'prompt', threshold)).toBeNull(); + }); + + it('should return premium rate from getPremiumRate when inputTokenCount exceeds threshold', () => { + expect(getPremiumRate(premiumKey, 'prompt', aboveThreshold)).toBe(premiumEntry.prompt); + expect(getPremiumRate(premiumKey, 'completion', aboveThreshold)).toBe(premiumEntry.completion); + expect(getPremiumRate(premiumKey, 'prompt', wellAboveThreshold)).toBe(premiumEntry.prompt); + }); + + it('should return null from getPremiumRate when inputTokenCount is undefined or null', () => { + expect(getPremiumRate(premiumKey, 'prompt', undefined)).toBeNull(); + expect(getPremiumRate(premiumKey, 'prompt', null)).toBeNull(); + }); + + it('should return standard rate from getMultiplier when inputTokenCount is below threshold', () => { + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview', + tokenType: 'prompt', + inputTokenCount: belowThreshold, + }), + ).toBe(tokenValues[premiumKey].prompt); + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview', + tokenType: 'completion', + inputTokenCount: belowThreshold, + }), + ).toBe(tokenValues[premiumKey].completion); + }); + + it('should return premium rate from getMultiplier when inputTokenCount exceeds threshold', () => { + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview', + tokenType: 'prompt', + inputTokenCount: aboveThreshold, + }), + ).toBe(premiumEntry.prompt); + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview', + tokenType: 'completion', + inputTokenCount: aboveThreshold, + }), + ).toBe(premiumEntry.completion); + }); + + it('should return standard rate from getMultiplier when inputTokenCount is exactly at threshold', () => { + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview', + tokenType: 'prompt', + inputTokenCount: threshold, + }), + ).toBe(tokenValues[premiumKey].prompt); + }); + + it('should apply premium pricing to customtools variant above threshold', () => { + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview-customtools', + tokenType: 'prompt', + inputTokenCount: aboveThreshold, + }), + ).toBe(premiumEntry.prompt); + expect( + getMultiplier({ + model: 'gemini-3.1-pro-preview-customtools', + tokenType: 'completion', + inputTokenCount: aboveThreshold, + }), + ).toBe(premiumEntry.completion); + }); + + it('should use standard rate when inputTokenCount is not provided', () => { + expect(getMultiplier({ model: 'gemini-3.1-pro-preview', tokenType: 'prompt' })).toBe( + tokenValues[premiumKey].prompt, + ); + expect(getMultiplier({ model: 'gemini-3.1-pro-preview', tokenType: 'completion' })).toBe( + tokenValues[premiumKey].completion, + ); + }); + + it('should apply premium pricing through getMultiplier with valueKey path', () => { + const valueKey = getValueKey('gemini-3.1-pro-preview'); + expect(valueKey).toBe(premiumKey); + expect(getMultiplier({ valueKey, tokenType: 'prompt', inputTokenCount: aboveThreshold })).toBe( + premiumEntry.prompt, + ); + expect( + getMultiplier({ valueKey, tokenType: 'completion', inputTokenCount: aboveThreshold }), + ).toBe(premiumEntry.completion); + }); + + it('should apply standard pricing through getMultiplier with valueKey path when below threshold', () => { + const valueKey = getValueKey('gemini-3.1-pro-preview'); + expect(getMultiplier({ valueKey, tokenType: 'prompt', inputTokenCount: belowThreshold })).toBe( + tokenValues[premiumKey].prompt, + ); + expect( + getMultiplier({ valueKey, tokenType: 'completion', inputTokenCount: belowThreshold }), + ).toBe(tokenValues[premiumKey].completion); + }); }); describe('Grok Model Tests - Pricing', () => { diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 18905d6d18..efbd962a8c 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -279,6 +279,12 @@ describe('getModelMaxTokens', () => { expect(getModelMaxTokens('gemini-3', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-3'], ); + expect(getModelMaxTokens('gemini-3.1-pro-preview', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-3.1'], + ); + expect(getModelMaxTokens('gemini-3.1-pro-preview-customtools', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-3.1'], + ); expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'], ); diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index a824afa489..faeb8f0f90 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -106,6 +106,7 @@ const googleModels = { 'gemini-exp': 2000000, 'gemini-3': 1000000, // 1M input tokens, 64k output tokens 'gemini-3-pro-image': 1000000, + 'gemini-3.1': 1000000, // 1M input tokens, 64k output tokens 'gemini-2.5': 1000000, // 1M input tokens, 64k output tokens 'gemini-2.5-pro': 1000000, 'gemini-2.5-flash': 1000000, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 360cce69ba..82d477e54e 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1192,6 +1192,9 @@ export const defaultModels = { [EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'], [EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels) [EModelEndpoint.google]: [ + // Gemini 3.1 Models + 'gemini-3.1-pro-preview', + 'gemini-3.1-pro-preview-customtools', // Gemini 2.5 Models 'gemini-2.5-pro', 'gemini-2.5-flash', From 59717f5f50f045a7b56935d2680dd7155e478004 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 20 Feb 2026 16:23:33 -0500 Subject: [PATCH 004/110] =?UTF-8?q?=E2=9C=B3=EF=B8=8F=20docs:=20Point=20CL?= =?UTF-8?q?AUDE.md=20to=20AGENTS.md=20(#11886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 5d2b7fa4d5d90ac66bae3ea8f069f89185eb3ff4 Mon Sep 17 00:00:00 2001 From: Rene Heijdens <101724050+H31nz3l@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:07:16 +0100 Subject: [PATCH 005/110] =?UTF-8?q?=F0=9F=AA=A3=20fix:=20Proper=20Key=20Ex?= =?UTF-8?q?traction=20from=20S3=20URL=20(#11241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Enhance S3 URL handling and add comprehensive tests for CRUD operations * 🔒 fix: Improve S3 URL key extraction with enhanced logging and additional test cases * chore: removed some duplicate testcases and fixed incorrect apostrophes * fix: Log error for malformed URLs * test: Add additional test case for extracting keys from S3 URLs * fix: Enhance S3 URL key extraction logic and improve error handling with additional test cases * test: Add test case for stripping bucket from custom endpoint URLs with forcePathStyle enabled * refactor: Update S3 path style handling and enhance environment configuration for S3-compatible services * refactor: Remove S3_FORCE_PATH_STYLE dependency and streamline S3 URL key extraction logic --------- Co-authored-by: Danny Avila --- api/server/services/Files/S3/crud.js | 53 +- api/test/services/Files/S3/crud.test.js | 845 ++++++++++++++++++++++++ 2 files changed, 896 insertions(+), 2 deletions(-) create mode 100644 api/test/services/Files/S3/crud.test.js diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index 0721e33b29..efd2d7734b 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -252,15 +252,63 @@ function extractKeyFromS3Url(fileUrlOrKey) { try { const url = new URL(fileUrlOrKey); - return url.pathname.substring(1); + const hostname = url.hostname; + const pathname = url.pathname.substring(1); // Remove leading slash + + if ( + hostname === 's3.amazonaws.com' || + hostname.match(/^s3[-.][a-z0-9-]+\.amazonaws\.com$/) || + (bucketName && pathname.startsWith(`${bucketName}/`)) + ) { + // Path-style: https://s3.amazonaws.com/bucket-name/key or custom endpoint (MinIO, R2, etc.) + // Strip the bucket name (first path segment) + const firstSlashIndex = pathname.indexOf('/'); + if (firstSlashIndex > 0) { + const key = pathname.substring(firstSlashIndex + 1); + + if (key === '') { + logger.warn( + `[extractKeyFromS3Url] Extracted key is empty after removing bucket name from URL: ${fileUrlOrKey}`, + ); + } else { + logger.debug( + `[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`, + ); + } + + return key; + } else { + logger.warn( + `[extractKeyFromS3Url] Unable to extract key from path-style URL: ${fileUrlOrKey}`, + ); + return ''; + } + } + + // Virtual-hosted-style or other: https://bucket-name.s3.amazonaws.com/key + // Just return the pathname without leading slash + logger.debug(`[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${pathname}`); + return pathname; } catch (error) { + if (fileUrlOrKey.startsWith('http://') || fileUrlOrKey.startsWith('https://')) { + logger.error( + `[extractKeyFromS3Url] Error parsing URL: ${fileUrlOrKey}, Error: ${error.message}`, + ); + } else { + logger.debug(`[extractKeyFromS3Url] Non-URL input, using fallback: ${fileUrlOrKey}`); + } + const parts = fileUrlOrKey.split('/'); if (parts.length >= 3 && !fileUrlOrKey.startsWith('http') && !fileUrlOrKey.startsWith('/')) { return fileUrlOrKey; } - return fileUrlOrKey.startsWith('/') ? fileUrlOrKey.substring(1) : fileUrlOrKey; + const key = fileUrlOrKey.startsWith('/') ? fileUrlOrKey.substring(1) : fileUrlOrKey; + logger.debug( + `[extractKeyFromS3Url] FALLBACK. fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`, + ); + return key; } } @@ -482,4 +530,5 @@ module.exports = { refreshS3Url, needsRefresh, getNewS3URL, + extractKeyFromS3Url, }; diff --git a/api/test/services/Files/S3/crud.test.js b/api/test/services/Files/S3/crud.test.js new file mode 100644 index 0000000000..c55bbbcf97 --- /dev/null +++ b/api/test/services/Files/S3/crud.test.js @@ -0,0 +1,845 @@ +const fs = require('fs'); +const fetch = require('node-fetch'); +const { Readable } = require('stream'); +const { FileSources } = require('librechat-data-provider'); +const { + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, + DeleteObjectCommand, +} = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + +// Mock dependencies +jest.mock('fs'); +jest.mock('node-fetch'); +jest.mock('@aws-sdk/s3-request-presigner'); +jest.mock('@aws-sdk/client-s3'); + +jest.mock('@librechat/api', () => ({ + initializeS3: jest.fn(), + deleteRagFile: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const { initializeS3, deleteRagFile } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); + +// Set env vars before requiring crud so module-level constants pick them up +process.env.AWS_BUCKET_NAME = 'test-bucket'; +process.env.S3_URL_EXPIRY_SECONDS = '120'; + +const { + saveBufferToS3, + saveURLToS3, + getS3URL, + deleteFileFromS3, + uploadFileToS3, + getS3FileStream, + refreshS3FileUrls, + refreshS3Url, + needsRefresh, + getNewS3URL, + extractKeyFromS3Url, +} = require('~/server/services/Files/S3/crud'); + +describe('S3 CRUD Operations', () => { + let mockS3Client; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock S3 client + mockS3Client = { + send: jest.fn(), + }; + initializeS3.mockReturnValue(mockS3Client); + }); + + afterEach(() => { + delete process.env.S3_URL_EXPIRY_SECONDS; + delete process.env.S3_REFRESH_EXPIRY_MS; + delete process.env.AWS_BUCKET_NAME; + }); + + describe('saveBufferToS3', () => { + it('should upload a buffer to S3 and return a signed URL', async () => { + const mockBuffer = Buffer.from('test data'); + const mockSignedUrl = + 'https://s3.amazonaws.com/test-bucket/images/user123/test.jpg?signature=abc'; + + mockS3Client.send.mockResolvedValue({}); + getSignedUrl.mockResolvedValue(mockSignedUrl); + + const result = await saveBufferToS3({ + userId: 'user123', + buffer: mockBuffer, + fileName: 'test.jpg', + basePath: 'images', + }); + + expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand)); + expect(result).toBe(mockSignedUrl); + }); + + it('should use default basePath if not provided', async () => { + const mockBuffer = Buffer.from('test data'); + const mockSignedUrl = + 'https://s3.amazonaws.com/test-bucket/images/user123/test.jpg?signature=abc'; + + mockS3Client.send.mockResolvedValue({}); + getSignedUrl.mockResolvedValue(mockSignedUrl); + + await saveBufferToS3({ + userId: 'user123', + buffer: mockBuffer, + fileName: 'test.jpg', + }); + + expect(getSignedUrl).toHaveBeenCalled(); + }); + + it('should handle S3 upload errors', async () => { + const mockBuffer = Buffer.from('test data'); + const error = new Error('S3 upload failed'); + + mockS3Client.send.mockRejectedValue(error); + + await expect( + saveBufferToS3({ + userId: 'user123', + buffer: mockBuffer, + fileName: 'test.jpg', + }), + ).rejects.toThrow('S3 upload failed'); + + expect(logger.error).toHaveBeenCalledWith( + '[saveBufferToS3] Error uploading buffer to S3:', + 'S3 upload failed', + ); + }); + }); + + describe('getS3URL', () => { + it('should return a signed URL for a file', async () => { + const mockSignedUrl = + 'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz'; + getSignedUrl.mockResolvedValue(mockSignedUrl); + + const result = await getS3URL({ + userId: 'user123', + fileName: 'file.pdf', + basePath: 'documents', + }); + + expect(result).toBe(mockSignedUrl); + expect(getSignedUrl).toHaveBeenCalledWith( + mockS3Client, + expect.any(GetObjectCommand), + expect.objectContaining({ expiresIn: 120 }), + ); + }); + + it('should add custom filename to Content-Disposition header', async () => { + const mockSignedUrl = + 'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz'; + getSignedUrl.mockResolvedValue(mockSignedUrl); + + await getS3URL({ + userId: 'user123', + fileName: 'file.pdf', + customFilename: 'custom-name.pdf', + }); + + expect(getSignedUrl).toHaveBeenCalled(); + }); + + it('should add custom content type', async () => { + const mockSignedUrl = + 'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf?signature=xyz'; + getSignedUrl.mockResolvedValue(mockSignedUrl); + + await getS3URL({ + userId: 'user123', + fileName: 'file.pdf', + contentType: 'application/pdf', + }); + + expect(getSignedUrl).toHaveBeenCalled(); + }); + + it('should handle errors when getting signed URL', async () => { + const error = new Error('Failed to sign URL'); + getSignedUrl.mockRejectedValue(error); + + await expect( + getS3URL({ + userId: 'user123', + fileName: 'file.pdf', + }), + ).rejects.toThrow('Failed to sign URL'); + + expect(logger.error).toHaveBeenCalledWith( + '[getS3URL] Error getting signed URL from S3:', + 'Failed to sign URL', + ); + }); + }); + + describe('saveURLToS3', () => { + it('should fetch a file from URL and save to S3', async () => { + const mockBuffer = Buffer.from('downloaded data'); + const mockResponse = { + buffer: jest.fn().mockResolvedValue(mockBuffer), + }; + const mockSignedUrl = + 'https://s3.amazonaws.com/test-bucket/images/user123/downloaded.jpg?signature=abc'; + + fetch.mockResolvedValue(mockResponse); + mockS3Client.send.mockResolvedValue({}); + getSignedUrl.mockResolvedValue(mockSignedUrl); + + const result = await saveURLToS3({ + userId: 'user123', + URL: 'https://example.com/image.jpg', + fileName: 'downloaded.jpg', + }); + + expect(fetch).toHaveBeenCalledWith('https://example.com/image.jpg'); + expect(mockS3Client.send).toHaveBeenCalled(); + expect(result).toBe(mockSignedUrl); + }); + + it('should handle fetch errors', async () => { + const error = new Error('Network error'); + fetch.mockRejectedValue(error); + + await expect( + saveURLToS3({ + userId: 'user123', + URL: 'https://example.com/image.jpg', + fileName: 'downloaded.jpg', + }), + ).rejects.toThrow('Network error'); + + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('deleteFileFromS3', () => { + const mockReq = { + user: { id: 'user123' }, + }; + + it('should delete a file from S3', async () => { + const mockFile = { + filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg', + file_id: 'file123', + }; + + // Mock HeadObject to verify file exists + mockS3Client.send + .mockResolvedValueOnce({}) // First HeadObject - exists + .mockResolvedValueOnce({}) // DeleteObject + .mockRejectedValueOnce({ name: 'NotFound' }); // Second HeadObject - deleted + + await deleteFileFromS3(mockReq, mockFile); + + expect(deleteRagFile).toHaveBeenCalledWith({ userId: 'user123', file: mockFile }); + expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(HeadObjectCommand)); + expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(DeleteObjectCommand)); + }); + + it('should handle file not found gracefully', async () => { + const mockFile = { + filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/nonexistent.jpg', + file_id: 'file123', + }; + + mockS3Client.send.mockRejectedValue({ name: 'NotFound' }); + + await deleteFileFromS3(mockReq, mockFile); + + expect(logger.warn).toHaveBeenCalled(); + }); + + it('should throw error if user ID does not match', async () => { + const mockFile = { + filepath: 'https://s3.amazonaws.com/test-bucket/images/different-user/file.jpg', + file_id: 'file123', + }; + + await expect(deleteFileFromS3(mockReq, mockFile)).rejects.toThrow('User ID mismatch'); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should handle NoSuchKey error', async () => { + const mockFile = { + filepath: 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg', + file_id: 'file123', + }; + + mockS3Client.send + .mockResolvedValueOnce({}) // HeadObject - exists + .mockRejectedValueOnce({ code: 'NoSuchKey' }); // DeleteObject fails + + await deleteFileFromS3(mockReq, mockFile); + + expect(logger.debug).toHaveBeenCalled(); + }); + }); + + describe('uploadFileToS3', () => { + const mockReq = { + user: { id: 'user123' }, + }; + + it('should upload a file from disk to S3', async () => { + const mockFile = { + path: '/tmp/upload.jpg', + originalname: 'photo.jpg', + }; + const mockStats = { size: 1024 }; + const mockSignedUrl = + 'https://s3.amazonaws.com/test-bucket/images/user123/file123__photo.jpg?signature=xyz'; + + fs.promises = { stat: jest.fn().mockResolvedValue(mockStats) }; + fs.createReadStream = jest.fn().mockReturnValue(new Readable()); + mockS3Client.send.mockResolvedValue({}); + getSignedUrl.mockResolvedValue(mockSignedUrl); + + const result = await uploadFileToS3({ + req: mockReq, + file: mockFile, + file_id: 'file123', + basePath: 'images', + }); + + expect(result).toEqual({ + filepath: mockSignedUrl, + bytes: 1024, + }); + expect(fs.createReadStream).toHaveBeenCalledWith('/tmp/upload.jpg'); + expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(PutObjectCommand)); + }); + + it('should handle upload errors and clean up temp file', async () => { + const mockFile = { + path: '/tmp/upload.jpg', + originalname: 'photo.jpg', + }; + const error = new Error('Upload failed'); + + fs.promises = { + stat: jest.fn().mockResolvedValue({ size: 1024 }), + unlink: jest.fn().mockResolvedValue(), + }; + fs.createReadStream = jest.fn().mockReturnValue(new Readable()); + mockS3Client.send.mockRejectedValue(error); + + await expect( + uploadFileToS3({ + req: mockReq, + file: mockFile, + file_id: 'file123', + }), + ).rejects.toThrow('Upload failed'); + + expect(logger.error).toHaveBeenCalledWith( + '[uploadFileToS3] Error streaming file to S3:', + error, + ); + }); + }); + + describe('getS3FileStream', () => { + it('should return a readable stream for a file', async () => { + const mockStream = new Readable(); + const mockResponse = { Body: mockStream }; + + mockS3Client.send.mockResolvedValue(mockResponse); + + const result = await getS3FileStream( + {}, + 'https://s3.amazonaws.com/test-bucket/images/user123/file.pdf', + ); + + expect(result).toBe(mockStream); + expect(mockS3Client.send).toHaveBeenCalledWith(expect.any(GetObjectCommand)); + }); + + it('should handle errors when retrieving stream', async () => { + const error = new Error('Stream error'); + mockS3Client.send.mockRejectedValue(error); + + await expect(getS3FileStream({}, 'images/user123/file.pdf')).rejects.toThrow('Stream error'); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('needsRefresh', () => { + it('should return false for non-signed URLs', () => { + const url = 'https://example.com/proxy/file.jpg'; + const result = needsRefresh(url, 3600); + expect(result).toBe(false); + }); + + it('should return true for expired signed URLs', () => { + const now = new Date(); + const past = new Date(now.getTime() - 3600 * 1000); // 1 hour ago + const dateStr = past + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + + const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`; + const result = needsRefresh(url, 60); + expect(result).toBe(true); + }); + + it('should return false for URLs that are not close to expiration', () => { + const now = new Date(); + const recent = new Date(now.getTime() - 10 * 1000); // 10 seconds ago + const dateStr = recent + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + + const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=7200`; + const result = needsRefresh(url, 60); + expect(result).toBe(false); + }); + + it('should use custom refresh expiry when S3_REFRESH_EXPIRY_MS is set', () => { + process.env.S3_REFRESH_EXPIRY_MS = '30000'; // 30 seconds + + const now = new Date(); + const recent = new Date(now.getTime() - 31 * 1000); // 31 seconds ago + const dateStr = recent + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + + const url = `https://s3.amazonaws.com/bucket/key?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=7200`; + + // Need to reload the module to pick up the env var change + jest.resetModules(); + const { needsRefresh: needsRefreshReloaded } = require('~/server/services/Files/S3/crud'); + + const result = needsRefreshReloaded(url, 60); + expect(result).toBe(true); + }); + + it('should return true for malformed URLs', () => { + const url = 'not-a-valid-url'; + const result = needsRefresh(url, 3600); + expect(result).toBe(true); + }); + }); + + describe('getNewS3URL', () => { + it('should generate a new URL from an existing S3 URL', async () => { + const currentURL = + 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=old'; + const newURL = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=new'; + + getSignedUrl.mockResolvedValue(newURL); + + const result = await getNewS3URL(currentURL); + + expect(result).toBe(newURL); + expect(getSignedUrl).toHaveBeenCalled(); + }); + + it('should return undefined for invalid URLs', async () => { + const result = await getNewS3URL('invalid-url'); + expect(result).toBeUndefined(); + }); + + it('should handle errors gracefully', async () => { + const currentURL = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg'; + getSignedUrl.mockRejectedValue(new Error('Failed')); + + const result = await getNewS3URL(currentURL); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith('Error getting new S3 URL:', expect.any(Error)); + }); + + it('should construct GetObjectCommand with correct key (no bucket name duplication)', async () => { + const currentURL = + 'https://s3.amazonaws.com/my-bucket/images/user123/file.jpg?X-Amz-Signature=old'; + getSignedUrl.mockResolvedValue( + 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg?signature=new', + ); + + await getNewS3URL(currentURL); + + expect(GetObjectCommand).toHaveBeenCalledWith( + expect.objectContaining({ Key: 'images/user123/file.jpg' }), + ); + }); + }); + + describe('refreshS3FileUrls', () => { + it('should refresh expired URLs for multiple files', async () => { + const now = new Date(); + const past = new Date(now.getTime() - 3600 * 1000); + const dateStr = past + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + + const files = [ + { + file_id: 'file1', + source: FileSources.s3, + filepath: `https://s3.amazonaws.com/bucket/images/user123/file1.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`, + }, + { + file_id: 'file2', + source: FileSources.s3, + filepath: `https://s3.amazonaws.com/bucket/images/user123/file2.jpg?X-Amz-Signature=def&X-Amz-Date=${dateStr}&X-Amz-Expires=60`, + }, + ]; + + const newURL1 = 'https://s3.amazonaws.com/bucket/images/user123/file1.jpg?signature=new1'; + const newURL2 = 'https://s3.amazonaws.com/bucket/images/user123/file2.jpg?signature=new2'; + + getSignedUrl.mockResolvedValueOnce(newURL1).mockResolvedValueOnce(newURL2); + + const mockBatchUpdate = jest.fn().mockResolvedValue(); + + const result = await refreshS3FileUrls(files, mockBatchUpdate, 60); + + expect(result[0].filepath).toBe(newURL1); + expect(result[1].filepath).toBe(newURL2); + expect(mockBatchUpdate).toHaveBeenCalledWith([ + { file_id: 'file1', filepath: newURL1 }, + { file_id: 'file2', filepath: newURL2 }, + ]); + }); + + it('should skip non-S3 files', async () => { + const files = [ + { + file_id: 'file1', + source: 'local', + filepath: '/local/path/file.jpg', + }, + ]; + + const mockBatchUpdate = jest.fn(); + + const result = await refreshS3FileUrls(files, mockBatchUpdate); + + expect(result).toEqual(files); + expect(mockBatchUpdate).not.toHaveBeenCalled(); + }); + + it('should handle empty or invalid input', async () => { + const mockBatchUpdate = jest.fn(); + + const result1 = await refreshS3FileUrls(null, mockBatchUpdate); + expect(result1).toBe(null); + + const result2 = await refreshS3FileUrls([], mockBatchUpdate); + expect(result2).toEqual([]); + + expect(mockBatchUpdate).not.toHaveBeenCalled(); + }); + + it('should handle errors for individual files gracefully', async () => { + const now = new Date(); + const past = new Date(now.getTime() - 3600 * 1000); + const dateStr = past + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + + const files = [ + { + file_id: 'file1', + source: FileSources.s3, + filepath: `https://s3.amazonaws.com/bucket/images/user123/file1.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`, + }, + ]; + + getSignedUrl.mockRejectedValue(new Error('Failed to refresh')); + const mockBatchUpdate = jest.fn(); + + await refreshS3FileUrls(files, mockBatchUpdate, 60); + + expect(logger.error).toHaveBeenCalledWith('Error getting new S3 URL:', expect.any(Error)); + expect(mockBatchUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('refreshS3Url', () => { + it('should refresh an expired S3 URL', async () => { + const now = new Date(); + const past = new Date(now.getTime() - 3600 * 1000); + const dateStr = past + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + + const fileObj = { + source: FileSources.s3, + filepath: `https://s3.amazonaws.com/bucket/images/user123/file.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`, + }; + + const newURL = 'https://s3.amazonaws.com/bucket/images/user123/file.jpg?signature=new'; + getSignedUrl.mockResolvedValue(newURL); + + const result = await refreshS3Url(fileObj, 60); + + expect(result).toBe(newURL); + }); + + it('should return original URL if not expired', async () => { + const fileObj = { + source: FileSources.s3, + filepath: 'https://example.com/proxy/file.jpg', + }; + + const result = await refreshS3Url(fileObj, 3600); + + expect(result).toBe(fileObj.filepath); + expect(getSignedUrl).not.toHaveBeenCalled(); + }); + + it('should return empty string for null input', async () => { + const result = await refreshS3Url(null); + expect(result).toBe(''); + }); + + it('should return original URL for non-S3 files', async () => { + const fileObj = { + source: 'local', + filepath: '/local/path/file.jpg', + }; + + const result = await refreshS3Url(fileObj); + + expect(result).toBe(fileObj.filepath); + }); + + it('should handle errors and return original URL', async () => { + const now = new Date(); + const past = new Date(now.getTime() - 3600 * 1000); + const dateStr = past + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.\d{3}/, ''); + + const fileObj = { + source: FileSources.s3, + filepath: `https://s3.amazonaws.com/bucket/images/user123/file.jpg?X-Amz-Signature=abc&X-Amz-Date=${dateStr}&X-Amz-Expires=60`, + }; + + getSignedUrl.mockRejectedValue(new Error('Refresh failed')); + + const result = await refreshS3Url(fileObj, 60); + + expect(result).toBe(fileObj.filepath); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('extractKeyFromS3Url', () => { + it('should extract key from a full S3 URL', () => { + const url = 'https://s3.amazonaws.com/test-bucket/images/user123/file.jpg'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('images/user123/file.jpg'); + }); + + it('should extract key from a signed S3 URL with query parameters', () => { + const url = + 'https://s3.amazonaws.com/test-bucket/documents/user456/report.pdf?X-Amz-Signature=abc123&X-Amz-Date=20260107'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('documents/user456/report.pdf'); + }); + + it('should extract key from S3 URL with different domain format', () => { + const url = 'https://test-bucket.s3.amazonaws.com/uploads/user789/image.png'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('uploads/user789/image.png'); + }); + + it('should return key as-is if already properly formatted (3+ parts, no http)', () => { + const key = 'images/user123/file.jpg'; + const result = extractKeyFromS3Url(key); + expect(result).toBe('images/user123/file.jpg'); + }); + + it('should handle key with leading slash by removing it', () => { + const key = '/images/user123/file.jpg'; + const result = extractKeyFromS3Url(key); + expect(result).toBe('images/user123/file.jpg'); + }); + + it('should handle simple key without slashes', () => { + const key = 'simple-file.txt'; + const result = extractKeyFromS3Url(key); + expect(result).toBe('simple-file.txt'); + }); + + it('should handle key with only two parts', () => { + const key = 'folder/file.txt'; + const result = extractKeyFromS3Url(key); + expect(result).toBe('folder/file.txt'); + }); + + it('should throw error for empty input', () => { + expect(() => extractKeyFromS3Url('')).toThrow('Invalid input: URL or key is empty'); + }); + + it('should throw error for null input', () => { + expect(() => extractKeyFromS3Url(null)).toThrow('Invalid input: URL or key is empty'); + }); + + it('should throw error for undefined input', () => { + expect(() => extractKeyFromS3Url(undefined)).toThrow('Invalid input: URL or key is empty'); + }); + + it('should handle URLs with encoded characters', () => { + const url = 'https://s3.amazonaws.com/test-bucket/images/user123/my%20file%20name.jpg'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('images/user123/my%20file%20name.jpg'); + }); + + it('should handle deep nested paths', () => { + const url = 'https://s3.amazonaws.com/bucket/a/b/c/d/e/f/file.jpg'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('a/b/c/d/e/f/file.jpg'); + }); + + it('should log debug message when extracting from URL', () => { + const url = 'https://s3.amazonaws.com/bucket/images/user123/file.jpg'; + extractKeyFromS3Url(url); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[extractKeyFromS3Url] fileUrlOrKey:'), + ); + }); + + it('should log fallback debug message for non-URL input', () => { + const key = 'simple-file.txt'; + extractKeyFromS3Url(key); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[extractKeyFromS3Url] FALLBACK'), + ); + }); + + it('should handle valid URLs that contain only a bucket', () => { + const url = 'https://s3.amazonaws.com/test-bucket/'; + const result = extractKeyFromS3Url(url); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + '[extractKeyFromS3Url] Extracted key is empty after removing bucket name from URL: https://s3.amazonaws.com/test-bucket/', + ), + ); + expect(result).toBe(''); + }); + + it('should handle invalid URLs that contain only a bucket', () => { + const url = 'https://s3.amazonaws.com/test-bucket'; + const result = extractKeyFromS3Url(url); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + '[extractKeyFromS3Url] Unable to extract key from path-style URL: https://s3.amazonaws.com/test-bucket', + ), + ); + expect(result).toBe(''); + }); + + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html + + // Path-style requests + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access + // https://s3.region-code.amazonaws.com/bucket-name/key-name + it('should handle formatted according to Path-style regional endpoint', () => { + const url = 'https://s3.us-west-2.amazonaws.com/amzn-s3-demo-bucket1/dogs/puppy.jpg'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('dogs/puppy.jpg'); + }); + + // virtual host style + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access + // https://bucket-name.s3.region-code.amazonaws.com/key-name + it('should handle formatted according to Virtual-hosted–style Regional endpoint', () => { + const url = 'https://amzn-s3-demo-bucket1.s3.us-west-2.amazonaws.com/dogs/puppy.png'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('dogs/puppy.png'); + }); + + // Legacy endpoints + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#VirtualHostingBackwardsCompatibility + + // s3‐Region + // https://bucket-name.s3-region-code.amazonaws.com + it('should handle formatted according to s3‐Region', () => { + const url = 'https://amzn-s3-demo-bucket1.s3-us-west-2.amazonaws.com/puppy.png'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('puppy.png'); + + const testcase2 = 'https://amzn-s3-demo-bucket1.s3-us-west-2.amazonaws.com/cats/kitten.png'; + const result2 = extractKeyFromS3Url(testcase2); + expect(result2).toBe('cats/kitten.png'); + }); + + // Legacy global endpoint + // bucket-name.s3.amazonaws.com + it('should handle formatted according to Legacy global endpoint', () => { + const url = 'https://amzn-s3-demo-bucket1.s3.amazonaws.com/dogs/puppy.png'; + const result = extractKeyFromS3Url(url); + expect(result).toBe('dogs/puppy.png'); + }); + + it('should handle malformed URL and log error', () => { + const malformedUrl = 'https://invalid url with spaces.com/key'; + const result = extractKeyFromS3Url(malformedUrl); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('[extractKeyFromS3Url] Error parsing URL:'), + ); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(malformedUrl)); + + expect(result).toBe(malformedUrl); + }); + + it('should return empty string for regional path-style URL with only bucket (no key)', () => { + const url = 'https://s3.us-west-2.amazonaws.com/my-bucket'; + const result = extractKeyFromS3Url(url); + expect(result).toBe(''); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[extractKeyFromS3Url] Unable to extract key from path-style URL:'), + ); + }); + + it('should not log error when given a plain S3 key (non-URL input)', () => { + extractKeyFromS3Url('images/user123/file.jpg'); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should strip bucket from custom endpoint URLs (MinIO, R2, etc.) using bucketName', () => { + // bucketName is the module-level const 'test-bucket', set before require at top of file + expect( + extractKeyFromS3Url('https://minio.example.com/test-bucket/images/user123/file.jpg'), + ).toBe('images/user123/file.jpg'); + expect( + extractKeyFromS3Url( + 'https://abc123.r2.cloudflarestorage.com/test-bucket/images/user123/avatar.png', + ), + ).toBe('images/user123/avatar.png'); + }); + }); +}); From e92061671b461cd7c04a72810afc365fd556448a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:09:36 -0500 Subject: [PATCH 006/110] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#11887)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/lv/translation.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 5048c33dcc..a04838ad1c 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -842,6 +842,7 @@ "com_ui_controls": "Pārvaldība", "com_ui_conversation": "saruna", "com_ui_conversation_label": "{{title}} saruna", + "com_ui_conversation_not_found": "Saruna nav atrasta", "com_ui_conversations": "sarunas", "com_ui_convo_archived": "Sarunas arhivētas", "com_ui_convo_delete_error": "Neizdevās izdzēst sarunu", @@ -1455,7 +1456,7 @@ "com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.", "com_ui_upload_error": "Augšupielādējot failu, radās kļūda.", "com_ui_upload_file_context": "Augšupielādēt failu kā kontekstu", - "com_ui_upload_file_search": "Augšupielādēt vektorizētai meklēšanai", + "com_ui_upload_file_search": "Pievienot meklēšanai dokumentos", "com_ui_upload_files": "Augšupielādēt failus", "com_ui_upload_icon": "Augšupielādēt ikonas attēlu", "com_ui_upload_image": "Augšupielādēt failu kā attēlu", @@ -1463,7 +1464,7 @@ "com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.", "com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB", "com_ui_upload_ocr_text": "Augšupielādēt failu kā kontekstu", - "com_ui_upload_provider": "Augšupielādēt pakalpojumu sniedzējam", + "com_ui_upload_provider": "Pievienot čatam", "com_ui_upload_success": "Fails veiksmīgi augšupielādēts", "com_ui_upload_type": "Izvēlieties augšupielādes veidu", "com_ui_usage": "Izmantošana", From 4404319e22600b2fca37f95da0b5def2441091e5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 21 Feb 2026 16:17:42 -0500 Subject: [PATCH 007/110] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Bump=20`@librec?= =?UTF-8?q?hat/agents`=20to=20v3.1.51=20(#11891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index 1c40ddb337..4542e25745 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.50", + "@librechat/agents": "^3.1.51", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 62af363289..4bca60d435 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.50", + "@librechat/agents": "^3.1.51", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11196,9 +11196,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.50", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.50.tgz", - "integrity": "sha512-+gdfUJ7X3PJ20/c+8lETY68D6QpxFlCIlGUQBF4A8VKv+Po9J/TO5rWE+OmzmPByYpye7GrcxVCBLfRTvZKraw==", + "version": "3.1.51", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.51.tgz", + "integrity": "sha512-inEcLCuD7YF0yCBrnxCgemg2oyRWJtCq49tLtokrD+WyWT97benSB+UyopjWh5woOsxSws3oc60d5mxRtifoLg==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -42188,7 +42188,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.50", + "@librechat/agents": "^3.1.51", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.26.0", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index 5f8d1357d0..67cb5df816 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -87,7 +87,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.50", + "@librechat/agents": "^3.1.51", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.26.0", "@smithy/node-http-handler": "^4.4.5", From cca9d63224183308af57a5ce541c39cb76decb3a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 21 Feb 2026 18:03:39 -0500 Subject: [PATCH 008/110] =?UTF-8?q?=F0=9F=94=92=20refactor:=20`graphTokenC?= =?UTF-8?q?ontroller`=20to=20use=20federated=20access=20token=20for=20OBO?= =?UTF-8?q?=20assertion=20(#11893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the extraction of access token from the Authorization header. - Implemented logic to use the federated access token from the user object. - Added error handling for missing federated access token. - Updated related documentation in GraphTokenService to reflect changes in access token usage. - Introduced unit tests for various scenarios in AuthController.spec.js to ensure proper functionality. --- api/server/controllers/AuthController.js | 17 +-- api/server/controllers/AuthController.spec.js | 144 ++++++++++++++++++ api/server/services/GraphTokenService.js | 2 +- 3 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 api/server/controllers/AuthController.spec.js diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 588391b535..58d2427512 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -196,15 +196,6 @@ const graphTokenController = async (req, res) => { }); } - // Extract access token from Authorization header - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - message: 'Valid authorization token required', - }); - } - - // Get scopes from query parameters const scopes = req.query.scopes; if (!scopes) { return res.status(400).json({ @@ -212,7 +203,13 @@ const graphTokenController = async (req, res) => { }); } - const accessToken = authHeader.substring(7); // Remove 'Bearer ' prefix + const accessToken = req.user.federatedTokens?.access_token; + if (!accessToken) { + return res.status(401).json({ + message: 'No federated access token available for token exchange', + }); + } + const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes); res.json(tokenResponse); diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js new file mode 100644 index 0000000000..cbf72657fb --- /dev/null +++ b/api/server/controllers/AuthController.spec.js @@ -0,0 +1,144 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn() }, +})); +jest.mock('~/server/services/GraphTokenService', () => ({ + getGraphApiToken: jest.fn(), +})); +jest.mock('~/server/services/AuthService', () => ({ + requestPasswordReset: jest.fn(), + setOpenIDAuthTokens: jest.fn(), + resetPassword: jest.fn(), + setAuthTokens: jest.fn(), + registerUser: jest.fn(), +})); +jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn() })); +jest.mock('~/models', () => ({ + deleteAllUserSessions: jest.fn(), + getUserById: jest.fn(), + findSession: jest.fn(), + updateUser: jest.fn(), + findUser: jest.fn(), +})); +jest.mock('@librechat/api', () => ({ + isEnabled: jest.fn(), + findOpenIDUser: jest.fn(), +})); + +const { isEnabled } = require('@librechat/api'); +const { getGraphApiToken } = require('~/server/services/GraphTokenService'); +const { graphTokenController } = require('./AuthController'); + +describe('graphTokenController', () => { + let req, res; + + beforeEach(() => { + jest.clearAllMocks(); + isEnabled.mockReturnValue(true); + + req = { + user: { + openidId: 'oid-123', + provider: 'openid', + federatedTokens: { + access_token: 'federated-access-token', + id_token: 'federated-id-token', + }, + }, + headers: { authorization: 'Bearer app-jwt-which-is-id-token' }, + query: { scopes: 'https://graph.microsoft.com/.default' }, + }; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + getGraphApiToken.mockResolvedValue({ + access_token: 'graph-access-token', + token_type: 'Bearer', + expires_in: 3600, + }); + }); + + it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => { + await graphTokenController(req, res); + + expect(getGraphApiToken).toHaveBeenCalledWith( + req.user, + 'federated-access-token', + 'https://graph.microsoft.com/.default', + ); + expect(getGraphApiToken).not.toHaveBeenCalledWith( + expect.anything(), + 'app-jwt-which-is-id-token', + expect.anything(), + ); + }); + + it('should return the graph token response on success', async () => { + await graphTokenController(req, res); + + expect(res.json).toHaveBeenCalledWith({ + access_token: 'graph-access-token', + token_type: 'Bearer', + expires_in: 3600, + }); + }); + + it('should return 403 when user is not authenticated via Entra ID', async () => { + req.user.provider = 'google'; + req.user.openidId = undefined; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => { + isEnabled.mockReturnValue(false); + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 400 when scopes query param is missing', async () => { + req.query.scopes = undefined; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 401 when federatedTokens.access_token is missing', async () => { + req.user.federatedTokens = {}; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 401 when federatedTokens is absent entirely', async () => { + req.user.federatedTokens = undefined; + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(getGraphApiToken).not.toHaveBeenCalled(); + }); + + it('should return 500 when getGraphApiToken throws', async () => { + getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed')); + + await graphTokenController(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + message: 'Failed to obtain Microsoft Graph token', + }); + }); +}); diff --git a/api/server/services/GraphTokenService.js b/api/server/services/GraphTokenService.js index d5cd6a94f2..843adbe5a2 100644 --- a/api/server/services/GraphTokenService.js +++ b/api/server/services/GraphTokenService.js @@ -7,7 +7,7 @@ const getLogStores = require('~/cache/getLogStores'); /** * Get Microsoft Graph API token using existing token exchange mechanism * @param {Object} user - User object with OpenID information - * @param {string} accessToken - Current access token from Authorization header + * @param {string} accessToken - Federated access token used as OBO assertion * @param {string} scopes - Graph API scopes for the token * @param {boolean} fromCache - Whether to try getting token from cache first * @returns {Promise} Graph API token response with access_token and expires_in From b7bfdfa8b20fa53e80268fc4bd070348ab5febf7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 21 Feb 2026 18:06:02 -0500 Subject: [PATCH 009/110] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Handle=20Delimite?= =?UTF-8?q?d=20String=20Role=20Claims=20in=20OpenID=20Strategy=20(#11892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: handle space/comma-separated string roles claim in OpenID strategy When an OpenID provider returns the roles claim as a delimited string (e.g. "role1 role2 admin"), the previous code wrapped the entire string as a single array element, causing role checks to always fail even for users with the required role. Split string claims on whitespace and commas before comparison so that both array and delimited-string formats are handled correctly. Adds regression tests for space-separated, comma-separated, mixed, and non-matching delimited string cases. * fix: enhance admin role handling in OpenID strategy Updated the OpenID strategy to correctly handle admin roles specified as space-separated or comma-separated strings. The logic now splits these strings into an array for accurate role checks. Added tests to verify that admin roles are granted or denied based on the presence of the specified admin role in the delimited string format. --- api/strategies/openidStrategy.js | 15 +++-- api/strategies/openidStrategy.spec.js | 96 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 198c8735ae..15e21f67ef 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -451,7 +451,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { throw new Error(`You must have ${rolesList} role to log in.`); } - const roleValues = Array.isArray(roles) ? roles : [roles]; + const roleValues = Array.isArray(roles) ? roles : roles.split(/[\s,]+/).filter(Boolean); if (!requiredRoles.some((role) => roleValues.includes(role))) { const rolesList = @@ -524,13 +524,14 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { } const adminRoles = get(adminRoleObject, adminRoleParameterPath); + let adminRoleValues = []; + if (Array.isArray(adminRoles)) { + adminRoleValues = adminRoles; + } else if (typeof adminRoles === 'string') { + adminRoleValues = adminRoles.split(/[\s,]+/).filter(Boolean); + } - if ( - adminRoles && - (adminRoles === true || - adminRoles === adminRole || - (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) - ) { + if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) { user.role = SystemRoles.ADMIN; logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`); } else if (user.role === SystemRoles.ADMIN) { diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index b1dc54d77b..00c65106ad 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -384,6 +384,62 @@ describe('setupOpenId', () => { expect(details.message).toBe('You must have "read" role to log in.'); }); + it('should allow login when roles claim is a space-separated string containing the required role', async () => { + // Arrange – IdP returns roles as a space-delimited string + jwtDecode.mockReturnValue({ + roles: 'role1 role2 requiredRole', + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – login succeeds when required role is present after splitting + expect(user).toBeTruthy(); + expect(createUser).toHaveBeenCalled(); + }); + + it('should allow login when roles claim is a comma-separated string containing the required role', async () => { + // Arrange – IdP returns roles as a comma-delimited string + jwtDecode.mockReturnValue({ + roles: 'role1,role2,requiredRole', + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – login succeeds when required role is present after splitting + expect(user).toBeTruthy(); + expect(createUser).toHaveBeenCalled(); + }); + + it('should allow login when roles claim is a mixed comma-and-space-separated string containing the required role', async () => { + // Arrange – IdP returns roles with comma-and-space delimiters + jwtDecode.mockReturnValue({ + roles: 'role1, role2, requiredRole', + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – login succeeds when required role is present after splitting + expect(user).toBeTruthy(); + expect(createUser).toHaveBeenCalled(); + }); + + it('should reject login when roles claim is a space-separated string that does not contain the required role', async () => { + // Arrange – IdP returns a delimited string but required role is absent + jwtDecode.mockReturnValue({ + roles: 'role1 role2 otherRole', + }); + + // Act + const { user, details } = await validate(tokenset); + + // Assert – login is rejected with the correct error message + expect(user).toBe(false); + expect(details.message).toBe('You must have "requiredRole" role to log in.'); + }); + it('should allow login when single required role is present (backward compatibility)', async () => { // Arrange – ensure single role configuration (as set in beforeEach) // OPENID_REQUIRED_ROLE = 'requiredRole' @@ -1182,6 +1238,46 @@ describe('setupOpenId', () => { expect(user.role).toBeUndefined(); }); + it('should grant admin when admin role claim is a space-separated string containing the admin role', async () => { + // Arrange – IdP returns admin roles as a space-delimited string + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: 'user site-admin moderator', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + // Act + const { user } = await validate(tokenset); + + // Assert – admin role is granted after splitting the delimited string + expect(user.role).toBe('ADMIN'); + }); + + it('should not grant admin when admin role claim is a space-separated string that does not contain the admin role', async () => { + // Arrange – delimited string present but admin role is absent + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: 'user moderator', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + // Act + const { user } = await validate(tokenset); + + // Assert – admin role is not granted + expect(user.role).toBeUndefined(); + }); + it('should handle nested path with special characters in keys', async () => { process.env.OPENID_REQUIRED_ROLE = 'app-user'; process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles'; From 7692fa837e959d2a997738a99ad2a33f9ad8ee7e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 21 Feb 2026 18:36:48 -0500 Subject: [PATCH 010/110] =?UTF-8?q?=F0=9F=AA=A3=20fix:=20S3=20path-style?= =?UTF-8?q?=20URL=20support=20for=20MinIO,=20R2,=20and=20custom=20endpoint?= =?UTF-8?q?s=20(#11894)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🪣 fix: S3 path-style URL support for MinIO, R2, and custom endpoints `extractKeyFromS3Url` now uses `AWS_BUCKET_NAME` to automatically detect and strip the bucket prefix from path-style URLs, fixing `NoSuchKey` errors on URL refresh for any S3-compatible provider using a custom endpoint (MinIO, Cloudflare R2, Hetzner, Backblaze B2, etc.). No additional configuration required — the bucket name is already a required env var for S3 to function. `initializeS3` now passes `forcePathStyle: true` to the S3Client constructor when `AWS_FORCE_PATH_STYLE=true` is set. Required for providers whose SSL certificates do not support virtual-hosted-style bucket subdomains (e.g. Hetzner Object Storage), which previously caused 401 / SignatureDoesNotMatch on upload. Additional fixes: - Suppress error log noise in `extractKeyFromS3Url` catch path: plain S3 keys no longer log as errors, only inputs that start with http(s):// do - Fix test env var ordering so module-level constants pick up `AWS_BUCKET_NAME` and `S3_URL_EXPIRY_SECONDS` correctly before the module is required - Add missing `deleteRagFile` mock and assertion in `deleteFileFromS3` tests - Add `AWS_BUCKET_NAME` cleanup to `afterEach` to prevent cross-test pollution - Add `initializeS3` unit tests covering endpoint, forcePathStyle, credentials, singleton, and IRSA code paths - Document `AWS_FORCE_PATH_STYLE` in `.env.example`, `dotenv.mdx`, and `s3.mdx` * 🪣 fix: Enhance S3 URL key extraction for custom endpoints Updated `extractKeyFromS3Url` to support precise key extraction when using custom endpoints with path-style URLs. The logic now accounts for the `AWS_ENDPOINT_URL` and `AWS_FORCE_PATH_STYLE` environment variables, ensuring correct key handling for various S3-compatible providers. Added unit tests to verify the new functionality, including scenarios for endpoints with base paths. This improves compatibility and reduces potential errors when interacting with S3-like services. --- .env.example | 3 + api/server/services/Files/S3/crud.js | 24 ++++- api/test/services/Files/S3/crud.test.js | 31 ++++++ packages/api/src/cdn/__tests__/s3.test.ts | 123 ++++++++++++++++++++++ packages/api/src/cdn/s3.ts | 3 +- 5 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/cdn/__tests__/s3.test.ts diff --git a/.env.example b/.env.example index 50229b1997..b7ec3a3dad 100644 --- a/.env.example +++ b/.env.example @@ -658,6 +658,9 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_REGION= AWS_BUCKET_NAME= +# Required for path-style S3-compatible providers (MinIO, Hetzner, Backblaze B2, etc.) +# that don't support virtual-hosted-style URLs (bucket.endpoint). Not needed for AWS S3. +# AWS_FORCE_PATH_STYLE=false #========================# # Azure Blob Storage # diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index efd2d7734b..c821c0696c 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -3,7 +3,7 @@ const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); const { FileSources } = require('librechat-data-provider'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); -const { initializeS3, deleteRagFile } = require('@librechat/api'); +const { initializeS3, deleteRagFile, isEnabled } = require('@librechat/api'); const { PutObjectCommand, GetObjectCommand, @@ -13,6 +13,8 @@ const { const bucketName = process.env.AWS_BUCKET_NAME; const defaultBasePath = 'images'; +const endpoint = process.env.AWS_ENDPOINT_URL; +const forcePathStyle = isEnabled(process.env.AWS_FORCE_PATH_STYLE); let s3UrlExpirySeconds = 2 * 60; // 2 minutes let s3RefreshExpiryMs = null; @@ -255,6 +257,26 @@ function extractKeyFromS3Url(fileUrlOrKey) { const hostname = url.hostname; const pathname = url.pathname.substring(1); // Remove leading slash + // Explicit path-style with custom endpoint: use endpoint pathname for precise key extraction. + // Handles endpoints with a base path (e.g. https://example.com/storage/). + if (endpoint && forcePathStyle) { + const endpointUrl = new URL(endpoint); + const startPos = + endpointUrl.pathname.length + + (endpointUrl.pathname.endsWith('/') ? 0 : 1) + + bucketName.length + + 1; + const key = url.pathname.substring(startPos); + if (!key) { + logger.warn( + `[extractKeyFromS3Url] Extracted key is empty for endpoint path-style URL: ${fileUrlOrKey}`, + ); + } else { + logger.debug(`[extractKeyFromS3Url] fileUrlOrKey: ${fileUrlOrKey}, Extracted key: ${key}`); + } + return key; + } + if ( hostname === 's3.amazonaws.com' || hostname.match(/^s3[-.][a-z0-9-]+\.amazonaws\.com$/) || diff --git a/api/test/services/Files/S3/crud.test.js b/api/test/services/Files/S3/crud.test.js index c55bbbcf97..c7b46fba4c 100644 --- a/api/test/services/Files/S3/crud.test.js +++ b/api/test/services/Files/S3/crud.test.js @@ -19,6 +19,7 @@ jest.mock('@aws-sdk/client-s3'); jest.mock('@librechat/api', () => ({ initializeS3: jest.fn(), deleteRagFile: jest.fn().mockResolvedValue(undefined), + isEnabled: jest.fn((val) => val === 'true'), })); jest.mock('@librechat/data-schemas', () => ({ @@ -841,5 +842,35 @@ describe('S3 CRUD Operations', () => { ), ).toBe('images/user123/avatar.png'); }); + + it('should use endpoint base path when AWS_ENDPOINT_URL and AWS_FORCE_PATH_STYLE are set', () => { + process.env.AWS_BUCKET_NAME = 'test-bucket'; + process.env.AWS_ENDPOINT_URL = 'https://minio.example.com'; + process.env.AWS_FORCE_PATH_STYLE = 'true'; + jest.resetModules(); + const { extractKeyFromS3Url: fn } = require('~/server/services/Files/S3/crud'); + + expect(fn('https://minio.example.com/test-bucket/images/user123/file.jpg')).toBe( + 'images/user123/file.jpg', + ); + + delete process.env.AWS_ENDPOINT_URL; + delete process.env.AWS_FORCE_PATH_STYLE; + }); + + it('should handle endpoint with a base path', () => { + process.env.AWS_BUCKET_NAME = 'test-bucket'; + process.env.AWS_ENDPOINT_URL = 'https://example.com/storage/'; + process.env.AWS_FORCE_PATH_STYLE = 'true'; + jest.resetModules(); + const { extractKeyFromS3Url: fn } = require('~/server/services/Files/S3/crud'); + + expect(fn('https://example.com/storage/test-bucket/images/user123/file.jpg')).toBe( + 'images/user123/file.jpg', + ); + + delete process.env.AWS_ENDPOINT_URL; + delete process.env.AWS_FORCE_PATH_STYLE; + }); }); }); diff --git a/packages/api/src/cdn/__tests__/s3.test.ts b/packages/api/src/cdn/__tests__/s3.test.ts new file mode 100644 index 0000000000..048c652a45 --- /dev/null +++ b/packages/api/src/cdn/__tests__/s3.test.ts @@ -0,0 +1,123 @@ +import type { S3Client } from '@aws-sdk/client-s3'; + +const mockLogger = { info: jest.fn(), error: jest.fn() }; + +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, +})); + +describe('initializeS3', () => { + const REQUIRED_ENV = { + AWS_REGION: 'us-east-1', + AWS_BUCKET_NAME: 'test-bucket', + AWS_ACCESS_KEY_ID: 'test-key-id', + AWS_SECRET_ACCESS_KEY: 'test-secret', + }; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + Object.assign(process.env, REQUIRED_ENV); + delete process.env.AWS_ENDPOINT_URL; + delete process.env.AWS_FORCE_PATH_STYLE; + }); + + afterEach(() => { + for (const key of Object.keys(REQUIRED_ENV)) { + delete process.env[key]; + } + delete process.env.AWS_ENDPOINT_URL; + delete process.env.AWS_FORCE_PATH_STYLE; + }); + + async function load() { + const { S3Client: MockS3Client } = jest.requireMock('@aws-sdk/client-s3') as { + S3Client: jest.MockedClass; + }; + const { initializeS3 } = await import('../s3'); + return { MockS3Client, initializeS3 }; + } + + it('should initialize with region and credentials', async () => { + const { MockS3Client, initializeS3 } = await load(); + initializeS3(); + expect(MockS3Client).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'us-east-1', + credentials: { accessKeyId: 'test-key-id', secretAccessKey: 'test-secret' }, + }), + ); + }); + + it('should include endpoint when AWS_ENDPOINT_URL is set', async () => { + process.env.AWS_ENDPOINT_URL = 'https://fsn1.your-objectstorage.com'; + const { MockS3Client, initializeS3 } = await load(); + initializeS3(); + expect(MockS3Client).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'https://fsn1.your-objectstorage.com' }), + ); + }); + + it('should not include endpoint when AWS_ENDPOINT_URL is not set', async () => { + const { MockS3Client, initializeS3 } = await load(); + initializeS3(); + const config = MockS3Client.mock.calls[0][0] as Record; + expect(config).not.toHaveProperty('endpoint'); + }); + + it('should set forcePathStyle when AWS_FORCE_PATH_STYLE is true', async () => { + process.env.AWS_FORCE_PATH_STYLE = 'true'; + const { MockS3Client, initializeS3 } = await load(); + initializeS3(); + expect(MockS3Client).toHaveBeenCalledWith(expect.objectContaining({ forcePathStyle: true })); + }); + + it('should not set forcePathStyle when AWS_FORCE_PATH_STYLE is false', async () => { + process.env.AWS_FORCE_PATH_STYLE = 'false'; + const { MockS3Client, initializeS3 } = await load(); + initializeS3(); + const config = MockS3Client.mock.calls[0][0] as Record; + expect(config).not.toHaveProperty('forcePathStyle'); + }); + + it('should not set forcePathStyle when AWS_FORCE_PATH_STYLE is not set', async () => { + const { MockS3Client, initializeS3 } = await load(); + initializeS3(); + const config = MockS3Client.mock.calls[0][0] as Record; + expect(config).not.toHaveProperty('forcePathStyle'); + }); + + it('should return null and log error when AWS_REGION is not set', async () => { + delete process.env.AWS_REGION; + const { initializeS3 } = await load(); + const result = initializeS3(); + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + '[initializeS3] AWS_REGION is not set. Cannot initialize S3.', + ); + }); + + it('should return the same instance on subsequent calls', async () => { + const { MockS3Client, initializeS3 } = await load(); + const first = initializeS3(); + const second = initializeS3(); + expect(first).toBe(second); + expect(MockS3Client).toHaveBeenCalledTimes(1); + }); + + it('should use default credentials chain when keys are not provided', async () => { + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + const { MockS3Client, initializeS3 } = await load(); + initializeS3(); + const config = MockS3Client.mock.calls[0][0] as Record; + expect(config).not.toHaveProperty('credentials'); + expect(mockLogger.info).toHaveBeenCalledWith( + '[initializeS3] S3 initialized using default credentials (IRSA).', + ); + }); +}); diff --git a/packages/api/src/cdn/s3.ts b/packages/api/src/cdn/s3.ts index 683a7887fa..f6f8527ce4 100644 --- a/packages/api/src/cdn/s3.ts +++ b/packages/api/src/cdn/s3.ts @@ -1,5 +1,6 @@ import { S3Client } from '@aws-sdk/client-s3'; import { logger } from '@librechat/data-schemas'; +import { isEnabled } from '~/utils/common'; let s3: S3Client | null = null; @@ -31,8 +32,8 @@ export const initializeS3 = (): S3Client | null => { const config = { region, - // Conditionally add the endpoint if it is provided ...(endpoint ? { endpoint } : {}), + ...(isEnabled(process.env.AWS_FORCE_PATH_STYLE) ? { forcePathStyle: true } : {}), }; if (accessKeyId && secretAccessKey) { From 7ce898d6a0367ad9b2cac0b123b498f7cc924e23 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 22 Feb 2026 14:22:45 -0500 Subject: [PATCH 011/110] =?UTF-8?q?=F0=9F=93=84=20feat:=20Local=20Text=20E?= =?UTF-8?q?xtraction=20for=20PDF,=20DOCX,=20and=20XLS/XLSX=20(#11900)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Added "document parser" OCR strategy The document parser uses libraries to parse the text out of known document types. This lets LibreChat handle some complex document types without having to use a secondary service (like Mistral or standing up a RAG API server). To enable the document parser, set the ocr strategy to "document_parser" in librechat.yaml. We now support: - PDFs using pdfjs - DOCX using mammoth - XLS/XLSX using SheetJS (The associated packages were also added to the project.) * fix: applied Copilot code review suggestions - Properly calculate length of text based on UTF8. - Avoid issues with loading / blocking PDF parsing. * fix: improved docs on parseDocument() * chore: move to packages/api for TS support * refactor: make document processing the default ocr strategy - Introduced support for additional document types in the OCR strategy, including PDF, DOCX, and XLS/XLSX. - Updated the file upload handling to dynamically select the appropriate parsing strategy based on the file type. - Refactored the document parsing functions to use asynchronous imports for improved performance and maintainability. * test: add unit tests for processAgentFileUpload functionality - Introduced a new test suite for the processAgentFileUpload function in process.spec.js. - Implemented various test cases to validate OCR strategy selection based on file types, including PDF, DOCX, XLSX, and XLS. - Mocked dependencies to ensure isolated testing of file upload handling and strategy selection logic. - Enhanced coverage for scenarios involving OCR capability checks and default strategy fallbacks. * chore: update pdfjs-dist version and enhance document parsing tests - Bumped pdfjs-dist dependency to version 5.4.624 in both api and packages/api. - Refactored document parsing tests to use 'originalname' instead of 'filename' for file objects. - Added a new test case for parsing XLS files to improve coverage of document types supported by the parser. - Introduced a sample XLS file for testing purposes. * feat: enforce text size limit and improve OCR fallback handling in processAgentFileUpload - Added a check to ensure extracted text does not exceed the 15MB storage limit, throwing an error if it does. - Refactored the OCR handling logic to improve fallback behavior when the configured OCR fails, ensuring a more robust document processing flow. - Enhanced unit tests to cover scenarios for oversized text and fallback mechanisms, ensuring proper error handling and functionality. * fix: correct OCR URL construction in performOCR function - Updated the OCR URL construction to ensure it correctly appends '/ocr' to the base URL if not already present, improving the reliability of the OCR request. --------- Co-authored-by: Dan Lew --- api/package.json | 3 + api/server/services/Files/process.js | 68 ++- api/server/services/Files/process.spec.js | 323 +++++++++++++ api/server/services/Files/strategies.js | 23 + package-lock.json | 439 +++++++++++++++++- packages/api/package.json | 7 +- packages/api/src/files/documents/crud.spec.ts | 80 ++++ packages/api/src/files/documents/crud.ts | 87 ++++ packages/api/src/files/documents/empty.docx | Bin 0 -> 6514 bytes packages/api/src/files/documents/sample.docx | Bin 0 -> 6553 bytes packages/api/src/files/documents/sample.xls | Bin 0 -> 3584 bytes packages/api/src/files/documents/sample.xlsx | Bin 0 -> 6111 bytes packages/api/src/files/index.ts | 1 + packages/api/src/files/mistral/crud.ts | 4 +- packages/data-provider/src/config.ts | 1 + packages/data-provider/src/types/files.ts | 1 + 16 files changed, 1012 insertions(+), 25 deletions(-) create mode 100644 api/server/services/Files/process.spec.js create mode 100644 packages/api/src/files/documents/crud.spec.ts create mode 100644 packages/api/src/files/documents/crud.ts create mode 100644 packages/api/src/files/documents/empty.docx create mode 100644 packages/api/src/files/documents/sample.docx create mode 100644 packages/api/src/files/documents/sample.xls create mode 100644 packages/api/src/files/documents/sample.xlsx diff --git a/api/package.json b/api/package.json index 4542e25745..9951b6f01a 100644 --- a/api/package.json +++ b/api/package.json @@ -80,6 +80,7 @@ "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.23", + "mammoth": "^1.11.0", "mathjs": "^15.1.0", "meilisearch": "^0.38.0", "memorystore": "^1.6.7", @@ -102,6 +103,7 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", + "pdfjs-dist": "^5.4.624", "rate-limit-redis": "^4.2.0", "sharp": "^0.33.5", "tiktoken": "^1.0.15", @@ -110,6 +112,7 @@ "undici": "^7.18.2", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.22.4" }, "devDependencies": { diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 30b47f2e52..d69be6a00c 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -523,6 +523,12 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { * @return {Promise} */ const createTextFile = async ({ text, bytes, filepath, type = 'text/plain' }) => { + const textBytes = Buffer.byteLength(text, 'utf8'); + if (textBytes > 15 * megabyte) { + throw new Error( + `Extracted text from "${file.originalname}" exceeds the 15MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`, + ); + } const fileInfo = removeNullishValues({ text, bytes, @@ -553,29 +559,59 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { const fileConfig = mergeFileConfig(appConfig.fileConfig); - const shouldUseOCR = + const documentParserMimeTypes = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]; + + const shouldUseConfiguredOCR = appConfig?.ocr != null && fileConfig.checkType(file.mimetype, fileConfig.ocr?.supportedMimeTypes || []); - if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) { - throw new Error('OCR capability is not enabled for Agents'); - } else if (shouldUseOCR) { + const shouldUseDocumentParser = + !shouldUseConfiguredOCR && documentParserMimeTypes.includes(file.mimetype); + + const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser; + + const resolveDocumentText = async () => { + if (shouldUseConfiguredOCR) { + try { + const ocrStrategy = appConfig?.ocr?.strategy ?? FileSources.document_parser; + const { handleFileUpload } = getStrategyFunctions(ocrStrategy); + return await handleFileUpload({ req, file, loadAuthValues }); + } catch (err) { + logger.error( + `[processAgentFileUpload] Configured OCR failed for "${file.originalname}", falling back to document_parser:`, + err, + ); + } + } try { - const { handleFileUpload: uploadOCR } = getStrategyFunctions( - appConfig?.ocr?.strategy ?? FileSources.mistral_ocr, - ); - const { - text, - bytes, - filepath: ocrFileURL, - } = await uploadOCR({ req, file, loadAuthValues }); - return await createTextFile({ text, bytes, filepath: ocrFileURL }); - } catch (ocrError) { + const { handleFileUpload } = getStrategyFunctions(FileSources.document_parser); + return await handleFileUpload({ req, file, loadAuthValues }); + } catch (err) { logger.error( - `[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`, - ocrError, + `[processAgentFileUpload] Document parser failed for "${file.originalname}":`, + err, ); } + }; + + if (shouldUseConfiguredOCR && !(await checkCapability(req, AgentCapabilities.ocr))) { + throw new Error('OCR capability is not enabled for Agents'); + } + + if (shouldUseOCR) { + const ocrResult = await resolveDocumentText(); + if (ocrResult) { + const { text, bytes, filepath: ocrFileURL } = ocrResult; + return await createTextFile({ text, bytes, filepath: ocrFileURL }); + } + throw new Error( + `Unable to extract text from "${file.originalname}". The document may be image-based and requires an OCR service to process.`, + ); } const shouldUseSTT = fileConfig.checkType( diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js new file mode 100644 index 0000000000..2938391ff2 --- /dev/null +++ b/api/server/services/Files/process.spec.js @@ -0,0 +1,323 @@ +jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') })); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, +})); + +jest.mock('@librechat/agents', () => ({ + EnvVar: { CODE_API_KEY: 'CODE_API_KEY' }, +})); + +jest.mock('@librechat/api', () => ({ + sanitizeFilename: jest.fn((n) => n), + parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }), + processAudioFile: jest.fn(), +})); + +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + mergeFileConfig: jest.fn(), +})); + +jest.mock('~/server/services/Files/images', () => ({ + convertImage: jest.fn(), + resizeAndConvert: jest.fn(), + resizeImageBuffer: jest.fn(), +})); + +jest.mock('~/server/controllers/assistants/v2', () => ({ + addResourceFileId: jest.fn(), + deleteResourceFileId: jest.fn(), +})); + +jest.mock('~/models/Agent', () => ({ + addAgentResourceFile: jest.fn().mockResolvedValue({}), + removeAgentResourceFiles: jest.fn(), +})); + +jest.mock('~/server/controllers/assistants/helpers', () => ({ + getOpenAIClient: jest.fn(), +})); + +jest.mock('~/server/services/Tools/credentials', () => ({ + loadAuthValues: jest.fn(), +})); + +jest.mock('~/models', () => ({ + createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }), + updateFileUsage: jest.fn(), + deleteFiles: jest.fn(), +})); + +jest.mock('~/server/utils/getFileStrategy', () => ({ + getFileStrategy: jest.fn().mockReturnValue('local'), +})); + +jest.mock('~/server/services/Config', () => ({ + checkCapability: jest.fn().mockResolvedValue(true), +})); + +jest.mock('~/server/utils/queue', () => ({ + LB_QueueAsyncCall: jest.fn(), +})); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(), +})); + +jest.mock('~/server/utils', () => ({ + determineFileType: jest.fn(), +})); + +jest.mock('~/server/services/Files/Audio/STTService', () => ({ + STTService: { getInstance: jest.fn() }, +})); + +const { EToolResources, FileSources, AgentCapabilities } = require('librechat-data-provider'); +const { mergeFileConfig } = require('librechat-data-provider'); +const { checkCapability } = require('~/server/services/Config'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { processAgentFileUpload } = require('./process'); + +const PDF_MIME = 'application/pdf'; +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; +const XLS_MIME = 'application/vnd.ms-excel'; + +const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({ + user: { id: 'user-123' }, + file: { + path: '/tmp/upload.bin', + originalname: 'upload.bin', + filename: 'upload-uuid.bin', + mimetype, + }, + body: { model: 'gpt-4o' }, + config: { + fileConfig: {}, + fileStrategy: 'local', + ocr: ocrConfig, + }, +}); + +const makeMetadata = () => ({ + agent_id: 'agent-abc', + tool_resource: EToolResources.context, + file_id: 'file-uuid-123', +}); + +const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnValue({}), +}; + +const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({ + checkType: (mime, types) => (types ?? []).includes(mime), + ocr: { supportedMimeTypes: ocrSupportedMimeTypes }, + stt: { supportedMimeTypes: [] }, + text: { supportedMimeTypes: [] }, +}); + +describe('processAgentFileUpload', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRes.status.mockReturnThis(); + mockRes.json.mockReturnValue({}); + checkCapability.mockResolvedValue(true); + getStrategyFunctions.mockReturnValue({ + handleFileUpload: jest + .fn() + .mockResolvedValue({ text: 'extracted text', bytes: 42, filepath: 'doc://result' }), + }); + mergeFileConfig.mockReturnValue(makeFileConfig()); + }); + + describe('OCR strategy selection', () => { + test.each([ + ['PDF', PDF_MIME], + ['DOCX', DOCX_MIME], + ['XLSX', XLSX_MIME], + ['XLS', XLS_MIME], + ])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => { + mergeFileConfig.mockReturnValue(makeFileConfig()); + const req = makeReq({ mimetype: mime, ocrConfig: null }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser); + }); + + test('does not check OCR capability when using automatic document_parser fallback', async () => { + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser); + }); + + test('uses the configured OCR strategy when OCR is set up for the file type', async () => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] })); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: { strategy: FileSources.mistral_ocr }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr); + }); + + test('uses document_parser as default when OCR is configured but no strategy is specified', async () => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] })); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: { supportedMimeTypes: [PDF_MIME] }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser); + }); + + test('throws when configured OCR capability is not enabled for the agent', async () => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] })); + checkCapability.mockResolvedValue(false); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: { strategy: FileSources.mistral_ocr }, + }); + + await expect( + processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }), + ).rejects.toThrow('OCR capability is not enabled for Agents'); + }); + + test('uses document_parser (no capability check) when OCR capability returns false but no OCR config', async () => { + checkCapability.mockResolvedValue(false); + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser); + }); + + test('uses document_parser when OCR is configured but the file type is not in OCR supported types', async () => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] })); + const req = makeReq({ + mimetype: DOCX_MIME, + ocrConfig: { strategy: FileSources.mistral_ocr }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(checkCapability).not.toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser); + expect(getStrategyFunctions).not.toHaveBeenCalledWith(FileSources.mistral_ocr); + }); + + test('does not invoke any OCR strategy for unsupported MIME types without OCR config', async () => { + const req = makeReq({ mimetype: 'text/plain', ocrConfig: null }); + + await expect( + processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }), + ).rejects.toThrow('File type text/plain is not supported for text parsing.'); + + expect(getStrategyFunctions).not.toHaveBeenCalled(); + }); + + test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => { + getStrategyFunctions.mockReturnValue({ + handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')), + }); + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + const { parseText } = require('@librechat/api'); + + await expect( + processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }), + ).rejects.toThrow(/image-based and requires an OCR service/); + + expect(parseText).not.toHaveBeenCalled(); + }); + + test('falls back to document_parser when configured OCR fails for a document MIME type', async () => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] })); + const failingUpload = jest.fn().mockRejectedValue(new Error('OCR API returned 500')); + const fallbackUpload = jest + .fn() + .mockResolvedValue({ text: 'parsed text', bytes: 11, filepath: 'doc://result' }); + getStrategyFunctions + .mockReturnValueOnce({ handleFileUpload: failingUpload }) + .mockReturnValueOnce({ handleFileUpload: fallbackUpload }); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: { strategy: FileSources.mistral_ocr }, + }); + + await expect( + processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }), + ).resolves.not.toThrow(); + + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.document_parser); + }); + + test('throws when both configured OCR and document_parser fallback fail', async () => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [PDF_MIME] })); + getStrategyFunctions.mockReturnValue({ + handleFileUpload: jest.fn().mockRejectedValue(new Error('failure')), + }); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: { strategy: FileSources.mistral_ocr }, + }); + const { parseText } = require('@librechat/api'); + + await expect( + processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }), + ).rejects.toThrow(/image-based and requires an OCR service/); + + expect(parseText).not.toHaveBeenCalled(); + }); + }); + + describe('text size guard', () => { + test('throws before writing to MongoDB when extracted text exceeds 15MB', async () => { + const oversizedText = 'x'.repeat(15 * 1024 * 1024 + 1); + getStrategyFunctions.mockReturnValue({ + handleFileUpload: jest.fn().mockResolvedValue({ + text: oversizedText, + bytes: Buffer.byteLength(oversizedText, 'utf8'), + filepath: 'doc://result', + }), + }); + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + const { createFile } = require('~/models'); + + await expect( + processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }), + ).rejects.toThrow(/exceeds the 15MB storage limit/); + + expect(createFile).not.toHaveBeenCalled(); + }); + + test('succeeds when extracted text is within the 15MB limit', async () => { + const okText = 'x'.repeat(1024); + getStrategyFunctions.mockReturnValue({ + handleFileUpload: jest.fn().mockResolvedValue({ + text: okText, + bytes: Buffer.byteLength(okText, 'utf8'), + filepath: 'doc://result', + }), + }); + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + + await expect( + processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/api/server/services/Files/strategies.js b/api/server/services/Files/strategies.js index 2ad526194b..25341b5715 100644 --- a/api/server/services/Files/strategies.js +++ b/api/server/services/Files/strategies.js @@ -1,5 +1,6 @@ const { FileSources } = require('librechat-data-provider'); const { + parseDocument, uploadMistralOCR, uploadAzureMistralOCR, uploadGoogleVertexMistralOCR, @@ -246,6 +247,26 @@ const vertexMistralOCRStrategy = () => ({ handleFileUpload: uploadGoogleVertexMistralOCR, }); +const documentParserStrategy = () => ({ + /** @type {typeof saveFileFromURL | null} */ + saveURL: null, + /** @type {typeof getLocalFileURL | null} */ + getFileURL: null, + /** @type {typeof saveLocalBuffer | null} */ + saveBuffer: null, + /** @type {typeof processLocalAvatar | null} */ + processAvatar: null, + /** @type {typeof uploadLocalImage | null} */ + handleImageUpload: null, + /** @type {typeof prepareImagesLocal | null} */ + prepareImagePayload: null, + /** @type {typeof deleteLocalFile | null} */ + deleteFile: null, + /** @type {typeof getLocalFileStream | null} */ + getDownloadStream: null, + handleFileUpload: parseDocument, +}); + // Strategy Selector const getStrategyFunctions = (fileSource) => { if (fileSource === FileSources.firebase) { @@ -270,6 +291,8 @@ const getStrategyFunctions = (fileSource) => { return azureMistralOCRStrategy(); } else if (fileSource === FileSources.vertexai_mistral_ocr) { return vertexMistralOCRStrategy(); + } else if (fileSource === FileSources.document_parser) { + return documentParserStrategy(); } else if (fileSource === FileSources.text) { return localStrategy(); // Text files use local strategy } else { diff --git a/package-lock.json b/package-lock.json index 4bca60d435..04f8251dd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "klona": "^2.0.6", "librechat-data-provider": "*", "lodash": "^4.17.23", + "mammoth": "^1.11.0", "mathjs": "^15.1.0", "meilisearch": "^0.38.0", "memorystore": "^1.6.7", @@ -117,6 +118,7 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", + "pdfjs-dist": "^5.4.530", "rate-limit-redis": "^4.2.0", "sharp": "^0.33.5", "tiktoken": "^1.0.15", @@ -125,6 +127,7 @@ "undici": "^7.18.2", "winston": "^3.11.0", "winston-daily-rotate-file": "^5.0.0", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^3.22.4" }, "devDependencies": { @@ -11380,6 +11383,256 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz", + "integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.88", + "@napi-rs/canvas-darwin-arm64": "0.1.88", + "@napi-rs/canvas-darwin-x64": "0.1.88", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", + "@napi-rs/canvas-linux-arm64-musl": "0.1.88", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-gnu": "0.1.88", + "@napi-rs/canvas-linux-x64-musl": "0.1.88", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", + "@napi-rs/canvas-win32-x64-msvc": "0.1.88" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz", + "integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz", + "integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz", + "integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz", + "integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz", + "integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz", + "integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz", + "integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz", + "integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz", + "integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz", + "integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.88", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz", + "integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -21601,6 +21854,12 @@ "node": ">=8" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", @@ -22854,7 +23113,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cors": { @@ -24239,6 +24497,12 @@ "dev": true, "license": "MIT" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -24375,6 +24639,15 @@ "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==" }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -27588,6 +27861,12 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", @@ -30052,6 +30331,45 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -30354,6 +30672,15 @@ "resolved": "packages/data-provider", "link": true }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -30997,6 +31324,17 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lowlight": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", @@ -31106,6 +31444,48 @@ "tmpl": "1.0.5" } }, + "node_modules/mammoth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", + "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/mammoth/node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -33039,6 +33419,13 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -33522,6 +33909,12 @@ "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "dev": true }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -33674,7 +34067,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -33953,7 +34345,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -34032,6 +34423,19 @@ "node": ">= 0.10" } }, + "node_modules/pdfjs-dist": { + "version": "5.4.624", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.624.tgz", + "integrity": "sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.88", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, "node_modules/peek-readable": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz", @@ -35713,7 +36117,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/promise.series": { @@ -38176,7 +38579,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, "license": "MIT" }, "node_modules/setprototypeof": { @@ -38441,7 +38843,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/sse.js": { @@ -40249,6 +40650,12 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", @@ -41904,6 +42311,18 @@ } } }, + "node_modules/xlsx": { + "version": "0.20.3", + "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", + "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==", + "license": "Apache-2.0", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -42171,12 +42590,15 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "librechat-data-provider": "*", + "mammoth": "^1.11.0", "mongodb": "^6.14.2", + "pdfjs-dist": "^5.4.624", "rimraf": "^6.1.2", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", "ts-node": "^10.9.2", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "peerDependencies": { "@anthropic-ai/vertex-sdk": "^0.14.3", @@ -42207,13 +42629,16 @@ "keyv": "^5.3.2", "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "mammoth": "^1.11.0", "mathjs": "^15.1.0", "memorystore": "^1.6.7", "mongoose": "^8.12.1", "node-fetch": "2.7.0", + "pdfjs-dist": "^5.4.530", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", "undici": "^7.18.2", + "xlsx": "*", "zod": "^3.22.4" } }, diff --git a/packages/api/package.json b/packages/api/package.json index 67cb5df816..6df880e0bf 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -67,12 +67,15 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "librechat-data-provider": "*", + "mammoth": "^1.11.0", "mongodb": "^6.14.2", + "pdfjs-dist": "^5.4.624", "rimraf": "^6.1.2", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", "ts-node": "^10.9.2", - "typescript": "^5.0.4" + "typescript": "^5.0.4", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "publishConfig": { "registry": "https://registry.npmjs.org/" @@ -106,10 +109,12 @@ "keyv": "^5.3.2", "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "mammoth": "^1.11.0", "mathjs": "^15.1.0", "memorystore": "^1.6.7", "mongoose": "^8.12.1", "node-fetch": "2.7.0", + "pdfjs-dist": "^5.4.624", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", "undici": "^7.18.2", diff --git a/packages/api/src/files/documents/crud.spec.ts b/packages/api/src/files/documents/crud.spec.ts new file mode 100644 index 0000000000..3b9e1636ef --- /dev/null +++ b/packages/api/src/files/documents/crud.spec.ts @@ -0,0 +1,80 @@ +import path from 'path'; +import { parseDocument } from './crud'; + +describe('Document Parser', () => { + test('parseDocument() parses text from docx', async () => { + const file = { + originalname: 'sample.docx', + path: path.join(__dirname, 'sample.docx'), + mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document).toEqual({ + bytes: 29, + filename: 'sample.docx', + filepath: 'document_parser', + images: [], + text: 'This is a sample DOCX file.\n\n', + }); + }); + + test('parseDocument() parses text from xlsx', async () => { + const file = { + originalname: 'sample.xlsx', + path: path.join(__dirname, 'sample.xlsx'), + mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document).toEqual({ + bytes: 66, + filename: 'sample.xlsx', + filepath: 'document_parser', + images: [], + text: 'Sheet One:\nData,on,first,sheet\nSecond Sheet:\nData,On\nSecond,Sheet\n', + }); + }); + + test('parseDocument() parses text from xls', async () => { + const file = { + originalname: 'sample.xls', + path: path.join(__dirname, 'sample.xls'), + mimetype: 'application/vnd.ms-excel', + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document).toEqual({ + bytes: 31, + filename: 'sample.xls', + filepath: 'document_parser', + images: [], + text: 'Sheet One:\nData,on,first,sheet\n', + }); + }); + + test('parseDocument() throws error for unhandled document type', async () => { + const file = { + originalname: 'nonexistent.file', + path: path.join(__dirname, 'nonexistent.file'), + mimetype: 'application/invalid', + } as Express.Multer.File; + + await expect(parseDocument({ file })).rejects.toThrow( + 'Unsupported file type in document parser: application/invalid', + ); + }); + + test('parseDocument() throws error for empty document', async () => { + const file = { + originalname: 'empty.docx', + path: path.join(__dirname, 'empty.docx'), + mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + } as Express.Multer.File; + + await expect(parseDocument({ file })).rejects.toThrow('No text found in document'); + }); +}); diff --git a/packages/api/src/files/documents/crud.ts b/packages/api/src/files/documents/crud.ts new file mode 100644 index 0000000000..f2d45644d4 --- /dev/null +++ b/packages/api/src/files/documents/crud.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import { FileSources } from 'librechat-data-provider'; +import type { TextItem } from 'pdfjs-dist/types/src/display/api'; +import type { MistralOCRUploadResult } from '~/types'; + +/** + * Parses an uploaded document and extracts its text content and metadata. + * + * Throws an Error if it fails to parse or no text is found. + */ +export async function parseDocument({ + file, +}: { + file: Express.Multer.File; +}): Promise { + let text: string; + switch (file.mimetype) { + case 'application/pdf': + text = await pdfToText(file); + break; + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + text = await wordDocToText(file); + break; + case 'application/vnd.ms-excel': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + text = await excelSheetToText(file); + break; + default: + throw new Error(`Unsupported file type in document parser: ${file.mimetype}`); + } + + if (!text?.trim()) { + throw new Error('No text found in document'); + } + + return { + filename: file.originalname, + bytes: Buffer.byteLength(text, 'utf8'), + filepath: FileSources.document_parser, + text, + images: [], + }; +} + +/** Parses PDF, returns text inside. */ +async function pdfToText(file: Express.Multer.File): Promise { + // Imported inline so that Jest can test other routes without failing due to loading ESM + const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs'); + + const data = new Uint8Array(await fs.promises.readFile(file.path)); + const pdf = await getDocument({ data }).promise; + + let fullText = ''; + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + const pageText = textContent.items + .filter((item): item is TextItem => !('type' in item)) + .map((item) => item.str) + .join(' '); + fullText += pageText + '\n'; + } + + return fullText; +} + +/** Parses Word document, returns text inside. */ +async function wordDocToText(file: Express.Multer.File): Promise { + const { extractRawText } = await import('mammoth'); + const rawText = await extractRawText({ path: file.path }); + return rawText.value; +} + +/** Parses Excel sheet, returns text inside. */ +async function excelSheetToText(file: Express.Multer.File): Promise { + const { readFile, utils } = await import('xlsx'); + const workbook = readFile(file.path); + + let text = ''; + for (const sheetName of workbook.SheetNames) { + const worksheet = workbook.Sheets[sheetName]; + const worksheetAsCsvString = utils.sheet_to_csv(worksheet); + text += `${sheetName}:\n${worksheetAsCsvString}\n`; + } + + return text; +} diff --git a/packages/api/src/files/documents/empty.docx b/packages/api/src/files/documents/empty.docx new file mode 100644 index 0000000000000000000000000000000000000000..c089246167a1de579f2a12abd7a4ae9fd789d725 GIT binary patch literal 6514 zcmaJ_1yqz<*B)Z%PNf^^?vRiMr9(hqq?w_+L!<>^Xr!b|x>FjY8$@DAkw!rB1Mm0U z>-GQd-LuxLnOU>XdC%VaJp0*?suBVs0RROB1(55+Zvgnk@a{gl**ThW*t$SKW{y_2 z7VPd2a4uBI_9G|G>t~}PRQuF$HKDSIL^8X1O|{4mz;U|gxfPwTQx__1G%dn$ExhPk zyPmiu9wQ1U**8I96KBPq_emJ_d9P95<*w}E!_Gg2~n2v$|Ihsh_{(4TCWiax%~Z zzvf6`#AjNj&7G^qCJo7|z7Vqpy7qe|lwhzC%-le^q}Zh~Wt$9ibtW{8o}+B=nV!v1 zyDCm!p4KYyzb?o2(LPGEw2RT>g^Cbs>uefJll795=+cnLB;xsQl(48#IR`Nx-zYic@o%%X5(EW*76sxhc{WcyUk~w5Y%arIb8i{tMTp%46gN*q z6uhsrO9j0o?MW74@JJa}jZj0^d?;jx`?8~4O&el} zl3cu-U^0Kxg~CH;hksOLPha5d($l57HG65jFS|F_GOU*OsDlIbFi$QM(4hL~aRXx5Ub1!>Rz0Ykk zLa9pWJQP_rTv=+MbD@mFIu<)udz(cks8*3>3Sn*(x4y^|{rWLrluh$AcdRJ@3xthLSYr*0oDM?+qm}!YgT7w3#P$*9gJ$W z-g$aYi*O8N3oYVDmlLN^@q>b8FsXe(3Vh40uIT19K~zS$u`BX3R*lr;JsUZ)aW0|3 z2^ghyGM4noY-s&Y&b9~z>G$=HzY3&QrSQ+Bik-51XxuE555(VyX9OWQ#6Q(-C*pdP2R-l`ZpFhK$4HufsFO*3#=Wse zwEK#Lk-IEdM*~eL#J6APCj{|KAFJ=L(GeTrW;0I;K5)be)bh_lE)YUAn|pCUX^h30 zl$;p%&0A&_PZ3cpWpb&es`zApy^X~lloOxv{`!f2)7T!ilO-{u{rB(U?H5Sp4cG_n z=*FuMiXho_dby)Z!PEfSOJ7b*oc4VmrE^)28Q?SNPTYFPz`o z>*9JWZ!#RY$epZg=Xh^pYGFvZh)vQl z^SUrCckHtQbd_f&UM)~PW+sxhNQ789C(~Jjb&;Tp8K#4)B=m?36gV~gU5vw?_4Uv@ z<G1zl8xDwo_n&ZxG~V1 z`hanFpimfp^9Z=YKk7>)lmH)+6xv{n8>_CrA2A#*c;bm-i4!lEfpfaaN~-`jf%a1U zJzUX7>$H>pnB$}^wFn#CHopAWcVGy7S1ma(Ez4pt1NK7iGnFMrd>AwJh~yJg6fO8Z zA;gc_gyH-CWm$?F?sF{fNOK-^s!KE~se6nMW3|XOUMc#&Cx`stotNw2ZdE!v6smp> z#A3{cyCR|z#N>n^(44qabWWwaV7++;LnK5_+?0Im58n{``k*IjmnkW=2+xvo;2qiZ z)Hw-se;eUp_LYRI?a6L-$C)MEL$z_G#+W0Di5waeV~r~DAat;%X@Yj7E659D=UYH3+^<^@-fsK8zaJ$6p`zxg{R&t4l6|*7Y3$i zOw2PHl*t_0!8xJN{TifSc?V~A*Sn6L^^6{WvdKyl@kM08dtLyemC7SfijKy0PbTZG zzYJ*5YA-YN)u+Jvm|2bAn;z&e2r?mb_2jWil}J)dY@s9H zorNid`yBM2JgEo@0wLe#&(?FXwb~#ZJ*$Pmv0RsdTOK&=RDB6CwxfLN*Ptw%lP0P# zV5CrR5Xm z4`|ZRdad-qI`c0L2QZ6k3a&6v$O`2RR)Ks- z8cXnONKm}+;f;prkDDmVS{ICCIpoqKz?Aobl&p^oxW_PCkd?z08d;m5siC+i!9qy` z@#Br@c^tV}72l{AqKPV|iZbe|G_oBsMLuJd&P#|Od=51EC^X5g50g+{MFnDncDz(X zvwL}(Y{^^WH)-3!}>+E?kQ@ zrnK~nKx2B~62c@sqS>tM!H4Sk@vLMIuEKZG@&@DZ1r({1?`o^niJ7Ad0&c6*X6z&(IljUNr(ZJci4qZ zrW$5O`*zk9JF-!boy5>f(HJHhEYl{*@vd$eAr)+(Qt1jb@h3F49ed;l?Gr(5 z!<@lQ?O;i(%!QMl$BCkkgtT)GqHZkws6D?XCRc18x7#Daq{O~36}MZ$lxOvlErG+< zyr|8tJy($Kv4OR8#L?iuL z6DRm?9txeCshprx3`1o$&eYc7}nIxE zD2yhefQzU8vY^1TruZZLI@`7T+s8kNDx2j9O~u_gnD1E2KcDmt@ctN;oh{8EW*k4? zx$bdoS5L`qfuF1md)AH0+0JZE`zxYL_-w^OsR}8bZ3u-`Si-Dow#2xCH|adgAFi{} zB!qAl5@MnsKs1|s$vY!Mpp^Q`Y;R<54n!rix~##SLZqCA01X)B8|*CM2gdy2sQ;R6=*^PAkM zcyq#x04Z5m3(%Qlgqxe*v}=x!wi$RZ%?oG#NPn;_Ok?rGI{T2chI=eQntaWS* z?kQlVI}!`10$k<`tYQ2S%SfD{t|#vq$WR3FNi!}8eYmz>MQJ4N75HZFu~_5n;8+l{ z+?gN0s$r)?rbf&8O+*`|Pxy`J(PBi1QSzaB@9oWmMceg7?!yA-<>L>Ow}&T($9Cek z#~~p|=datP$;C5q-)=DoNr;HTcVfWzsisANa6m;G;I4o!uBTQQTj^*lTdDY;%Hd|H zwXnb7j>0Rq7sI>=gWaULv?l@06taZNM4Kg@!DD$lg{+I{FhNdq;+N!gL5Hmhu5RqD zEe`Mv$||f))P)5NLsV;mR9tG0e0t7MxMCJ_t5;{(zM``Kkk4u&aN*=@G?fS$PX?&wC-9Nw85 zD3AQgVe&W(%8;TDQdc_@uEuhjt(&DQq6yCSVpzlc)bQr>n%5$ZT$q{#Nr><%Z38k> z_!z&AEWLY;)VNR=T(456PY9k%?R=|B&}bUfc3cH#JmSifXIeQfgTvSgON(&!p+l5N zU~AM!lY?}z_s#D6TeZP20LCP1^Hw#E&Y`+Zc4Bd#%cf%nirxi8v{fG`-=(yr?v3V> z^pQ6Dk;d{;%4#0|v2l&FQ104dhRDw`dbbJ2=}RHq;8@)FkSn@ZCe&qt=!nBT^7h|R zwN`9H@o`|Lc<_4VSagc@uZn|fpl+^@bB H=c6D`}bH!T>fyBZUZNPEA^;cB%`1m zR>6GiE$N($$m!}Su$0_}Ud1XtJE=vgv{prX?XL}%@&unkGcLp|I57uFG2UuSfCxYt z4-)t;(|Sk`^Sl`7hkN^LfQ$8B^S$%O17k~u{E6%_#~W$3 zZ$Cz3$3oq$`v<_!MPV;xx@@;7GpR`<$T2(xj=vohzItku@f2~*wMxF(dWEQl>vkZo z;NhoO9OAhztS0~httE=7mIUc9W#4}Wwpdglhb?DbKsTeN8-)M zl>#fCJmx>bm5mdyYh}z2VlLxAeWqXV9Oabtg(KYhP}FYiNv4IOiEdAzsXv~wQ`NN4 zfzy|@f$&a zCor+F6K1IGKEO{;A*2cKmhWdMc{5)HHM~W>Z>-eMvtO~^4aW=Q|JH8s@7j&N^qnSh z$DWNeJ?zb#4DXvw4OHdnPb$(*PGhGAO8`n0SP@Gkwr7*I*D#g_->xM?PfwM{`SG;g z;>4(!ZVok{MX`AZFL~&IsozIs;l(+!qM-$A(cWlN}cJR(NW1Ytkgs4Cue z%mARC!eMf5k$jyLKGRfs7(>!F84E5Eqi4lMumQHfvAd;xb)}ccl*Ce`S1R} zYF_t4&|e0-+wrIWuQKfK?!Rg$f8Rdv?qPqr|4k|SB{%RKH1?vL&N)LVXc z{*^2L{n>TU{_FfJGydKESH5^psQ)q~%>TOo1HJxzrC<5jJ-__RB(eU+Hh*{g^=94E ywZDwzE*d|N|2uj6eTiR-s5j04 literal 0 HcmV?d00001 diff --git a/packages/api/src/files/documents/sample.docx b/packages/api/src/files/documents/sample.docx new file mode 100644 index 0000000000000000000000000000000000000000..c7e1c02b65495f5241283a648ef6f9828fa3a6d0 GIT binary patch literal 6553 zcmaJ_1yqz<*B)YE=nm-t>6T6j>5y&&N7|vgq(m4(5Qav&OG3J(LApVtOG+9=-~;dX z-Rt%L@7=T3teIJ}&w0<@`#k&EkD3w^G9ds20s;7(_>BO+82;^NcY7x@PCHjyh`E!s zoh65dEi@OdWcPs!_toPO5t;)qLUpJtGO^5FUSka^DKu8^BDbPr^~{w92VI+JOdCJy z#=bjtk;jAzPO&Q}{M1FUJ3A4xF7FlE+uXICV^={sq)kblXxU>pMLpd>SBFpfmQ<?CEIAEr#r4?VgTCUGdrIK zyD3gxp4BMvzbeP^)j3MFvX9p1g^Q5r=x&-yQ}j@h>CuwOB;dc;C}CBnaS38xa0J>o zMM>=!Su<*RN7s<3x-qX&c#%9u7X7S)Aoks}ur@0;{2fSg9RrFviz?|XVS;2v9mwl4 zFpIH8$!lSu&nRN@Cea^LvYNrw{c6_H$2=j5g3aD~_QuNI3VnX=ssd+k1?6@6K9aH> zVEhtLWe8Xgc@e?H#3>ZG(38YZp*|dB2f(r0QT*WCS;XvNU{lt%r*<7j@I=WLmfZQ2 z21yLd44=IPK}`u2^wMGYUN!;%kcq+XOrR%y9g?)7))_(=!g* zwy1pMGnff_l~WYFM~x(@Oy9gLmCS!*2C_HltAS#_eVwT*f3yFB_1!dUnu$1&datlT zxewUSz)YqNBYIk?ml1V3e}NWRf~4Tjsz9>EaT5fYC8#my5@ z1vZXCfQWjsqWvcpy0&kn%=icvy4&13QbEtiyOTs1Jd=mi!qqXf?g`oBJ!>yl*ReGQ zB^B?*Kb^bo1o7OrCpao{pf7N7?e0|DAI`{OPpWytu%GMs>>l{iPptFF*Dd!E=}J5# zR1>TAlt-xMq5SBY&zrx`k%~a#v6)OIT=1k{JEZZXJp#m1GVt~Q3kJjv>*up33$7)< z8|aCz(L`}a-8o{C0JL)A!3aDCv#%G1&3G!AIpGJw{QHlok!M@VZ_4HgVt z8E!2!cqqXLqXLD^+k+O{GL0pQBk%KxZsablwQ?`r{F*}Cj(`^9^MG$erf%d*+hqRR zo4yg9a%oPa^^rU~f^0)UPXVZImPpHnQgh6DN(+n*V(^>(!vS1!HP0`o50<=WvU6Ka zKvl^dhayYH%Zv5*U8y6nkHs$3-(=nwRIkW1vt?-zxA~qY`sG8w2)ov&(x+8UigK&d zqP8CgWhUO~4CYQr=Jj&YgC2ew+AndJvEtHxg~jP9pOm`3WWFjp4o_ePFvtZQg+ zJZ5RFj1_$nJ9^)v^DQDl`UCwVsD5cMT7Cp!a2TK7D(kk-<8fzeZoSj!4!KxCtI3<` zI_6nHDS~sUV&|-GT6e3Yui~%8(}R#4<5cz9h`E{a;9vcQTJW*PFcYWl>1Ke>A8hOs z@4X~rd{7pwtBEcY@}f`oCj{|K9cvt{-6t`@%VL=jBz3|L)b`IrEf7LBpMCO`+7z2B zF)1N-*GFarUlCa>d1A4;s`#Xzqm|VGk`tGnef7w&adaQY*@}eG;q+9z?K?_&JuQ1hRdw2-i8H^)Wz z>YeGi|52+7D4LsB*tPTwyr*R7j=%^56Th~d#fBSS?RKJp(U{b7s-OG$NBL4ww~F`h z+b@dcnU%{*L1Z_FzRo$LgvzpYy9l|zc<57?ER0m>-z`BZ<7^q8bG*MXIX|de#4hQS zaaEX_JNiihzQQvdryi&gJsm+;BtoK`li{Mtwm{g)vZ{-xB*a7k37nid72|YZdo}o0 zc_c~3C|3|)xia_x2TzUO5@&v=<5LWBvTG011}C0cLsVh zUnt%V9KOomGz_iqkNg|~CnT^<46QfCi_tJV2p$Av?AMQ1k%>?fHYFeWBi6^f`q~}2$DEi_gl|RN|CZuv z@`4P0u#I#t>xYD!-N{~7`?(duJ@ql(E}W++HF17ErgPrqNdXaDX(F|VK{{?c;bx@) z5=DlvObbQ3!jkSr6#5&hW3m(GwaeUfReoFKt;+Xc8)z9;x9zLml+x5b@CTtlQ)kHc zcSt?_FDxFB(`bH#)OSru+9@Ucz@GwDug@7v%147YHim_%sUp@>3eS9&9hZwZzZ;pM zGqX% z-DN<%c3YY83qvaG52?1Iv#|vU!te1nw0L=_F# zRoMjN-sq4+0=j?5SjIzVgxE&CjiJTo@+L^yf@vz3EHY0sqdC?x)1Oms1Z@t@9KX(o zYQJraA@kmgVS!d33CU^QWE5P71E1 z`?ZwHd(X2dp*0qwb1a*Hq-bGsOCnXh1Ui@vGuq;d}xYw@3n4T3y= z0-sq`3iJKick-wrC+zEDgx_O8MK+yd8g`gh^albs$adba89bI zLcfWAf$(IBa9VW+-3^-z=YjIv~BzK&{`fW~eCK3R^ zPV{e}{wL=!vw!AlYi{TAU(Ruh>6j9&rZ=Wr&5jf~#+JO>(m~irZfJAZGpuw2HMo8F zO!U@gr`ZSA8icAAq|pjtA@D~^4UK-2EHmgXV}v}!k=<;#KmizA<-N7M9XJL1Uyida1%4AfsdZ8#qQ>hT zurFurRl*fTJzUt(08%d8ByX4Aj7>;Xc(byPECfIn*AO7Jo&( z$C@0uH{k1qwzq^6Q8tXEtO^j=M6i2|VAS-a8`O6kO&5DCC_pJ!bJ@=2k6P;|ec5y;+q?PD3RN z;g*>Ed*lK*&$TczRpSOUCC%GIwa2k;k}vaO*A%Z6jt*(~~vot5qM8g@@P{ouVK zXsuY&xW(P9Un+y~JHFsX<_h~gvkVwVfvO7z7`AV4*1I4y;|h-Gz)TN!mLEqe(#z&n zy~A@YqwhxBf1WXzcB4^}YKJskJI=xfesXM2e2i~!3R^-I%&*hJWOF?<+~d@*Y%DccZjx;^*tQHu8rvCPb( z>c-72b4CTV5Y>?;+)|a5f2ArE|La*MPUcW&&R=Vr9EhIc+na`>N%im$(PHr7OoGr8V3No~OO$AHTW@ZZLY_qyNx2)fza$^vP==12wD72y3%F*q% zM%i4h`;4HVFGz235ZxY#Cfs84STNkkZOlxH7U&3Thda+`Ca=BJ$>~RRF@NygN`}LH zf*0h8N4A|;R?FK$-u6&=j1{Fq7c2TH+C|h}hphiCQHQ51m*j5fT0o2TxwpunrT z_ygiP`<2I=hd=o&yVWpl#qBv*ZpqC*pY#?N|Cp#51scu~i9uUsmFG(Qe+9CxwrPd{SZ5Vauw;qF-gZ-Tok+#8V35!M{TL z0X>hl+&2Kn-@bF#tF#6pm)K1l=7a~!jc{Bi+Id0RiM3~>LO&x;gqx6S_H#PFrPL-_iT}J|c z*yN8p>at#RV=fxC)!MKQMtT7kQe9#ybN{cST?WRe(!; zfz^!PVi-x{HT31Z0vU>IeN#;fLhr4uS5cdYdk4PWe<;>)GcX#2DtGS3uV&ogn4#Hx zaUI@D?HhLOb+iy3Vv=;I(Q|V>ZrOVEJ@;OL%hK^X>YKxp!()5#o8yoWl#5qw(v;#E zcyG2Cgd{{n5j!vud(~1SA-IqtO=xF8C%39K=2jXy>sAWE$8v;eur|(Tyb(m@w&GQ9 zqF{GvZk-806O}B{67gn9NAPIgP9fU@#;PC}2FWwZ+MvT01vht&mS#u9dSw;1MsQ(4 z{UFVnAPu)VlW+Gqh&y^A_x;K=`xi8hZ}ORqgsxnC4Q3J{{mL$pvbYH_1<6qNE&)S# zwGbYHOO;9!87Xa33y6YVSETNt-g0jN_v^&hO##|g8cy!CO<^6mf%2#?9Vd=qaE4?< zh=%&P@Oy0MncA8AMYO?L-VAG4AM0O#TJv7Om4m5Ul7$GL(bc0ehmG><%F=sOON|M2 zBJ?PA_=e!S*37l21dXK9Y{yn`#UU?GdZm`*F*uGcvo;H79Xdve1hzyDH#$le`&{p3 z->46K1~4YtShT2fb_~{Ta*&AoUN#;xQ1#3sqp$cnzgSFN?AmB5NgHmZA8sfwrGC%D zKRTv)9{QlBm?7d*wEj)JY1(2)7c>ShF677kmrucEff&d`-SQ5nXxhtmp#-?AX84Hu z<=FQX>s}TISHs=i9_E@l*KDYA#`$;KgkOGhl5T~@Lo4-ZTqPsn?bgA39L;H5jHqcE z$*ak^^*xGJeD+ccG^s6$4mw}zt>g(mhNgcPv*f}WAjf>8ISwI&q?5+;U8Z)EALe;8 z&=2+WRRbC4!zVjezF<_`__@nhRs4X1(xJTkLbkp%ras z9U(sVq;?`aShNkn09SrLm5NDjC(qT69nWdUH&Rn8hWzoYQKxHZ_T6tIF{7a#Hhuk2 z1JTuIGM#o?)EQv%a7s)sf#cny!k4Nh>8i+UZdLM4Hp|4-+&BGs1@}J2;F8RK9)qNJ zR5=z(^N&wdC2U=X!6b^Uq1*zXSkWC3i5#OA^Q@*Kk&{Q|yho&m)Em@|>D;PaY zs>@0Ie5kmxOKb^gvxsr_p<1t$iBE$Z?$qrP_NN$IeEdA#uuB-=1xzUHST)x1=;x=W z64F9+&-XKyyq>Fq8{eSbHCC`e)=ReA8G4@b-`WkqZM!j)zLidHNw$fWr-Qk(@m;g2 zhO4Olq#|vUwD#(&@j$5pYZ8fswk(RaYR1ywo3$kP*_rYfKfd-G+-McE&B3PgNOn)* zMNeHQI6E>EKh}v29VARfJ{9L-$RX>VC85#IM1rIR!C~5{D&BTV2VmG?>%p+p8xrtO zQJOwHU;vd&aC^%%4L(nTztDXiEwFl6I>C*LOs2jAwiuTy&|WR@5d2|Z%9^R+q{9l~ z=(EW3n5*z;gPtFu`p%mUnaWm_m7);1uGZ&96gNngp;Vd1a)L*3CtD+hh72tnjIr#X zA9jv~^t$+$gE?sok^GX@=qzHLCj5KdH;rKuoyE_-6!)K$h|c%dXrxUXdBk_njC^i) z2fXFC@)>-hRPE%Dl#zfM42m?7+&DE4eL_W;rQ1}w{6OC1D2k%EyZ9p@5(55`(0^54?*y#B+y52RfA{}Y8oQg7{<78E9e?`& zs@DGQ{;S0D_w563ANHsF-_*3<{eK|`=U%B$%pIsOIzs|oh zn5W)Bdh|;5{)~g;~q2Q%9{$^gbjYTl2AR0Q$Z{F@?_szc9*_p}552>Xm zFS?hpg@zEpdL@onjoh&B*l`k&v0%MYsaP?)JM~2= z3%Sz~!k)j}`4++>;zWXIC3X>QL_5(zFa#h)q=^i%o9HB()lSX$U6VYoKOq-UW5b=~ zf_g}NZ9xJJ$smUUvTP-cqdycjz|(M4zxc51qu!q2KSha#>Z&*vPUjgr-GZBWm8 zvD2aI_t!K3cXO=nM!w&^^LXE}KHJ~UGwv}8#G=R--?zle1~CSXWmXB#-FI3meCA8f zFkUfLPl%}v(cgBrqv%GUD;`#_Le!k3cU{IbuA;~7#{jxud?Zef8VSuGG>sYMp8L7K9+Ou!y)(S_iZ>45Nt z(X%2+mr2-TGWPZm-XK!0#TV-MaZ`krHCS=FKn7-Ewq%5)a>Odqo!f^-OV86=afmmoOIY?}* zlgK;dr14V!+YHxCeQi2f@ciN!XT1z&MQ@cNx&aiteS{T;MHELyD=90E=tz#`U!)&G CdJo0` literal 0 HcmV?d00001 diff --git a/packages/api/src/files/documents/sample.xlsx b/packages/api/src/files/documents/sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2abb6961d15ebb49476577aaf81ab794b8d5e6c5 GIT binary patch literal 6111 zcmeHLc|6qZ+Balp#xnMVO!j3g*|O6lyC{*Ju`~=ZPl!sEWM7jt#xj?{qsRY$ivAorP~x)52YafLc0XKKIUM|NUH)i>(+(>CoUjaq9WRW z86yvR)Fm9e0Oy?A6}DI2B2lGVs#dh#oC`%Xg!kx*S4}yAE6&)bJW{>`7JkhzlgLKo zZZXrv{I2Tk^UGdl*+ta%LmQ0MZ^+3^h*HeGE2f-g$3)BIl6GFbd!zHk!sV}uB#B;s zmZx$}F1#T~B$!$|&>HYBy-2pHlyY`Qio>7YYKL&scH(6&@hd$!-913lLtn9A{6L8~ zzVb!hK{e%lSyHJ3=JnB*Yc*|s<}IV(W&o>BgFuDKy_Ud2F0+~#IL!U?qcVE})<^9v zJut!Xl7GE*{1KjMto zE-Mz>nB&7^V^^#SH5eNp%?dY6iznBjWUh^E;`?+50eas@e^W$)_iC9Q>IxlgVlruv zX#*6_E44)F47Pvx_-15BM$HS=I=-{q`OyO|Y~!!i%qZ3#$#*R6dTc(-luEy| z2U>^|v@0|VSq;kUx5Thi$VSPsMmbC9Xia5xf3YPP;5mCRPntO|>I>Su=D`ry1|m$% zMlG-~)DogTx63d-U(FV2wp=srNUX6nZz69N^PH;IQ~2nus_Bcb-mmuEahgqUiwrUR z<+E*GZ4#1frGBLfpg^>dOl}VDAYtadf%qw8oPq$hWF7>5%YbHfR=Z zbxCLGSx^$MQqR1MFV@D=D6rPAjtigL#yrI1Z0|SXmhbpSWwa|hbi4Ghj4q$~T^Vuy zV5AeVH+O|Qp3DHDqwir|8f5iQDxwKmk3gtbOn<-=@nx7lGV7d$8V2Ne4zk)aoD`Nm zQ9sks<=tR@5hR9X36PTJM^6(%i`wQ}fi#_?cW?ysw8mjrEW{E`qJ_)T7PoGbyp!y0 z!&InOA0ItVPTi+Q#OeOrQTrYP1xEI)|oNpgmA^X6aWVNyyEny;;P&kyfSFC$1wqF*av zvBKkW@|C4(Z1;G5;~W~4;5{?`(5=_!1cT3q-y_KJR5do#Y;APJ)kNEyt#I$#><)4%m3ZUCx@LC2ZtF zW{mysMVmZcl@ygb&VLylbfGW*28Q6Bn&jRTEp0cvMQ$YbOXucORx&A_!W9;V)Z{-t zIkHl8ZIMqv9nC^QFl$Tg9v?qz4l$B{1zlpL&PcPk;NZVcImssMICFc^#eHgVrF;8} zMD-|eD{4zqpjDsJG>usMd9UT_+!NdBN}{Per*m25f2gC>W#7`Wq}GMcq7fj|F5^g)=fAkv2OePnvVL&6N)*kAjc5WaL?P82B6ieE}S&z~2=>DLJ*n zP#DRmx?EkPCff%tj-ZIKF-I`DkV)rWhvXi7KbiY3HyaEAnU*Dh!Hnb*&FZt{oHpx} zRWt4sJ%$K?(_Tr;Byfe!b5UfF3vh6$F5En5>!a3!ly7-+%-Q z43mC$QY;ZQ%DAuvo2?sa>`^FPuGH)4$s5+>I`H5qZoa}7r}jjLWPCvUr_h%lX{o>5UYn;Wuu&GpkG(|8U;Q{Jwvmj3nn#SD!BZ)I zhki!3;hL6bsB$M6h;(uGDp@I8|JGoC+Tg5cq3B{NDrhoq zSfD=@@5Cp_*SP3jmDtBVUsckcf?j{bnI2Hf)(l?*o`agIXnbcMGD#;=oS`ZksAV=J zsP~$^AfG+SGI`IN*~d@uvWUsB8~wr9i$bCtW?+SLf!x6QB@e%G*4q^ogT|&0${M{0 zsuxX#z03SIjjI)C79H&dS*ynvQ)^%XS%LOOgK@5*fPr^d4i`;6Zc2no=%^fS5|iP zAmuODMNLYq@+v94ijTv~bKH+!((WrrYP<;;v5Yc+?&k(8Xk7T{gGm@#v}NK!lni!` z?37Q&^*6VlU0_I1r6!&B&q~R2rfZ!Wko0Z8`oJI^bajj5^IX*TIzGLF5g}SejIzWa zg+9{pvqT7PF;702Q&@K22YuOhG2F`R6P-x}i!|)4v4aAicwBvz$HVYfyG8mdy~+0M zYpu7I_7khoZLEef5o3R}2dsNP^YBuEW(qcCRZR6_?lm*9ooRB#231SP^m$uL&aD{R zRP(RD+|(IvE!rkNDl~Y;BTJsci8UYiyH*4KQD|=VmacFceU$5w3OJfr;SnOp1}Md4 zo$gB{Bi7PP(dm(~Xr+Fj6NtS9B~X`c5+&3-Y?zxpH0AYY1M%DK=dks#0BbJtT?}CW z*p*@4_T$=Ar591-WbT?ZSMe7=3cF34_{)8!vGOX|LhFRX~L4+O^gU zy4_{j)tT4PP7yNOYH7;PuL`^mOPCx8Eg(;=V+!wlQsuD<3lZCS6VhHRabG|Hu`O8^ z=bW^`s)9jUe{Zg=S$uHJE=JlQ`J?_S@Z0`M`PZRQo{sS2)0P;#`4_J|Z>$Q=<0@jE znap{%5t;vG0Ormn_MnS@}-$S zgBoJx+p!)W@kT>bKg%HNT7$Jky90OhnI>(f-Gt$gmI>v{yNrQeO}=wkGnNE?s5!j} z!3g^u0&>4+)#MH_7aa}3sR=VfJsmCzNau?3$wFfflC(*f4?HcF{%hiV_^61`4K6RO zHJD7??z1TuzhZ)j%05`LLuYV*7Cr+3wF{~+y^1iW{Yz4{tKY1_gi>K8*rJa1p*{bQ zc;g=A!ySINiHv~}h<&FuAphE@ilJt6p2rw?q?QgLPC41h$xF* zZocy|2C^hMd@6sa%5r~hDrWZGVBEG}o8T7kibLO6FP%C%gqHkilWg|tcCL`}mCLRj z1FzDh0)tSdR`B*-RwrviKagL-Igtx?a7?Q%!JvBOtgF$Uru(MIs2)!2QRVcDn9Qg~ z#Jb$ow@E51C0>In8B>l^7ekFR`%9@fZ>m4=lwX>Ws?rzCxMx^7{5|S(URNBaEModH z%lCBdLGc#4{kYH1(V23$X-nE`LVz50lFt)kaM=o4`^QC4BvVw~cbE&We%o?EKl0&d z-m-FbMjR`)=y7B{J2)`aVx`-qpcuyBFht7Xb9_rsp(=M7aE#D!3bW-xVrv-Mn4sjv0tgUiAA3D)og&tF>()@!Z0!*-IlGnG86L*O>9=y4 z`A1BC4F4x1b3FavqP1=}K&b)_>FLSfKv7C*ERA#$O-rd3i2Y?D+)6_^@nCBsMMC@! z_IM#p!$~=jmHYO^W8M@vkA`@e6)@Nch;O>AHET>Lyt}+E_e6DwY1Au_45s-y_f@~n z2gGE8O;zNX!1ByGngC*6eWyF!ylVi~adCNI;?1TTM5b)0kfM&YOHA2Jx+(DB6g?_; z3|=7{!NG-LNiD;vi~@6F6bh|O({-MB;lp{}t+*@L7AcBp_+!_rk5#R^oIQM{7Aki7 z(${ZX5$^(fzR?fPl_Pe;LOQA<0&S@08K!OSqrZw&#ScGx%=^${dT_Hq{+QhqzVcwd z9{L6LThEan{^Ez*{u&gOfWA;F!tO(*WV>es`mct|0GZy47;<0GN`=f+cSgJK1sjN_5 zM`PvZBM)<*md!b5$0X`%{|>{c&>jzSE|0k#OSCQ2wK-DDqI*C7We>i02D!!Z!WXur zN~EsG`9D&7n%L;fgCH8tP_mm`2M~p^CrtwAx);$8WpCmRg%L4HrZbwo= zpt&6R-p2c4;xhiV^R3~c*F^&_a1`6=5rnAwo@ww^8T>l*3{ zvyH(J=C=lF;i}b^LJ#<{1(IR*!;E|{6bDilsbZM}>?(Y2GVN(x0H60o(?lVXklrSx zyV`4n=M|I$Gw&hRiPb7iiRNq2--|le5J)saPfK zaDi-dW<@L2SdYq&g09-|xB+6Ft0jt?xQU!Hgt5v@P2zgRX1iYvlC697(oslqRJw(( z9fre)@Mxd_;Dhmg-YK5EB0IWM{MG*54deeFMdR>){|4n)4xL6hdC&0cUgGeNQBLdX zV`*_3<)l*Q{N*^&hrQ%*FDFp`_J-m#;7Rp&YFmd6{T1-1+&_(SQa=5f%3*=!jl#C ctM6-R{+8HuH3*50&?ygJ?uSj_+Ofm`0f_d>wg3PC literal 0 HcmV?d00001 diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 3aedc5ba9d..707f2ef7fb 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -1,5 +1,6 @@ export * from './audio'; export * from './context'; +export * from './documents/crud'; export * from './encode'; export * from './filter'; export * from './mistral/crud'; diff --git a/packages/api/src/files/mistral/crud.ts b/packages/api/src/files/mistral/crud.ts index fefe4a4675..c818fab8b8 100644 --- a/packages/api/src/files/mistral/crud.ts +++ b/packages/api/src/files/mistral/crud.ts @@ -165,9 +165,11 @@ export async function performOCR({ config.httpsAgent = new HttpsProxyAgent(process.env.PROXY); } + const ocrURL = baseURL.endsWith('/ocr') ? baseURL : `${baseURL}/ocr`; + return axios .post( - `${baseURL}/ocr`, + ocrURL, { model, image_limit: 0, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 82d477e54e..64fc99b0eb 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -820,6 +820,7 @@ export enum OCRStrategy { CUSTOM_OCR = 'custom_ocr', AZURE_MISTRAL_OCR = 'azure_mistral_ocr', VERTEXAI_MISTRAL_OCR = 'vertexai_mistral_ocr', + DOCUMENT_PARSER = 'document_parser', } export enum SearchCategories { diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts index ec42520bc0..1eb8c200d6 100644 --- a/packages/data-provider/src/types/files.ts +++ b/packages/data-provider/src/types/files.ts @@ -13,6 +13,7 @@ export enum FileSources { azure_mistral_ocr = 'azure_mistral_ocr', vertexai_mistral_ocr = 'vertexai_mistral_ocr', text = 'text', + document_parser = 'document_parser', } export const checkOpenAIStorage = (source: string) => From b349f2f876acef42ff18c675e2e1077bfc4bf177 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 22 Feb 2026 18:29:31 -0500 Subject: [PATCH 012/110] =?UTF-8?q?=F0=9F=AA=A3=20fix:=20Serve=20Fresh=20P?= =?UTF-8?q?resigned=20URLs=20on=20Agent=20List=20Cache=20Hits=20(#11902)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: serve cached presigned URLs on agent list cache hits On a cache hit the list endpoint was skipping the S3 refresh and returning whatever presigned URL was stored in MongoDB, which could be expired if the S3 URL TTL is shorter than the 30-minute cache window. refreshListAvatars now collects a urlCache map (agentId -> refreshed filepath) alongside its existing stats. The controller stores this map in the cache instead of a plain boolean and re-applies it to every paginated response, guaranteeing clients always receive a URL that was valid as of the last refresh rather than a potentially stale DB value. * fix: improve avatar refresh cache handling and logging Updated the avatar refresh logic to validate cached refresh data before proceeding with S3 URL updates. Enhanced logging to exclude sensitive `urlCache` details while still providing relevant statistics. Added error handling for cache invalidation during avatar updates to ensure robustness. * fix: update avatar refresh logic to clear urlCache on no change Modified the avatar refresh function to clear the urlCache when no new path is generated, ensuring that stale URLs are not retained. This change improves cache handling and aligns with the updated logic for avatar updates. * fix: enhance avatar refresh logic to handle legacy cache entries Updated the avatar refresh logic to accommodate legacy boolean cache entries, ensuring they are treated as cache misses and triggering a refresh. The cache now stores a structured `urlCache` map instead of a boolean, improving cache handling. Added tests to verify correct behavior for cache hits and misses, ensuring clients receive valid URLs based on the latest refresh. --- api/server/controllers/agents/v1.js | 32 +++++++-- api/server/controllers/agents/v1.spec.js | 89 +++++++++++++++++++++++- packages/api/src/agents/avatars.spec.ts | 57 +++++++++++++++ packages/api/src/agents/avatars.ts | 46 ++++++------ 4 files changed, 193 insertions(+), 31 deletions(-) diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 34078b2250..a2c0d55186 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -530,10 +530,10 @@ const getListAgentsHandler = async (req, res) => { */ const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); const refreshKey = `${userId}:agents_avatar_refresh`; - const alreadyChecked = await cache.get(refreshKey); - if (alreadyChecked) { - logger.debug('[/Agents] S3 avatar refresh already checked, skipping'); - } else { + let cachedRefresh = await cache.get(refreshKey); + const isValidCachedRefresh = + cachedRefresh != null && typeof cachedRefresh === 'object' && cachedRefresh.urlCache != null; + if (!isValidCachedRefresh) { try { const fullList = await getListAgentsByAccess({ accessibleIds, @@ -541,16 +541,19 @@ const getListAgentsHandler = async (req, res) => { limit: MAX_AVATAR_REFRESH_AGENTS, after: null, }); - await refreshListAvatars({ + const { urlCache } = await refreshListAvatars({ agents: fullList?.data ?? [], userId, refreshS3Url, updateAgent, }); - await cache.set(refreshKey, true, Time.THIRTY_MINUTES); + cachedRefresh = { urlCache }; + await cache.set(refreshKey, cachedRefresh, Time.THIRTY_MINUTES); } catch (err) { logger.error('[/Agents] Error refreshing avatars for full list: %o', err); } + } else { + logger.debug('[/Agents] S3 avatar refresh already checked, skipping'); } // Use the new ACL-aware function @@ -568,11 +571,20 @@ const getListAgentsHandler = async (req, res) => { const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString())); + const urlCache = cachedRefresh?.urlCache; data.data = agents.map((agent) => { try { if (agent?._id && publicSet.has(agent._id.toString())) { agent.isPublic = true; } + if ( + urlCache && + agent?.id && + agent?.avatar?.source === FileSources.s3 && + urlCache[agent.id] + ) { + agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] }; + } } catch (e) { // Silently ignore mapping errors void e; @@ -658,6 +670,14 @@ const uploadAgentAvatarHandler = async (req, res) => { const updatedAgent = await updateAgent({ id: agent_id }, data, { updatingUserId: req.user.id, }); + + try { + const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL); + await avatarCache.delete(`${req.user.id}:agents_avatar_refresh`); + } catch (cacheErr) { + logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr); + } + res.status(201).json(updatedAgent); } catch (error) { const message = 'An error occurred while updating the Agent Avatar'; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 8b2a57d903..ce68cc241f 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -59,6 +59,7 @@ jest.mock('~/models', () => ({ const mockCache = { get: jest.fn(), set: jest.fn(), + delete: jest.fn(), }; jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => mockCache), @@ -1309,7 +1310,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }); test('should skip avatar refresh if cache hit', async () => { - mockCache.get.mockResolvedValue(true); + mockCache.get.mockResolvedValue({ urlCache: {} }); findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); findPubliclyAccessibleResources.mockResolvedValue([]); @@ -1348,8 +1349,12 @@ describe('Agent Controllers - Mass Assignment Protection', () => { // Verify S3 URL was refreshed expect(refreshS3Url).toHaveBeenCalled(); - // Verify cache was set - expect(mockCache.set).toHaveBeenCalled(); + // Verify cache was set with urlCache map, not a plain boolean + expect(mockCache.set).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ urlCache: expect.any(Object) }), + expect.any(Number), + ); // Verify response was returned expect(mockRes.json).toHaveBeenCalled(); @@ -1563,5 +1568,83 @@ describe('Agent Controllers - Mass Assignment Protection', () => { // Verify that the handler completed successfully expect(mockRes.json).toHaveBeenCalled(); }); + + test('should treat legacy boolean cache entry as a miss and run refresh', async () => { + // Simulate a cache entry written by the pre-fix code + mockCache.get.mockResolvedValue(true); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + refreshS3Url.mockResolvedValue('new-s3-path.jpg'); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + // Boolean true fails the shape guard, so refresh must run + expect(refreshS3Url).toHaveBeenCalled(); + // Cache is overwritten with the proper format + expect(mockCache.set).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ urlCache: expect.any(Object) }), + expect.any(Number), + ); + }); + + test('should apply cached urlCache filepath to paginated response on cache hit', async () => { + const agentId = agentWithS3Avatar.id; + const cachedUrl = 'cached-presigned-url.jpg'; + + mockCache.get.mockResolvedValue({ urlCache: { [agentId]: cachedUrl } }); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + expect(refreshS3Url).not.toHaveBeenCalled(); + + const responseData = mockRes.json.mock.calls[0][0]; + const agent = responseData.data.find((a) => a.id === agentId); + // Cached URL is served, not the stale DB value 'old-s3-path.jpg' + expect(agent.avatar.filepath).toBe(cachedUrl); + }); + + test('should preserve DB filepath for agents absent from urlCache on cache hit', async () => { + mockCache.get.mockResolvedValue({ urlCache: {} }); + findAccessibleResources.mockResolvedValue([agentWithS3Avatar._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + const mockReq = { + user: { id: userA.toString(), role: 'USER' }, + query: {}, + }; + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + await getListAgentsHandler(mockReq, mockRes); + + expect(refreshS3Url).not.toHaveBeenCalled(); + + const responseData = mockRes.json.mock.calls[0][0]; + const agent = responseData.data.find((a) => a.id === agentWithS3Avatar.id); + expect(agent.avatar.filepath).toBe('old-s3-path.jpg'); + }); }); }); diff --git a/packages/api/src/agents/avatars.spec.ts b/packages/api/src/agents/avatars.spec.ts index ac97964837..db82b311ee 100644 --- a/packages/api/src/agents/avatars.spec.ts +++ b/packages/api/src/agents/avatars.spec.ts @@ -7,6 +7,16 @@ import { refreshListAvatars, } from './avatars'; +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +import { logger } from '@librechat/data-schemas'; + describe('refreshListAvatars', () => { let mockRefreshS3Url: jest.MockedFunction; let mockUpdateAgent: jest.MockedFunction; @@ -15,6 +25,7 @@ describe('refreshListAvatars', () => { beforeEach(() => { mockRefreshS3Url = jest.fn(); mockUpdateAgent = jest.fn(); + jest.clearAllMocks(); }); const createAgent = (overrides: Partial = {}): Agent => ({ @@ -44,6 +55,7 @@ describe('refreshListAvatars', () => { }); expect(stats.updated).toBe(0); + expect(stats.urlCache).toEqual({}); expect(mockRefreshS3Url).not.toHaveBeenCalled(); expect(mockUpdateAgent).not.toHaveBeenCalled(); }); @@ -62,6 +74,7 @@ describe('refreshListAvatars', () => { expect(stats.not_s3).toBe(1); expect(stats.updated).toBe(0); + expect(stats.urlCache).toEqual({}); expect(mockRefreshS3Url).not.toHaveBeenCalled(); }); @@ -109,6 +122,7 @@ describe('refreshListAvatars', () => { }); expect(stats.updated).toBe(1); + expect(stats.urlCache).toEqual({ agent1: 'new-path.jpg' }); expect(mockRefreshS3Url).toHaveBeenCalledWith(agent.avatar); expect(mockUpdateAgent).toHaveBeenCalledWith( { id: 'agent1' }, @@ -130,6 +144,7 @@ describe('refreshListAvatars', () => { expect(stats.no_change).toBe(1); expect(stats.updated).toBe(0); + expect(stats.urlCache).toEqual({}); expect(mockUpdateAgent).not.toHaveBeenCalled(); }); @@ -146,6 +161,7 @@ describe('refreshListAvatars', () => { expect(stats.s3_error).toBe(1); expect(stats.updated).toBe(0); + expect(stats.urlCache).toEqual({}); }); it('should handle database persist errors gracefully', async () => { @@ -162,6 +178,7 @@ describe('refreshListAvatars', () => { expect(stats.persist_error).toBe(1); expect(stats.updated).toBe(0); + expect(stats.urlCache).toEqual({ agent1: 'new-path.jpg' }); }); it('should process agents in batches', async () => { @@ -186,10 +203,49 @@ describe('refreshListAvatars', () => { }); expect(stats.updated).toBe(25); + expect(Object.keys(stats.urlCache)).toHaveLength(25); expect(mockRefreshS3Url).toHaveBeenCalledTimes(25); expect(mockUpdateAgent).toHaveBeenCalledTimes(25); }); + it('should not populate urlCache when refreshS3Url resolves with falsy', async () => { + const agent = createAgent(); + mockRefreshS3Url.mockResolvedValue(undefined); + + const stats = await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + expect(stats.no_change).toBe(1); + expect(stats.urlCache).toEqual({}); + expect(mockUpdateAgent).not.toHaveBeenCalled(); + }); + + it('should redact urlCache from log output', async () => { + const agent = createAgent(); + mockRefreshS3Url.mockResolvedValue('new-path.jpg'); + mockUpdateAgent.mockResolvedValue({}); + + await refreshListAvatars({ + agents: [agent], + userId, + refreshS3Url: mockRefreshS3Url, + updateAgent: mockUpdateAgent, + }); + + const loggerInfo = logger.info as jest.Mock; + const summaryCall = loggerInfo.mock.calls.find(([msg]) => + msg.includes('Avatar refresh summary'), + ); + expect(summaryCall).toBeDefined(); + const loggedPayload = summaryCall[1]; + expect(loggedPayload).toHaveProperty('urlCacheSize', 1); + expect(loggedPayload).not.toHaveProperty('urlCache'); + }); + it('should track mixed statistics correctly', async () => { const agents = [ createAgent({ id: 'agent1' }), @@ -214,6 +270,7 @@ describe('refreshListAvatars', () => { expect(stats.updated).toBe(2); // agent1 and agent2 (other user's agent now refreshed) expect(stats.not_s3).toBe(1); // agent3 expect(stats.no_id).toBe(1); // agent with empty id + expect(stats.urlCache).toEqual({ agent1: 'new-path.jpg', agent2: 'new-path.jpg' }); }); }); diff --git a/packages/api/src/agents/avatars.ts b/packages/api/src/agents/avatars.ts index 7c92f352b2..25adfdc717 100644 --- a/packages/api/src/agents/avatars.ts +++ b/packages/api/src/agents/avatars.ts @@ -29,6 +29,8 @@ export type RefreshStats = { no_change: number; s3_error: number; persist_error: number; + /** Maps agentId to the latest valid presigned filepath for re-application on cache hits */ + urlCache: Record; }; /** @@ -55,6 +57,7 @@ export const refreshListAvatars = async ({ no_change: 0, s3_error: 0, persist_error: 0, + urlCache: {}, }; if (!agents?.length) { @@ -86,28 +89,23 @@ export const refreshListAvatars = async ({ logger.debug('[refreshListAvatars] Refreshing S3 avatar for agent: %s', agent._id); const newPath = await refreshS3Url(agent.avatar); - if (newPath && newPath !== agent.avatar.filepath) { - try { - await updateAgent( - { id: agent.id }, - { - avatar: { - filepath: newPath, - source: agent.avatar.source, - }, - }, - { - updatingUserId: userId, - skipVersioning: true, - }, - ); - stats.updated++; - } catch (persistErr) { - logger.error('[refreshListAvatars] Avatar refresh persist error: %o', persistErr); - stats.persist_error++; - } - } else { + if (!newPath || newPath === agent.avatar.filepath) { stats.no_change++; + return; + } + + stats.urlCache[agent.id] = newPath; + + try { + await updateAgent( + { id: agent.id }, + { avatar: { filepath: newPath, source: agent.avatar.source } }, + { updatingUserId: userId, skipVersioning: true }, + ); + stats.updated++; + } catch (persistErr) { + logger.error('[refreshListAvatars] Avatar refresh persist error: %o', persistErr); + stats.persist_error++; } } catch (err) { logger.error('[refreshListAvatars] S3 avatar refresh error: %o', err); @@ -117,6 +115,10 @@ export const refreshListAvatars = async ({ ); } - logger.info('[refreshListAvatars] Avatar refresh summary: %o', stats); + const { urlCache: _urlCache, ...loggableStats } = stats; + logger.info('[refreshListAvatars] Avatar refresh summary: %o', { + ...loggableStats, + urlCacheSize: Object.keys(_urlCache).length, + }); return stats; }; From 1d0a4c501f00d336e1f00bbb50ff1985f6a7da0c Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:32:44 -0800 Subject: [PATCH 013/110] =?UTF-8?q?=F0=9F=AA=A8=20feat:=20AWS=20Bedrock=20?= =?UTF-8?q?Document=20Uploads=20(#11912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add aws bedrock upload to provider support * chore: address copilot comments * feat: add shared Bedrock document format types and MIME mapping Bedrock Converse API accepts 9 document formats beyond PDF. Add BedrockDocumentFormat union type, MIME-to-format mapping, and helpers in data-provider so both client and backend can reference them. * refactor: generalize Bedrock PDF validation to support all document types Rename validateBedrockPdf to validateBedrockDocument with MIME-aware logic: 4.5MB hard limit applies to all types, PDF header check only runs for application/pdf. Adds test coverage for non-PDF documents. * feat: support all Bedrock document formats in encoding pipeline Widen file type gates to accept csv, doc, docx, xls, xlsx, html, txt, md for Bedrock. Uses shared MIME-to-format map instead of hardcoded 'pdf'. Other providers' PDF-only paths remain unchanged. * feat: expand Bedrock file upload UI to accept all document types Add 'image_document_extended' upload type for Bedrock with accept filters for all 9 supported formats. Update drag-and-drop validation to use isBedrockDocumentType helper. * fix: route Bedrock document types through provider pipeline --- api/app/clients/BaseClient.js | 7 + .../Chat/Input/Files/AttachFileMenu.tsx | 29 ++- .../Chat/Input/Files/DragDropModal.tsx | 51 ++--- .../api/src/files/encode/document.spec.ts | 185 +++++++++++++++++- packages/api/src/files/encode/document.ts | 64 ++++-- packages/api/src/files/validation.spec.ts | 118 ++++++++++- packages/api/src/files/validation.ts | 67 +++++++ packages/api/src/types/files.ts | 18 +- packages/data-provider/src/file-config.ts | 35 +++- packages/data-provider/src/schemas.ts | 1 + 10 files changed, 528 insertions(+), 47 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index a2dfaf9907..fab82db93b 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -20,6 +20,7 @@ const { isAgentsEndpoint, isEphemeralAgentId, supportsBalanceCheck, + isBedrockDocumentType, } = require('librechat-data-provider'); const { updateMessage, @@ -1300,6 +1301,9 @@ class BaseClient { const allFiles = []; + const provider = this.options.agent?.provider ?? this.options.endpoint; + const isBedrock = provider === EModelEndpoint.bedrock; + for (const file of attachments) { /** @type {FileSources} */ const source = file.source ?? FileSources.local; @@ -1317,6 +1321,9 @@ class BaseClient { } else if (file.type === 'application/pdf') { categorizedAttachments.documents.push(file); allFiles.push(file); + } else if (isBedrock && isBedrockDocumentType(file.type)) { + categorizedAttachments.documents.push(file); + allFiles.push(file); } else if (file.type.startsWith('video/')) { categorizedAttachments.videos.push(file); allFiles.push(file); diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 218328b086..5b7346f646 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -8,13 +8,6 @@ import { FileImageIcon, TerminalSquareIcon, } from 'lucide-react'; -import { - Providers, - EToolResources, - EModelEndpoint, - defaultAgentCapabilities, - isDocumentSupportedProvider, -} from 'librechat-data-provider'; import { FileUpload, TooltipAnchor, @@ -22,6 +15,14 @@ import { AttachmentIcon, SharePointIcon, } from '@librechat/client'; +import { + Providers, + EToolResources, + EModelEndpoint, + defaultAgentCapabilities, + bedrockDocumentExtensions, + isDocumentSupportedProvider, +} from 'librechat-data-provider'; import type { EndpointFileConfig } from 'librechat-data-provider'; import { useAgentToolPermissions, @@ -37,7 +38,12 @@ import { ephemeralAgentByConvoId } from '~/store'; import { MenuItemProps } from '~/common'; import { cn } from '~/utils'; -type FileUploadType = 'image' | 'document' | 'image_document' | 'image_document_video_audio'; +type FileUploadType = + | 'image' + | 'document' + | 'image_document' + | 'image_document_extended' + | 'image_document_video_audio'; interface AttachFileMenuProps { agentId?: string | null; @@ -99,6 +105,8 @@ const AttachFileMenu = ({ inputRef.current.accept = '.pdf,application/pdf'; } else if (fileType === 'image_document') { inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf'; + } else if (fileType === 'image_document_extended') { + inputRef.current.accept = `image/*,.heif,.heic,${bedrockDocumentExtensions}`; } else if (fileType === 'image_document_video_audio') { inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf,video/*,audio/*'; } else { @@ -134,6 +142,11 @@ const AttachFileMenu = ({ let fileType: Exclude = 'image_document'; if (currentProvider === Providers.GOOGLE || currentProvider === Providers.OPENROUTER) { fileType = 'image_document_video_audio'; + } else if ( + currentProvider === Providers.BEDROCK || + endpointType === EModelEndpoint.bedrock + ) { + fileType = 'image_document_extended'; } onAction(fileType); }, diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index a59a7e3e9d..cb5109c866 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -1,14 +1,6 @@ import React, { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; -import { - Providers, - inferMimeType, - EToolResources, - EModelEndpoint, - defaultAgentCapabilities, - isDocumentSupportedProvider, -} from 'librechat-data-provider'; import { ImageUpIcon, FileSearch, @@ -16,6 +8,15 @@ import { FileImageIcon, TerminalSquareIcon, } from 'lucide-react'; +import { + Providers, + inferMimeType, + EToolResources, + EModelEndpoint, + isBedrockDocumentType, + defaultAgentCapabilities, + isDocumentSupportedProvider, +} from 'librechat-data-provider'; import { useAgentToolPermissions, useAgentCapabilities, @@ -77,20 +78,26 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD ) { const supportsImageDocVideoAudio = currentProvider === EModelEndpoint.google || currentProvider === Providers.OPENROUTER; - const validFileTypes = supportsImageDocVideoAudio - ? files.every((file) => { - const type = getFileType(file); - return ( - type?.startsWith('image/') || - type?.startsWith('video/') || - type?.startsWith('audio/') || - type === 'application/pdf' - ); - }) - : files.every((file) => { - const type = getFileType(file); - return type?.startsWith('image/') || type === 'application/pdf'; - }); + const isBedrock = + currentProvider === Providers.BEDROCK || endpointType === EModelEndpoint.bedrock; + + const isValidProviderFile = (file: File): boolean => { + const type = getFileType(file); + if (supportsImageDocVideoAudio) { + return ( + type?.startsWith('image/') || + type?.startsWith('video/') || + type?.startsWith('audio/') || + type === 'application/pdf' + ); + } + if (isBedrock) { + return type?.startsWith('image/') || isBedrockDocumentType(type); + } + return type?.startsWith('image/') || type === 'application/pdf'; + }; + + const validFileTypes = files.every(isValidProviderFile); _options.push({ label: localize('com_ui_upload_provider'), diff --git a/packages/api/src/files/encode/document.spec.ts b/packages/api/src/files/encode/document.spec.ts index 9091cedd9e..a93800b5e1 100644 --- a/packages/api/src/files/encode/document.spec.ts +++ b/packages/api/src/files/encode/document.spec.ts @@ -7,6 +7,7 @@ import { encodeAndFormatDocuments } from './document'; /** Mock the validation module */ jest.mock('~/files/validation', () => ({ validatePdf: jest.fn(), + validateBedrockDocument: jest.fn(), })); /** Mock the utils module */ @@ -15,11 +16,14 @@ jest.mock('./utils', () => ({ getConfiguredFileSizeLimit: jest.fn(), })); -import { validatePdf } from '~/files/validation'; +import { validatePdf, validateBedrockDocument } from '~/files/validation'; import { getFileStream, getConfiguredFileSizeLimit } from './utils'; import { Types } from 'mongoose'; const mockedValidatePdf = validatePdf as jest.MockedFunction; +const mockedValidateBedrockDocument = validateBedrockDocument as jest.MockedFunction< + typeof validateBedrockDocument +>; const mockedGetFileStream = getFileStream as jest.MockedFunction; const mockedGetConfiguredFileSizeLimit = getConfiguredFileSizeLimit as jest.MockedFunction< typeof getConfiguredFileSizeLimit @@ -84,6 +88,26 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { updatedAt: new Date(), }) as unknown as IMongoFile; + const createMockDocFile = ( + sizeInMB: number, + mimeType: string, + filename: string, + ): IMongoFile => + ({ + _id: new Types.ObjectId(), + user: new Types.ObjectId(), + file_id: new Types.ObjectId().toString(), + filename, + type: mimeType, + bytes: Math.floor(sizeInMB * 1024 * 1024), + object: 'file', + usage: 0, + source: 'test', + filepath: `/test/path/${filename}`, + createdAt: new Date(), + updatedAt: new Date(), + }) as unknown as IMongoFile; + describe('Configuration extraction and validation', () => { it('should pass configured file size limit to validatePdf for OpenAI', async () => { const configuredLimit = mbToBytes(15); @@ -500,6 +524,165 @@ describe('encodeAndFormatDocuments - fileConfig integration', () => { }); }); + it('should format Bedrock document with valid PDF', async () => { + const req = createMockRequest() as ServerRequest; + const file = createMockFile(3); + + const mockContent = Buffer.from('test-pdf-content').toString('base64'); + mockedGetFileStream.mockResolvedValue({ + file, + content: mockContent, + metadata: file, + }); + + mockedValidateBedrockDocument.mockResolvedValue({ isValid: true }); + + const result = await encodeAndFormatDocuments( + req, + [file], + { provider: Providers.BEDROCK }, + mockStrategyFunctions, + ); + + expect(result.documents).toHaveLength(1); + expect(result.documents[0]).toMatchObject({ + type: 'document', + document: { + name: 'test_pdf', + format: 'pdf', + source: { + bytes: expect.any(Buffer), + }, + }, + }); + }); + + it('should format Bedrock CSV document', async () => { + const req = createMockRequest() as ServerRequest; + const file = createMockDocFile(1, 'text/csv', 'data.csv'); + + const mockContent = Buffer.from('col1,col2\nval1,val2').toString('base64'); + mockedGetFileStream.mockResolvedValue({ + file, + content: mockContent, + metadata: file, + }); + + mockedValidateBedrockDocument.mockResolvedValue({ isValid: true }); + + const result = await encodeAndFormatDocuments( + req, + [file], + { provider: Providers.BEDROCK }, + mockStrategyFunctions, + ); + + expect(result.documents).toHaveLength(1); + expect(result.documents[0]).toMatchObject({ + type: 'document', + document: { + name: 'data_csv', + format: 'csv', + source: { + bytes: expect.any(Buffer), + }, + }, + }); + }); + + it('should format Bedrock DOCX document', async () => { + const req = createMockRequest() as ServerRequest; + const mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + const file = createMockDocFile(2, mimeType, 'report.docx'); + + const mockContent = Buffer.from('docx-binary-content').toString('base64'); + mockedGetFileStream.mockResolvedValue({ + file, + content: mockContent, + metadata: file, + }); + + mockedValidateBedrockDocument.mockResolvedValue({ isValid: true }); + + const result = await encodeAndFormatDocuments( + req, + [file], + { provider: Providers.BEDROCK }, + mockStrategyFunctions, + ); + + expect(result.documents).toHaveLength(1); + expect(result.documents[0]).toMatchObject({ + type: 'document', + document: { + name: 'report_docx', + format: 'docx', + source: { + bytes: expect.any(Buffer), + }, + }, + }); + }); + + it('should format Bedrock plain text document', async () => { + const req = createMockRequest() as ServerRequest; + const file = createMockDocFile(0.5, 'text/plain', 'notes.txt'); + + const mockContent = Buffer.from('plain text content').toString('base64'); + mockedGetFileStream.mockResolvedValue({ + file, + content: mockContent, + metadata: file, + }); + + mockedValidateBedrockDocument.mockResolvedValue({ isValid: true }); + + const result = await encodeAndFormatDocuments( + req, + [file], + { provider: Providers.BEDROCK }, + mockStrategyFunctions, + ); + + expect(result.documents).toHaveLength(1); + expect(result.documents[0]).toMatchObject({ + type: 'document', + document: { + name: 'notes_txt', + format: 'txt', + source: { + bytes: expect.any(Buffer), + }, + }, + }); + }); + + it('should reject Bedrock document when validation fails', async () => { + const req = createMockRequest() as ServerRequest; + const file = createMockDocFile(5, 'text/csv', 'big.csv'); + + const mockContent = Buffer.from('large-csv-content').toString('base64'); + mockedGetFileStream.mockResolvedValue({ + file, + content: mockContent, + metadata: file, + }); + + mockedValidateBedrockDocument.mockResolvedValue({ + isValid: false, + error: 'File size (5.0MB) exceeds the 4.5MB limit for Bedrock', + }); + + await expect( + encodeAndFormatDocuments( + req, + [file], + { provider: Providers.BEDROCK }, + mockStrategyFunctions, + ), + ).rejects.toThrow('Document validation failed'); + }); + it('should format OpenAI document with responses API', async () => { const req = createMockRequest(15) as ServerRequest; const file = createMockFile(10); diff --git a/packages/api/src/files/encode/document.ts b/packages/api/src/files/encode/document.ts index 487a5503a4..e4fd066324 100644 --- a/packages/api/src/files/encode/document.ts +++ b/packages/api/src/files/encode/document.ts @@ -1,5 +1,10 @@ import { Providers } from '@librechat/agents'; -import { isOpenAILikeProvider, isDocumentSupportedProvider } from 'librechat-data-provider'; +import { + isOpenAILikeProvider, + isBedrockDocumentType, + bedrockDocumentFormats, + isDocumentSupportedProvider, +} from 'librechat-data-provider'; import type { IMongoFile } from '@librechat/data-schemas'; import type { AnthropicDocumentBlock, @@ -7,8 +12,8 @@ import type { DocumentResult, ServerRequest, } from '~/types'; +import { validatePdf, validateBedrockDocument } from '~/files/validation'; import { getFileStream, getConfiguredFileSizeLimit } from './utils'; -import { validatePdf } from '~/files/validation'; /** * Processes and encodes document files for various providers @@ -35,9 +40,15 @@ export async function encodeAndFormatDocuments( const encodingMethods: Record = {}; const result: DocumentResult = { documents: [], files: [] }; - const documentFiles = files.filter( - (file) => file.type === 'application/pdf' || file.type?.startsWith('application/'), - ); + const isBedrock = provider === Providers.BEDROCK; + const isDocSupported = isDocumentSupportedProvider(provider); + + const documentFiles = files.filter((file) => { + if (isBedrock && isBedrockDocumentType(file.type)) { + return true; + } + return file.type === 'application/pdf' || file.type?.startsWith('application/'); + }); if (!documentFiles.length) { return result; @@ -45,7 +56,10 @@ export async function encodeAndFormatDocuments( const results = await Promise.allSettled( documentFiles.map((file) => { - if (file.type !== 'application/pdf' || !isDocumentSupportedProvider(provider)) { + const isProcessable = isBedrock + ? isBedrockDocumentType(file.type) + : file.type === 'application/pdf' && isDocSupported; + if (!isProcessable) { return Promise.resolve(null); } return getFileStream(req, file, encodingMethods, getStrategyFunctions); @@ -68,14 +82,40 @@ export async function encodeAndFormatDocuments( continue; } - if (file.type === 'application/pdf' && isDocumentSupportedProvider(provider)) { - const pdfBuffer = Buffer.from(content, 'base64'); + const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, { provider, endpoint }); + const mimeType = file.type ?? ''; - /** Extract configured file size limit from fileConfig for this endpoint */ - const configuredFileSizeLimit = getConfiguredFileSizeLimit(req, { - provider, - endpoint, + if (isBedrock && isBedrockDocumentType(mimeType)) { + const fileBuffer = Buffer.from(content, 'base64'); + const format = bedrockDocumentFormats[mimeType]; + + const validation = await validateBedrockDocument( + fileBuffer.length, + mimeType, + fileBuffer, + configuredFileSizeLimit, + ); + + if (!validation.isValid) { + throw new Error(`Document validation failed: ${validation.error}`); + } + + const sanitizedName = (file.filename || 'document') + .replace(/[^a-zA-Z0-9\s\-()[\]]/g, '_') + .slice(0, 200); + result.documents.push({ + type: 'document', + document: { + name: sanitizedName, + format, + source: { + bytes: fileBuffer, + }, + }, }); + result.files.push(metadata); + } else if (file.type === 'application/pdf' && isDocSupported) { + const pdfBuffer = Buffer.from(content, 'base64'); const validation = await validatePdf( pdfBuffer, diff --git a/packages/api/src/files/validation.spec.ts b/packages/api/src/files/validation.spec.ts index 384f499f43..98dcda4188 100644 --- a/packages/api/src/files/validation.spec.ts +++ b/packages/api/src/files/validation.spec.ts @@ -1,6 +1,6 @@ import { Providers } from '@librechat/agents'; import { mbToBytes } from 'librechat-data-provider'; -import { validatePdf, validateVideo, validateAudio } from './validation'; +import { validatePdf, validateBedrockDocument, validateVideo, validateAudio } from './validation'; describe('PDF Validation with fileConfig.endpoints.*.fileSizeLimit', () => { /** Helper to create a PDF buffer with valid header */ @@ -145,6 +145,122 @@ describe('PDF Validation with fileConfig.endpoints.*.fileSizeLimit', () => { }); }); + describe('validatePdf - Bedrock provider', () => { + const provider = Providers.BEDROCK; + + it('should accept PDF within provider limit when no config provided', async () => { + const pdfBuffer = createMockPdfBuffer(3); + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should reject PDF exceeding 4.5MB hard limit when no config provided', async () => { + const pdfBuffer = createMockPdfBuffer(5); + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('4.5MB'); + }); + + it('should use configured limit when it is lower than provider limit', async () => { + const configuredLimit = mbToBytes(2); + const pdfBuffer = createMockPdfBuffer(3); + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, configuredLimit); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('2.0MB'); + }); + + it('should clamp to 4.5MB hard limit even when config is higher', async () => { + const configuredLimit = mbToBytes(512); + const pdfBuffer = createMockPdfBuffer(5); + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider, configuredLimit); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('4.5MB'); + }); + + it('should reject PDFs with invalid header', async () => { + const pdfBuffer = Buffer.alloc(1024); + pdfBuffer.write('INVALID', 0); + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('PDF header'); + }); + + it('should reject PDFs that are too small', async () => { + const pdfBuffer = Buffer.alloc(3); + const result = await validatePdf(pdfBuffer, pdfBuffer.length, provider); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('too small'); + }); + }); + + describe('validateBedrockDocument - non-PDF types', () => { + it('should accept CSV within 4.5MB limit', async () => { + const fileSize = 2 * 1024 * 1024; + const result = await validateBedrockDocument(fileSize, 'text/csv'); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should accept DOCX within 4.5MB limit', async () => { + const fileSize = 3 * 1024 * 1024; + const mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + const result = await validateBedrockDocument(fileSize, mimeType); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should reject non-PDF document exceeding 4.5MB hard limit', async () => { + const fileSize = 5 * 1024 * 1024; + const result = await validateBedrockDocument(fileSize, 'text/plain'); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('4.5MB'); + }); + + it('should clamp to 4.5MB even when config is higher for non-PDF', async () => { + const fileSize = 5 * 1024 * 1024; + const configuredLimit = mbToBytes(512); + const result = await validateBedrockDocument(fileSize, 'text/html', undefined, configuredLimit); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('4.5MB'); + }); + + it('should use configured limit when lower than provider limit for non-PDF', async () => { + const fileSize = 3 * 1024 * 1024; + const configuredLimit = mbToBytes(2); + const result = await validateBedrockDocument(fileSize, 'text/markdown', undefined, configuredLimit); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('2.0MB'); + }); + + it('should not run PDF header check on non-PDF types', async () => { + const buffer = Buffer.from('NOT-A-PDF-HEADER-but-valid-csv-content'); + const result = await validateBedrockDocument(buffer.length, 'text/csv', buffer); + + expect(result.isValid).toBe(true); + }); + + it('should still run PDF header check when mimeType is application/pdf', async () => { + const buffer = Buffer.alloc(1024); + buffer.write('INVALID', 0); + const result = await validateBedrockDocument(buffer.length, 'application/pdf', buffer); + + expect(result.isValid).toBe(false); + expect(result.error).toContain('PDF header'); + }); + }); + describe('validatePdf - Google provider', () => { const provider = Providers.GOOGLE; diff --git a/packages/api/src/files/validation.ts b/packages/api/src/files/validation.ts index 4b36ac0bff..b3db19e92a 100644 --- a/packages/api/src/files/validation.ts +++ b/packages/api/src/files/validation.ts @@ -1,6 +1,11 @@ import { Providers } from '@librechat/agents'; import { mbToBytes, isOpenAILikeProvider } from 'librechat-data-provider'; +export interface ValidationResult { + isValid: boolean; + error?: string; +} + export interface PDFValidationResult { isValid: boolean; error?: string; @@ -31,6 +36,10 @@ export async function validatePdf( return validateAnthropicPdf(pdfBuffer, fileSize, configuredFileSizeLimit); } + if (provider === Providers.BEDROCK) { + return validateBedrockDocument(fileSize, 'application/pdf', pdfBuffer, configuredFileSizeLimit); + } + if (isOpenAILikeProvider(provider)) { return validateOpenAIPdf(fileSize, configuredFileSizeLimit); } @@ -113,6 +122,64 @@ async function validateAnthropicPdf( } } +/** + * Validates a document against Bedrock's 4.5MB hard limit. PDF-specific header + * checks run only when the MIME type is `application/pdf`. + * @param fileSize - The file size in bytes + * @param mimeType - The MIME type of the document + * @param fileBuffer - The file buffer (used for PDF header validation) + * @param configuredFileSizeLimit - Optional configured file size limit from fileConfig (in bytes) + * @returns Promise that resolves to validation result + */ +export async function validateBedrockDocument( + fileSize: number, + mimeType: string, + fileBuffer?: Buffer, + configuredFileSizeLimit?: number, +): Promise { + try { + /** Bedrock enforces a hard 4.5MB per-document limit at the API level; config can only lower it */ + const providerLimit = mbToBytes(4.5); + const effectiveLimit = + configuredFileSizeLimit != null + ? Math.min(configuredFileSizeLimit, providerLimit) + : providerLimit; + + if (fileSize > effectiveLimit) { + const limitMB = (effectiveLimit / (1024 * 1024)).toFixed(1); + return { + isValid: false, + error: `File size (${(fileSize / (1024 * 1024)).toFixed(1)}MB) exceeds the ${limitMB}MB limit for Bedrock`, + }; + } + + if (mimeType === 'application/pdf' && fileBuffer) { + if (fileBuffer.length < 5) { + return { + isValid: false, + error: 'Invalid PDF file: too small or corrupted', + }; + } + + const pdfHeader = fileBuffer.subarray(0, 5).toString(); + if (!pdfHeader.startsWith('%PDF-')) { + return { + isValid: false, + error: 'Invalid PDF file: missing PDF header', + }; + } + } + + return { isValid: true }; + } catch (error) { + console.error('Bedrock document validation error:', error); + return { + isValid: false, + error: 'Failed to validate document file', + }; + } +} + /** * Validates if a PDF meets OpenAI's requirements * @param fileSize - The file size in bytes diff --git a/packages/api/src/types/files.ts b/packages/api/src/types/files.ts index 6a403932da..ada6ff024c 100644 --- a/packages/api/src/types/files.ts +++ b/packages/api/src/types/files.ts @@ -1,6 +1,7 @@ +import type { BedrockDocumentFormat } from 'librechat-data-provider'; import type { IMongoFile } from '@librechat/data-schemas'; -import type { ServerRequest } from './http'; import type { Readable } from 'stream'; +import type { ServerRequest } from './http'; export interface STTService { getInstance(): Promise; getProviderSchema(req: ServerRequest): Promise<[string, object]>; @@ -95,11 +96,24 @@ export interface OpenAIInputFileBlock { file_data: string; } +/** Bedrock Converse API document block (passthrough via @langchain/aws) */ +export interface BedrockDocumentBlock { + type: 'document'; + document: { + name: string; + format: BedrockDocumentFormat; + source: { + bytes: Buffer; + }; + }; +} + export type DocumentBlock = | AnthropicDocumentBlock | GoogleDocumentBlock | OpenAIFileBlock - | OpenAIInputFileBlock; + | OpenAIInputFileBlock + | BedrockDocumentBlock; export interface DocumentResult { documents: DocumentBlock[]; diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 98254390b9..5a117eb760 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -139,6 +139,39 @@ export const retrievalMimeTypesList = [ export const imageExtRegex = /\.(jpg|jpeg|png|gif|webp|heic|heif)$/i; +/** @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html */ +export type BedrockDocumentFormat = + | 'pdf' + | 'csv' + | 'doc' + | 'docx' + | 'xls' + | 'xlsx' + | 'html' + | 'txt' + | 'md'; + +/** Maps MIME types to Bedrock Converse API document format values */ +export const bedrockDocumentFormats: Record = { + 'application/pdf': 'pdf', + 'text/csv': 'csv', + 'application/csv': 'csv', + 'application/msword': 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'application/vnd.ms-excel': 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx', + 'text/html': 'html', + 'text/plain': 'txt', + 'text/markdown': 'md', +}; + +export const isBedrockDocumentType = (mimeType?: string): boolean => + mimeType != null && mimeType in bedrockDocumentFormats; + +/** File extensions accepted by Bedrock document uploads (for input accept attributes) */ +export const bedrockDocumentExtensions = + '.pdf,.csv,.doc,.docx,.xls,.xlsx,.html,.htm,.txt,.md,application/pdf,text/csv,application/csv,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/html,text/plain,text/markdown'; + export const excelMimeTypes = /^application\/(vnd\.ms-excel|msexcel|x-msexcel|x-ms-excel|x-excel|x-dos_ms_excel|xls|x-xls|vnd\.openxmlformats-officedocument\.spreadsheetml\.sheet)$/; @@ -146,7 +179,7 @@ export const textMimeTypes = /^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv|xml))$/; export const applicationMimeTypes = - /^(application\/(epub\+zip|csv|json|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/; + /^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/; export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/; diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 039bfa572e..60a20ecae7 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -49,6 +49,7 @@ export enum Providers { export const documentSupportedProviders = new Set([ EModelEndpoint.anthropic, EModelEndpoint.openAI, + EModelEndpoint.bedrock, EModelEndpoint.custom, // handled in AttachFileMenu and DragDropModal since azureOpenAI only supports documents with Use Responses API set to true // EModelEndpoint.azureOpenAI, From f3eb197675d8277eccdac8c9de5bda950cdb0667 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 24 Feb 2026 08:21:02 -0500 Subject: [PATCH 014/110] =?UTF-8?q?=F0=9F=92=8E=20fix:=20Gemini=20Image=20?= =?UTF-8?q?Gen=20Tool=20Vertex=20AI=20Auth=20and=20File=20Storage=20(#1192?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: saveToCloudStorage function and enhance error handling - Removed unnecessary parameters and streamlined the logic for saving images to cloud storage. - Introduced buffer handling for base64 image data and improved the integration with file strategy functions. - Enhanced error handling during local image saving to ensure robustness. - Updated the createGeminiImageTool function to reflect changes in the saveToCloudStorage implementation. * refactor: streamline image persistence logic in GeminiImageGen - Consolidated image saving functionality by renaming and refactoring the saveToCloudStorage function to persistGeneratedImage. - Improved error handling and logging for image persistence operations. - Enhanced the replaceUnwantedChars function to better sanitize input strings. - Updated createGeminiImageTool to reflect changes in image handling and ensure consistent behavior across storage strategies. * fix: clean up GeminiImageGen by removing unused functions and improving logging - Removed the getSafeFormat and persistGeneratedImage functions to streamline image handling. - Updated logging in createGeminiImageTool for clarity and consistency. - Consolidated imports by eliminating unused dependencies, enhancing code maintainability. * chore: update environment configuration and manifest for unused GEMINI_VERTEX_ENABLED - Removed the Vertex AI configuration option from .env.example to simplify setup. - Updated the manifest.json to reflect the removal of the Vertex AI dependency in the authentication field. - Cleaned up the createGeminiImageTool function by eliminating unused fields related to Vertex AI, streamlining the code. * fix: update loadAuthValues call in loadTools function for GeminiImageGen tool - Modified the loadAuthValues function call to include throwError: false, preventing exceptions on authentication failures. - Removed the unused processFileURL parameter from the tool context object, streamlining the code. * refactor: streamline GoogleGenAI initialization in GeminiImageGen - Removed unused file system access check for Google application credentials, simplifying the environment setup. - Added googleAuthOptions to the GoogleGenAI instantiation, enhancing the configuration for authentication. * fix: update Gemini API Key label and description in manifest.json - Changed the label to indicate that the Gemini API Key is optional. - Revised the description to clarify usage with Vertex AI and service accounts, enhancing user guidance. * fix: enhance abort signal handling in createGeminiImageTool - Introduced derivedSignal to manage abort events during image generation, improving responsiveness to cancellation requests. - Added an abortHandler to log when image generation is aborted, enhancing debugging capabilities. - Ensured proper cleanup of event listeners in the finally block to prevent memory leaks. * fix: update authentication handling for plugins to support optional fields - Added support for optional authentication fields in the manifest and PluginAuthForm. - Updated the checkPluginAuth function to correctly validate plugins with optional fields. - Enhanced tests to cover scenarios with optional authentication fields, ensuring accurate validation logic. --- .env.example | 4 - api/app/clients/tools/manifest.json | 7 +- .../tools/structured/GeminiImageGen.js | 202 ++++-------------- api/app/clients/tools/util/handleTools.js | 3 +- .../Plugins/Store/PluginAuthForm.tsx | 25 ++- packages/api/src/tools/format.spec.ts | 71 ++++++ packages/api/src/tools/format.ts | 4 + packages/data-provider/src/schemas.ts | 1 + 8 files changed, 136 insertions(+), 181 deletions(-) diff --git a/.env.example b/.env.example index b7ec3a3dad..f6d2ec271f 100644 --- a/.env.example +++ b/.env.example @@ -243,10 +243,6 @@ GOOGLE_KEY=user_provided # Option A: Use dedicated Gemini API key for image generation # GEMINI_API_KEY=your-gemini-api-key -# Option B: Use Vertex AI (no API key needed, uses service account) -# Set this to enable Vertex AI and allow tool without requiring API keys -# GEMINI_VERTEX_ENABLED=true - # Vertex AI model for image generation (defaults to gemini-2.5-flash-image) # GEMINI_IMAGE_MODEL=gemini-2.5-flash-image diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index 7930e67ac9..b0dca8971e 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -161,9 +161,10 @@ "icon": "assets/gemini_image_gen.svg", "authConfig": [ { - "authField": "GEMINI_API_KEY||GOOGLE_KEY||GEMINI_VERTEX_ENABLED", - "label": "Gemini API Key (Optional if Vertex AI is configured)", - "description": "Your Google Gemini API Key from Google AI Studio. Leave blank if using Vertex AI with service account." + "authField": "GEMINI_API_KEY||GOOGLE_KEY||GOOGLE_SERVICE_KEY_FILE", + "label": "Gemini API Key (optional)", + "description": "Your Google Gemini API Key from Google AI Studio. Leave blank to use Vertex AI with a service account (GOOGLE_SERVICE_KEY_FILE or api/data/auth.json).", + "optional": true } ] } diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index c0e5a0ce1d..b201db019d 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -1,4 +1,3 @@ -const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); const { v4 } = require('uuid'); @@ -6,12 +5,7 @@ const { ProxyAgent } = require('undici'); const { GoogleGenAI } = require('@google/genai'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); -const { - FileContext, - ContentTypes, - FileSources, - EImageOutputType, -} = require('librechat-data-provider'); +const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); const { geminiToolkit, loadServiceKey, @@ -59,17 +53,12 @@ const displayMessage = * @returns {string} - The processed string */ function replaceUnwantedChars(inputString) { - return inputString?.replace(/[^\w\s\-_.,!?()]/g, '') || ''; -} - -/** - * Validate and sanitize image format - * @param {string} format - The format to validate - * @returns {string} - Safe format - */ -function getSafeFormat(format) { - const allowedFormats = ['png', 'jpg', 'jpeg', 'webp', 'gif']; - return allowedFormats.includes(format?.toLowerCase()) ? format.toLowerCase() : 'png'; + return ( + inputString + ?.replace(/\r\n|\r|\n/g, ' ') + .replace(/"/g, '') + .trim() || '' + ); } /** @@ -117,11 +106,8 @@ async function initializeGeminiClient(options = {}) { return new GoogleGenAI({ apiKey: googleKey }); } - // Fall back to Vertex AI with service account logger.debug('[GeminiImageGen] Using Vertex AI with service account'); const credentialsPath = getDefaultServiceKeyPath(); - - // Use loadServiceKey for consistent loading (supports file paths, JSON strings, base64) const serviceKey = await loadServiceKey(credentialsPath); if (!serviceKey || !serviceKey.project_id) { @@ -131,75 +117,14 @@ async function initializeGeminiClient(options = {}) { ); } - // Set GOOGLE_APPLICATION_CREDENTIALS for any Google Cloud SDK dependencies - try { - await fs.promises.access(credentialsPath); - process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; - } catch { - // File doesn't exist, skip setting env var - } - return new GoogleGenAI({ vertexai: true, project: serviceKey.project_id, location: process.env.GOOGLE_LOC || process.env.GOOGLE_CLOUD_LOCATION || 'global', + googleAuthOptions: { credentials: serviceKey }, }); } -/** - * Save image to local filesystem - * @param {string} base64Data - Base64 encoded image data - * @param {string} format - Image format - * @param {string} userId - User ID - * @returns {Promise} - The relative URL - */ -async function saveImageLocally(base64Data, format, userId) { - const safeFormat = getSafeFormat(format); - const safeUserId = userId ? path.basename(userId) : 'default'; - const imageName = `gemini-img-${v4()}.${safeFormat}`; - const userDir = path.join(process.cwd(), 'client/public/images', safeUserId); - - await fs.promises.mkdir(userDir, { recursive: true }); - - const filePath = path.join(userDir, imageName); - await fs.promises.writeFile(filePath, Buffer.from(base64Data, 'base64')); - - logger.debug('[GeminiImageGen] Image saved locally to:', filePath); - return `/images/${safeUserId}/${imageName}`; -} - -/** - * Save image to cloud storage - * @param {Object} params - Parameters - * @returns {Promise} - The storage URL or null - */ -async function saveToCloudStorage({ base64Data, format, processFileURL, fileStrategy, userId }) { - if (!processFileURL || !fileStrategy || !userId) { - return null; - } - - try { - const safeFormat = getSafeFormat(format); - const safeUserId = path.basename(userId); - const dataURL = `data:image/${safeFormat};base64,${base64Data}`; - const imageName = `gemini-img-${v4()}.${safeFormat}`; - - const result = await processFileURL({ - URL: dataURL, - basePath: 'images', - userId: safeUserId, - fileName: imageName, - fileStrategy, - context: FileContext.image_generation, - }); - - return result.filepath; - } catch (error) { - logger.error('[GeminiImageGen] Error saving to cloud storage:', error); - return null; - } -} - /** * Convert image files to Gemini inline data format * @param {Object} params - Parameters @@ -390,34 +315,18 @@ function createGeminiImageTool(fields = {}) { throw new Error('This tool is only available for agents.'); } - // Skip validation during tool creation - validation happens at runtime in initializeGeminiClient - // This allows the tool to be added to agents when using Vertex AI without requiring API keys - // The actual credentials check happens when the tool is invoked - - const { - req, - imageFiles = [], - processFileURL, - userId, - fileStrategy, - GEMINI_API_KEY, - GOOGLE_KEY, - // GEMINI_VERTEX_ENABLED is used for auth validation only (not used in code) - // When set as env var, it signals Vertex AI is configured and bypasses API key requirement - } = fields; + const { req, imageFiles = [], userId, fileStrategy, GEMINI_API_KEY, GOOGLE_KEY } = fields; const imageOutputType = fields.imageOutputType || EImageOutputType.PNG; const geminiImageGenTool = tool( - async ({ prompt, image_ids, aspectRatio, imageSize }, _runnableConfig) => { + async ({ prompt, image_ids, aspectRatio, imageSize }, runnableConfig) => { if (!prompt) { throw new Error('Missing required field: prompt'); } - logger.debug('[GeminiImageGen] Generating image with prompt:', prompt?.substring(0, 100)); - logger.debug('[GeminiImageGen] Options:', { aspectRatio, imageSize }); + logger.debug('[GeminiImageGen] Generating image', { aspectRatio, imageSize }); - // Initialize Gemini client with user-provided credentials let ai; try { ai = await initializeGeminiClient({ @@ -432,10 +341,8 @@ function createGeminiImageTool(fields = {}) { ]; } - // Build request contents const contents = [{ text: replaceUnwantedChars(prompt) }]; - // Add context images if provided if (image_ids?.length > 0) { const contextImages = await convertImagesToInlineData({ imageFiles, @@ -447,28 +354,34 @@ function createGeminiImageTool(fields = {}) { logger.debug('[GeminiImageGen] Added', contextImages.length, 'context images'); } - // Generate image let apiResponse; const geminiModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image'; - try { - // Build config with optional imageConfig - const config = { - responseModalities: ['TEXT', 'IMAGE'], - }; + const config = { + responseModalities: ['TEXT', 'IMAGE'], + }; - // Add imageConfig if aspectRatio or imageSize is specified - // Note: gemini-2.5-flash-image doesn't support imageSize - const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image'); - if (aspectRatio || (imageSize && supportsImageSize)) { - config.imageConfig = {}; - if (aspectRatio) { - config.imageConfig.aspectRatio = aspectRatio; - } - if (imageSize && supportsImageSize) { - config.imageConfig.imageSize = imageSize; - } + const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image'); + if (aspectRatio || (imageSize && supportsImageSize)) { + config.imageConfig = {}; + if (aspectRatio) { + config.imageConfig.aspectRatio = aspectRatio; } + if (imageSize && supportsImageSize) { + config.imageConfig.imageSize = imageSize; + } + } + let derivedSignal = null; + let abortHandler = null; + + if (runnableConfig?.signal) { + derivedSignal = AbortSignal.any([runnableConfig.signal]); + abortHandler = () => logger.debug('[GeminiImageGen] Image generation aborted'); + derivedSignal.addEventListener('abort', abortHandler, { once: true }); + config.abortSignal = derivedSignal; + } + + try { apiResponse = await ai.models.generateContent({ model: geminiModel, contents, @@ -480,9 +393,12 @@ function createGeminiImageTool(fields = {}) { [{ type: ContentTypes.TEXT, text: `Image generation failed: ${error.message}` }], { content: [], file_ids: [] }, ]; + } finally { + if (abortHandler && derivedSignal) { + derivedSignal.removeEventListener('abort', abortHandler); + } } - // Check for safety blocks const safetyBlock = checkForSafetyBlock(apiResponse); if (safetyBlock) { logger.warn('[GeminiImageGen] Safety block:', safetyBlock); @@ -509,46 +425,7 @@ function createGeminiImageTool(fields = {}) { const imageData = convertedBuffer.toString('base64'); const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : `image/${outputFormat}`; - logger.debug('[GeminiImageGen] Image format:', { outputFormat, mimeType }); - - let imageUrl; - const useLocalStorage = !fileStrategy || fileStrategy === FileSources.local; - - if (useLocalStorage) { - try { - imageUrl = await saveImageLocally(imageData, outputFormat, userId); - } catch (error) { - logger.error('[GeminiImageGen] Local save failed:', error); - imageUrl = `data:${mimeType};base64,${imageData}`; - } - } else { - const cloudUrl = await saveToCloudStorage({ - base64Data: imageData, - format: outputFormat, - processFileURL, - fileStrategy, - userId, - }); - - if (cloudUrl) { - imageUrl = cloudUrl; - } else { - // Fallback to local - try { - imageUrl = await saveImageLocally(imageData, outputFormat, userId); - } catch (_error) { - imageUrl = `data:${mimeType};base64,${imageData}`; - } - } - } - - logger.debug('[GeminiImageGen] Image URL:', imageUrl); - - // For the artifact, we need a data URL (same as OpenAI) - // The local file save is for persistence, but the response needs a data URL const dataUrl = `data:${mimeType};base64,${imageData}`; - - // Return in content_and_artifact format (same as OpenAI) const file_ids = [v4()]; const content = [ { @@ -567,8 +444,7 @@ function createGeminiImageTool(fields = {}) { }, ]; - // Record token usage for balance tracking (don't await to avoid blocking response) - const conversationId = _runnableConfig?.configurable?.thread_id; + const conversationId = runnableConfig?.configurable?.thread_id; recordTokenUsage({ usageMetadata: apiResponse.usageMetadata, req, diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 65c88ce83f..48fcf7cb83 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -207,7 +207,7 @@ const loadTools = async ({ }, gemini_image_gen: async (toolContextMap) => { const authFields = getAuthFields('gemini_image_gen'); - const authValues = await loadAuthValues({ userId: user, authFields }); + const authValues = await loadAuthValues({ userId: user, authFields, throwError: false }); const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? []; const toolContext = buildImageToolContext({ imageFiles, @@ -222,7 +222,6 @@ const loadTools = async ({ isAgent: !!agent, req: options.req, imageFiles, - processFileURL: options.processFileURL, userId: user, fileStrategy, }); diff --git a/client/src/components/Plugins/Store/PluginAuthForm.tsx b/client/src/components/Plugins/Store/PluginAuthForm.tsx index 5af1948c11..d304b2eab7 100644 --- a/client/src/components/Plugins/Store/PluginAuthForm.tsx +++ b/client/src/components/Plugins/Store/PluginAuthForm.tsx @@ -20,6 +20,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps const localize = useLocalize(); const authConfig = plugin?.authConfig ?? []; + const allFieldsOptional = authConfig.length > 0 && authConfig.every((c) => c.optional === true); return (
@@ -38,6 +39,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps > {authConfig.map((config: TPluginAuthConfig, i: number) => { const authField = config.authField.split('||')[0]; + const isOptional = config.optional === true; return (
{ isExpanded={isExpanded} onClick={handleClick} content={reasoningText} + contentId={contentId} />
diff --git a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx index 0c5992f4ab..7641738c15 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Thinking.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, memo, useCallback, useRef, type MouseEvent } from 'react'; +import { useState, useMemo, memo, useCallback, useRef, useId, type MouseEvent } from 'react'; import { useAtomValue } from 'jotai'; import { Clipboard, CheckMark, TooltipAnchor } from '@librechat/client'; import { Lightbulb, ChevronDown, ChevronUp } from 'lucide-react'; @@ -35,12 +35,14 @@ export const ThinkingButton = memo( onClick, label, content, + contentId, showCopyButton = true, }: { isExpanded: boolean; onClick: (e: MouseEvent) => void; label: string; content?: string; + contentId: string; showCopyButton?: boolean; }) => { const localize = useLocalize(); @@ -66,6 +68,7 @@ export const ThinkingButton = memo( type="button" onClick={onClick} aria-expanded={isExpanded} + aria-controls={contentId} className={cn( 'group/button flex flex-1 items-center justify-start rounded-lg leading-[18px]', fontSize, @@ -132,11 +135,13 @@ export const FloatingThinkingBar = memo( isExpanded, onClick, content, + contentId, }: { isVisible: boolean; isExpanded: boolean; onClick: (e: MouseEvent) => void; content?: string; + contentId: string; }) => { const localize = useLocalize(); const [isCopied, setIsCopied] = useState(false); @@ -176,6 +181,8 @@ export const FloatingThinkingBar = memo( tabIndex={isVisible ? 0 : -1} onClick={onClick} aria-label={collapseTooltip} + aria-expanded={isExpanded} + aria-controls={contentId} className={cn( 'flex items-center justify-center rounded-lg bg-surface-secondary p-1.5 text-text-secondary-alt shadow-sm', 'hover:bg-surface-hover hover:text-text-primary', @@ -240,6 +247,7 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN const [isExpanded, setIsExpanded] = useState(showThinking); const [isBarVisible, setIsBarVisible] = useState(false); const containerRef = useRef(null); + const contentId = useId(); const handleClick = useCallback((e: MouseEvent) => { e.preventDefault(); @@ -295,9 +303,14 @@ const Thinking: React.ElementType = memo(({ children }: { children: React.ReactN onClick={handleClick} label={label} content={textContent} + contentId={contentId} />
@@ -322,4 +336,4 @@ ThinkingContent.displayName = 'ThinkingContent'; FloatingThinkingBar.displayName = 'FloatingThinkingBar'; Thinking.displayName = 'Thinking'; -export default memo(Thinking); +export default Thinking; From 9a8a5d66d7051912308c7f3bf2e5819189844747 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 24 Feb 2026 21:05:58 -0500 Subject: [PATCH 018/110] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20fix:=20Separate=20?= =?UTF-8?q?MCP=20GET=20SSE=20Stream=20Timeout=20from=20POST=20and=20Suppre?= =?UTF-8?q?ss=20SDK-Internal=20Recovery=20Errors=20(#11936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Separate MCP GET SSE body timeout from POST and suppress SDK-internal stream recovery - Add a dedicated GET Agent with a configurable `sseReadTimeout` (default 5 min, matching the Python MCP SDK) so idle SSE streams time out independently of POST requests, preventing the reconnect-loop log flood described in Discussion #11230. - Suppress "SSE stream disconnected" and "Failed to reconnect SSE stream" errors in setupTransportErrorHandlers — these are SDK-internal recovery events, not transport failures. "Maximum reconnection attempts exceeded" still escalates. - Add optional `sseReadTimeout` to BaseOptionsSchema for per-server configuration. - Add 6 tests: agent timeout separation, custom sseReadTimeout, SSE disconnect suppression (3 unit), and a real-server integration test proving the GET stream recovers without a full transport rebuild. * fix: Refactor MCP connection timeouts and error handling - Updated the `DEFAULT_SSE_READ_TIMEOUT` to use a constant for better readability. - Introduced internal error message constants for SSE stream disconnection and reconnection failures to improve maintainability. - Enhanced type safety in tests by ensuring the options symbol is defined before usage. - Updated the `sseReadTimeout` in `BaseOptionsSchema` to enforce positive values, ensuring valid configurations. * chore: Update SSE read timeout documentation format in BaseOptionsSchema - Changed the default timeout value comment in BaseOptionsSchema to use an underscore for better readability, aligning with common formatting practices. --- .../MCPConnectionAgentLifecycle.test.ts | 195 +++++++++++++++++- packages/api/src/mcp/connection.ts | 66 +++++- packages/data-provider/src/mcp.ts | 3 + 3 files changed, 254 insertions(+), 10 deletions(-) diff --git a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts index f3b3a4c0f2..14e0694558 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts @@ -229,7 +229,8 @@ describe('MCPConnection Agent lifecycle – streamable-http', () => { await safeDisconnect(conn); /** - * streamable-http creates one Agent via createFetchFunction. + * streamable-http creates two Agents via createFetchFunction: one for POST + * (normal timeout) and one for GET SSE (long body timeout). * If agents were per-request (old bug), they would not be stored and close * would be called 0 times. With our fix, Agents are stored and closed on * disconnect regardless of request count — confirming reuse. @@ -300,6 +301,57 @@ describe('MCPConnection Agent lifecycle – streamable-http', () => { expect(closeSpy.mock.calls.length).toBe(countAfterFirst); conn = null; }); + + it('creates separate Agents for POST (normal timeout) and GET SSE (default sseReadTimeout)', async () => { + conn = new MCPConnection({ + serverName: 'test', + serverConfig: { type: 'streamable-http', url: server.url }, + useSSRFProtection: false, + }); + + await conn.connect(); + + const agents = (conn as unknown as { agents: Agent[] }).agents; + expect(agents.length).toBeGreaterThanOrEqual(2); + + const optionsSym = Object.getOwnPropertySymbols(agents[0]).find( + (s) => s.toString() === 'Symbol(options)', + ); + expect(optionsSym).toBeDefined(); + + const bodyTimeouts = agents.map( + (a) => (a as unknown as Record)[optionsSym!].bodyTimeout, + ); + + const hasShortTimeout = bodyTimeouts.some((t) => t <= 120_000); + const hasLongTimeout = bodyTimeouts.some((t) => t === 5 * 60 * 1000); + + expect(hasShortTimeout).toBe(true); + expect(hasLongTimeout).toBe(true); + }); + + it('respects a custom sseReadTimeout from server config', async () => { + const customTimeout = 10 * 60 * 1000; + conn = new MCPConnection({ + serverName: 'test', + serverConfig: { type: 'streamable-http', url: server.url, sseReadTimeout: customTimeout }, + useSSRFProtection: false, + }); + + await conn.connect(); + + const agents = (conn as unknown as { agents: Agent[] }).agents; + const optionsSym = Object.getOwnPropertySymbols(agents[0]).find( + (s) => s.toString() === 'Symbol(options)', + ); + expect(optionsSym).toBeDefined(); + + const bodyTimeouts = agents.map( + (a) => (a as unknown as Record)[optionsSym!].bodyTimeout, + ); + + expect(bodyTimeouts).toContain(customTimeout); + }); }); describe('MCPConnection Agent lifecycle – SSE', () => { @@ -528,3 +580,144 @@ describe('MCPConnection SSE 404 handling – session-aware', () => { expect(emitSpy).not.toHaveBeenCalledWith('connectionChange', 'error'); }); }); + +describe('MCPConnection SSE stream disconnect handling', () => { + function makeTransportStub() { + return { + onerror: undefined as ((e: Error) => void) | undefined, + onclose: undefined as (() => void) | undefined, + onmessage: undefined as ((m: unknown) => void) | undefined, + start: jest.fn(), + close: jest.fn(), + send: jest.fn(), + }; + } + + function makeConn() { + return new MCPConnection({ + serverName: 'test-sse-disconnect', + serverConfig: { url: 'http://127.0.0.1:1/sse' }, + useSSRFProtection: false, + }); + } + + function bindErrorHandler(conn: MCPConnection, transport: ReturnType) { + ( + conn as unknown as { setupTransportErrorHandlers: (t: unknown) => void } + ).setupTransportErrorHandlers(transport); + } + + beforeEach(() => { + mockLogger.debug.mockClear(); + mockLogger.error.mockClear(); + }); + + it('suppresses "SSE stream disconnected" errors from escalating to full reconnection', () => { + const conn = makeConn(); + const transport = makeTransportStub(); + const emitSpy = jest.spyOn(conn, 'emit'); + bindErrorHandler(conn, transport); + + transport.onerror?.( + new Error('SSE stream disconnected: AbortError: The operation was aborted'), + ); + + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('SDK SSE stream recovery in progress'), + ); + expect(emitSpy).not.toHaveBeenCalledWith('connectionChange', 'error'); + }); + + it('suppresses "Failed to reconnect SSE stream" errors (SDK still has retries left)', () => { + const conn = makeConn(); + const transport = makeTransportStub(); + const emitSpy = jest.spyOn(conn, 'emit'); + bindErrorHandler(conn, transport); + + transport.onerror?.(new Error('Failed to reconnect SSE stream: connection refused')); + + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('SDK SSE stream recovery in progress'), + ); + expect(emitSpy).not.toHaveBeenCalledWith('connectionChange', 'error'); + }); + + it('escalates "Maximum reconnection attempts exceeded" (SDK gave up)', () => { + const conn = makeConn(); + const transport = makeTransportStub(); + const emitSpy = jest.spyOn(conn, 'emit'); + bindErrorHandler(conn, transport); + + transport.onerror?.(new Error('Maximum reconnection attempts (2) exceeded.')); + + expect(emitSpy).toHaveBeenCalledWith('connectionChange', 'error'); + }); + + it('still escalates non-SSE-stream errors (e.g. POST failures)', () => { + const conn = makeConn(); + const transport = makeTransportStub(); + const emitSpy = jest.spyOn(conn, 'emit'); + bindErrorHandler(conn, transport); + + transport.onerror?.(new Error('Streamable HTTP error: Error POSTing to endpoint: 500')); + + expect(emitSpy).toHaveBeenCalledWith('connectionChange', 'error'); + }); +}); + +describe('MCPConnection SSE GET stream recovery – integration', () => { + let server: TestServer; + let conn: MCPConnection | null; + + beforeEach(async () => { + server = await createStreamableServer(); + conn = null; + }); + + afterEach(async () => { + await safeDisconnect(conn); + conn = null; + jest.restoreAllMocks(); + await server.close(); + }); + + it('survives a GET SSE body timeout without triggering a full transport rebuild', async () => { + const SHORT_SSE_TIMEOUT = 1500; + + conn = new MCPConnection({ + serverName: 'test-sse-recovery', + serverConfig: { + type: 'streamable-http', + url: server.url, + sseReadTimeout: SHORT_SSE_TIMEOUT, + }, + useSSRFProtection: false, + }); + + await conn.connect(); + + await conn.fetchTools(); + + /** + * Wait for the GET SSE body timeout to fire. The SDK will see a stream + * error and call onerror("SSE stream disconnected: …"), then internally + * schedule a reconnection. Our handler should suppress the escalation. + */ + await new Promise((resolve) => setTimeout(resolve, SHORT_SSE_TIMEOUT + 1000)); + + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('SDK SSE stream recovery in progress'), + ); + expect(mockLogger.error).not.toHaveBeenCalledWith( + expect.stringContaining('Reconnection handler failed'), + expect.anything(), + ); + + /** + * The connection should still be functional — POST requests use a + * separate Agent with the normal timeout and are unaffected. + */ + const tools = await conn.fetchTools(); + expect(tools).toBeDefined(); + }, 10_000); +}); diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 534607234e..5744059708 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -71,6 +71,17 @@ const FIVE_MINUTES = 5 * 60 * 1000; const DEFAULT_TIMEOUT = 60000; /** SSE connections through proxies may need longer initial handshake time */ const SSE_CONNECT_TIMEOUT = 120000; +/** Default body timeout for Streamable HTTP GET SSE streams that idle between server pushes */ +const DEFAULT_SSE_READ_TIMEOUT = FIVE_MINUTES; + +/** + * Error message prefixes emitted by the MCP SDK's StreamableHTTPClientTransport + * (client/streamableHttp.ts → _handleSseStream / _scheduleReconnection). + * These are SDK-internal strings, not part of a public API. If the SDK changes + * them, suppression in setupTransportErrorHandlers will silently stop working. + */ +const SDK_SSE_STREAM_DISCONNECTED = 'SSE stream disconnected'; +const SDK_SSE_RECONNECT_FAILED = 'Failed to reconnect SSE stream'; /** * Headers for SSE connections. @@ -254,6 +265,7 @@ export class MCPConnection extends EventEmitter { private readonly useSSRFProtection: boolean; iconPath?: string; timeout?: number; + sseReadTimeout?: number; url?: string; /** @@ -285,6 +297,7 @@ export class MCPConnection extends EventEmitter { this.useSSRFProtection = params.useSSRFProtection === true; this.iconPath = params.serverConfig.iconPath; this.timeout = params.serverConfig.timeout; + this.sseReadTimeout = params.serverConfig.sseReadTimeout; this.lastPingTime = Date.now(); this.createdAt = Date.now(); // Record creation timestamp for staleness detection if (params.oauthTokens) { @@ -313,30 +326,45 @@ export class MCPConnection extends EventEmitter { * Factory function to create fetch functions without capturing the entire `this` context. * This helps prevent memory leaks by only passing necessary dependencies. * - * @param getHeaders Function to retrieve request headers - * @param timeout Timeout value for the agent (in milliseconds) - * @returns A fetch function that merges headers appropriately + * When `sseBodyTimeout` is provided, a second Agent is created with a much longer + * body timeout for GET requests (the Streamable HTTP SSE stream). POST requests + * continue using the normal timeout so they fail fast on real errors. */ private createFetchFunction( getHeaders: () => Record | null | undefined, timeout?: number, + sseBodyTimeout?: number, ): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise { const ssrfConnect = this.useSSRFProtection ? createSSRFSafeUndiciConnect() : undefined; + const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {}; const effectiveTimeout = timeout || DEFAULT_TIMEOUT; - const agent = new Agent({ + const postAgent = new Agent({ bodyTimeout: effectiveTimeout, headersTimeout: effectiveTimeout, - ...(ssrfConnect != null ? { connect: ssrfConnect } : {}), + ...connectOpts, }); - this.agents.push(agent); + this.agents.push(postAgent); + + let getAgent: Agent | undefined; + if (sseBodyTimeout != null) { + getAgent = new Agent({ + bodyTimeout: sseBodyTimeout, + headersTimeout: effectiveTimeout, + ...connectOpts, + }); + this.agents.push(getAgent); + } return function customFetch( input: UndiciRequestInfo, init?: UndiciRequestInit, ): Promise { + const isGet = (init?.method ?? 'GET').toUpperCase() === 'GET'; + const dispatcher = isGet && getAgent ? getAgent : postAgent; + const requestHeaders = getHeaders(); if (!requestHeaders) { - return undiciFetch(input, { ...init, dispatcher: agent }); + return undiciFetch(input, { ...init, dispatcher }); } let initHeaders: Record = {}; @@ -356,7 +384,7 @@ export class MCPConnection extends EventEmitter { ...initHeaders, ...requestHeaders, }, - dispatcher: agent, + dispatcher, }); }; } @@ -507,6 +535,7 @@ export class MCPConnection extends EventEmitter { fetch: this.createFetchFunction( this.getRequestHeaders.bind(this), this.timeout, + this.sseReadTimeout || DEFAULT_SSE_READ_TIMEOUT, ) as unknown as FetchLike, }); @@ -829,7 +858,26 @@ export class MCPConnection extends EventEmitter { private setupTransportErrorHandlers(transport: Transport): void { transport.onerror = (error) => { - // Extract meaningful error information (handles "SSE error: undefined" cases) + const rawMessage = + error && typeof error === 'object' ? ((error as { message?: string }).message ?? '') : ''; + + /** + * The MCP SDK's StreamableHTTPClientTransport fires onerror for SSE GET stream + * disconnects but also handles reconnection internally via _scheduleReconnection. + * Escalating these to a full transport rebuild creates a redundant reconnection + * loop. Log at debug level and let the SDK recover the GET stream on its own. + * + * "Maximum reconnection attempts … exceeded" means the SDK gave up — that one + * must fall through so our higher-level reconnection takes over. + */ + if ( + rawMessage.startsWith(SDK_SSE_STREAM_DISCONNECTED) || + rawMessage.startsWith(SDK_SSE_RECONNECT_FAILED) + ) { + logger.debug(`${this.getLogPrefix()} SDK SSE stream recovery in progress: ${rawMessage}`); + return; + } + const { message: errorMessage, code: errorCode, diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 50a9746357..3911e91ed0 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -19,6 +19,8 @@ const BaseOptionsSchema = z.object({ startup: z.boolean().optional(), iconPath: z.string().optional(), timeout: z.number().optional(), + /** Timeout (ms) for the long-lived SSE GET stream body before undici aborts it. Default: 300_000 (5 min). */ + sseReadTimeout: z.number().positive().optional(), initTimeout: z.number().optional(), /** Controls visibility in chat dropdown menu (MCPSelect) */ chatMenu: z.boolean().optional(), @@ -212,6 +214,7 @@ const omitServerManagedFields = >(schema: T schema.omit({ startup: true, timeout: true, + sseReadTimeout: true, initTimeout: true, chatMenu: true, serverInstructions: true, From 4080e914e2b9be9b85fed20507450210634fdbca Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 24 Feb 2026 21:10:34 -0500 Subject: [PATCH 019/110] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Bump=20`@modelc?= =?UTF-8?q?ontextprotocol/sdk`=20from=201.26.0=20to=201.27.1=20(#11937)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 15 +++++++-------- packages/api/package.json | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/api/package.json b/api/package.json index 9951b6f01a..7c3c1045ed 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", "@node-saml/passport-saml": "^5.1.0", "@smithy/node-http-handler": "^4.4.5", "axios": "^1.13.5", diff --git a/package-lock.json b/package-lock.json index 04f8251dd6..614cda5337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", "@node-saml/passport-saml": "^5.1.0", "@smithy/node-http-handler": "^4.4.5", "axios": "^1.13.5", @@ -118,7 +118,7 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", - "pdfjs-dist": "^5.4.530", + "pdfjs-dist": "^5.4.624", "rate-limit-redis": "^4.2.0", "sharp": "^0.33.5", "tiktoken": "^1.0.15", @@ -11326,9 +11326,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -42612,7 +42612,7 @@ "@langchain/core": "^0.3.80", "@librechat/agents": "^3.1.51", "@librechat/data-schemas": "*", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", "axios": "^1.13.5", "connect-redis": "^8.1.0", @@ -42634,11 +42634,10 @@ "memorystore": "^1.6.7", "mongoose": "^8.12.1", "node-fetch": "2.7.0", - "pdfjs-dist": "^5.4.530", + "pdfjs-dist": "^5.4.624", "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", "undici": "^7.18.2", - "xlsx": "*", "zod": "^3.22.4" } }, diff --git a/packages/api/package.json b/packages/api/package.json index 6df880e0bf..8e55d8d901 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -92,7 +92,7 @@ "@langchain/core": "^0.3.80", "@librechat/agents": "^3.1.51", "@librechat/data-schemas": "*", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", "axios": "^1.13.5", "connect-redis": "^8.1.0", From 59bd27b4f49652c3a0c43d4c8eff045315597bb4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 24 Feb 2026 21:30:28 -0500 Subject: [PATCH 020/110] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20chore:=20Bump?= =?UTF-8?q?=20ESLint=20Tooling=20Deps=20and=20Resolve=20`ajv`=20Security?= =?UTF-8?q?=20Vulnerability=20(#11938)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore: Update `@eslint/eslintrc` and related dependencies in `package-lock.json` and `package.json` to latest versions for improved stability and performance * 🔧 chore: Update `postcss-preset-env` to version 11.2.0 in `package-lock.json` and `client/package.json`, and add `eslint` dependency in `package.json` for improved linting support --- client/package.json | 2 +- package-lock.json | 2315 ++++++++++++++++++++++++++++++++----------- package.json | 7 +- 3 files changed, 1718 insertions(+), 606 deletions(-) diff --git a/client/package.json b/client/package.json index fcc4c99e00..613a41ac3f 100644 --- a/client/package.json +++ b/client/package.json @@ -148,7 +148,7 @@ "jest-file-loader": "^1.0.3", "jest-junit": "^16.0.0", "postcss": "^8.4.31", - "postcss-preset-env": "^8.2.0", + "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^6.4.1", diff --git a/package-lock.json b/package-lock.json index 614cda5337..3a875f9fb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "devDependencies": { "@axe-core/playwright": "^4.10.1", "@eslint/compat": "^1.2.6", - "@eslint/eslintrc": "^3.3.1", + "@eslint/eslintrc": "^3.3.4", "@eslint/js": "^9.20.0", "@playwright/test": "^1.56.1", "@types/react-virtualized": "^9.22.0", @@ -529,7 +529,7 @@ "jest-file-loader": "^1.0.3", "jest-junit": "^16.0.0", "postcss": "^8.4.31", - "postcss-preset-env": "^8.2.0", + "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", "vite": "^6.4.1", @@ -1749,44 +1749,6 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, - "client/node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "client/node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", @@ -6765,9 +6727,9 @@ } }, "node_modules/@csstools/cascade-layer-name-parser": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.7.tgz", - "integrity": "sha512-9J4aMRJ7A2WRjaRLvsMeWrL69FmEuijtiW1XlK/sG+V0UJiHVYUyvj9mY4WAXfU/hGIiGOgL8e0jJcRyaZTjDQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-3.0.0.tgz", + "integrity": "sha512-/3iksyevwRfSJx5yH0RkcrcYXwuhMQx3Juqf40t97PeEy2/Mz2TItZ/z/216qpe4GgOyFBP8MKIwVvytzHmfIQ==", "dev": true, "funding": [ { @@ -6779,18 +6741,19 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/color-helpers": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-4.0.0.tgz", - "integrity": "sha512-wjyXB22/h2OvxAr3jldPB7R7kjTUEzopvjitS8jWtyd8fN6xJ8vy1HnHu0ZNfEkqpBJgQ76Q+sBDshWcMvTa/w==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -6802,14 +6765,15 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-1.1.6.tgz", - "integrity": "sha512-YHPAuFg5iA4qZGzMzvrQwzkvJpesXXyIUyaONflQrjtHB+BcFFbgltJkIkb31dMGO4SE9iZFA4HYpdk7+hnYew==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -6821,18 +6785,19 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.5.1.tgz", - "integrity": "sha512-x+SajGB2paGrTjPOUorGi8iCztF008YMKXTn+XzGVDBEIVJ/W1121pPerpneJYGOe1m6zWLPLnzOPaznmQxKFw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -6844,22 +6809,23 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^4.0.0", - "@csstools/css-calc": "^1.1.6" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.5.0.tgz", - "integrity": "sha512-abypo6m9re3clXA00eu5syw+oaPHbJTPapu9C4pzNsJ4hdZDzushT50Zhu+iIYXgEe1CxnRMn7ngsbV+MLrlpQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -6871,17 +6837,18 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.3.tgz", - "integrity": "sha512-pp//EvZ9dUmGuGtG1p+n17gTHEOqu9jO+FiCUjNN3BDmyhdA2Jq9QsVeR7K8/2QCK17HSsioPlTW9ZkzoWb3Lg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -6893,14 +6860,15 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.7.tgz", - "integrity": "sha512-lHPKJDkPUECsyAvD60joYfDmp8UERYxHGkFfyLJFTVK/ERJe0sVlIFLXU5XFxdjNDTerp5L4KeaKG+Z5S94qxQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-5.0.0.tgz", + "integrity": "sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==", "dev": true, "funding": [ { @@ -6912,38 +6880,49 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@csstools/postcss-cascade-layers": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-3.0.1.tgz", - "integrity": "sha512-dD8W98dOYNOH/yX4V4HXOhfCOnvVAg8TtsL+qCGNoKXuq5z2C/d026wGWgySgC8cajXXo/wNezS31Glj5GcqrA==", + "node_modules/@csstools/postcss-alpha-function": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-alpha-function/-/postcss-alpha-function-2.0.3.tgz", + "integrity": "sha512-8GqzD3JnfpKJSVxPIC0KadyAfB5VRzPZdv7XQ4zvK1q0ku+uHVUAS2N/IDavQkW40gkuUci64O0ea6QB/zgCSw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^2.0.2", - "postcss-selector-parser": "^6.0.10" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, - "node_modules/@csstools/postcss-color-function": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-2.2.3.tgz", - "integrity": "sha512-b1ptNkr1UWP96EEHqKBWWaV5m/0hgYGctgA/RVZhONeP1L3T/8hwoqDm9bB23yVCfOgE9U93KI9j06+pEkJTvw==", + "node_modules/@csstools/postcss-cascade-layers": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-6.0.0.tgz", + "integrity": "sha512-WhsECqmrEZQGqaPlBA7JkmF/CJ2/+wetL4fkL9sOPccKd32PQ1qToFM6gqSI5rkpmYqubvbxjEJhyMTHYK0vZQ==", "dev": true, "funding": [ { @@ -6955,23 +6934,119 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.2.0", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/postcss-progressive-custom-properties": "^2.3.0" + "@csstools/selector-specificity": "^6.0.0", + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-5.0.2.tgz", + "integrity": "sha512-CjBdFemUFcAh3087MEJhZcO+QT1b8S75agysa1rU9TEC1YecznzwV+jpMxUc0JRBEV4ET2PjLssqmndR9IygeA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-function-display-p3-linear": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function-display-p3-linear/-/postcss-color-function-display-p3-linear-2.0.2.tgz", + "integrity": "sha512-TWUwSe1+2KdYGGWTx5LR4JQN07vKHAeSho+bGYRgow+9cs3dqgOqS1f/a1odiX30ESmZvwIudJ86wzeiDR6UGg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-color-mix-function": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-1.0.3.tgz", - "integrity": "sha512-QGXjGugTluqFZWzVf+S3wCiRiI0ukXlYqCi7OnpDotP/zaVTyl/aqZujLFzTOXy24BoWnu89frGMc79ohY5eog==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-4.0.2.tgz", + "integrity": "sha512-PFKQKswFqZrYKpajZsP4lhqjU/6+J5PTOWq1rKiFnniKsf4LgpGXrgHS/C6nn5Rc51LX0n4dWOWqY5ZN2i5IjA==", "dev": true, "funding": [ { @@ -6983,42 +7058,223 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.2.0", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/postcss-progressive-custom-properties": "^2.3.0" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-variadic-function-arguments": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-variadic-function-arguments/-/postcss-color-mix-variadic-function-arguments-2.0.2.tgz", + "integrity": "sha512-zEchsghpDH/6SytyjKu9TIPm4hiiWcur102cENl54cyIwTZsa+2MBJl/vtyALZ+uQ17h27L4waD+0Ow96sgZow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-content-alt-text": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-3.0.0.tgz", + "integrity": "sha512-OHa+4aCcrJtHpPWB3zptScHwpS1TUbeLR4uO0ntIz0Su/zw9SoWkVu+tDMSySSAsNtNSI3kut4fTliFwIsrHxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-contrast-color-function": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-contrast-color-function/-/postcss-contrast-color-function-3.0.2.tgz", + "integrity": "sha512-fwOz/m+ytFPz4aIph2foQS9nEDOdOjYcN5bgwbGR2jGUV8mYaeD/EaTVMHTRb/zqB65y2qNwmcFcE6VQty69Pw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-exponential-functions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-3.0.1.tgz", + "integrity": "sha512-WHJ52Uk0AVUIICEYRY9xFHJZAuq0ZVg0f8xzqUN2zRFrZvGgRPpFwxK7h9FWvqKIOueOwN6hnJD23A8FwsUiVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-font-format-keywords": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-2.0.2.tgz", - "integrity": "sha512-iKYZlIs6JsNT7NKyRjyIyezTCHLh4L4BBB3F5Nx7Dc4Z/QmBgX+YJFuUSar8IM6KclGiAUFGomXFdYxAwJydlA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-5.0.0.tgz", + "integrity": "sha512-M1EjCe/J3u8fFhOZgRci74cQhJ7R0UFBX6T+WqoEvjrr8hVfMiV+HTYrzxLY5OW8YllvXYr5Q5t5OvJbsUSeDg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-font-width-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-width-property/-/postcss-font-width-property-1.0.0.tgz", + "integrity": "sha512-AvmySApdijbjYQuXXh95tb7iVnqZBbJrv3oajO927ksE/mDmJBiszm+psW8orL2lRGR8j6ZU5Uv9/ou2Z5KRKA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-gamut-mapping": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-3.0.2.tgz", + "integrity": "sha512-IrXAW3KQ3Sxm29C3/4mYQ/iA0Q5OH9YFOPQ2w24iIlXpD06A9MHvmQapP2vAGtQI3tlp2Xw5LIdm9F8khARfOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-gradients-interpolation-method": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-3.0.6.tgz", - "integrity": "sha512-rBOBTat/YMmB0G8VHwKqDEx+RZ4KCU9j42K8LwS0IpZnyThalZZF7BCSsZ6TFlZhcRZKlZy3LLFI2pLqjNVGGA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-6.0.2.tgz", + "integrity": "sha512-saQHvD1PD/zCdn+kxCWCcQOdXZBljr8L6BKlCLs0w8GXYfo3SHdWL1HZQ+I1hVCPlU+MJPJJbZJjG/jHRJSlAw==", "dev": true, "funding": [ { @@ -7030,23 +7286,25 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.2.0", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/postcss-progressive-custom-properties": "^2.3.0" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-hwb-function": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-2.2.2.tgz", - "integrity": "sha512-W5Y5oaJ382HSlbdGfPf60d7dAK6Hqf10+Be1yZbd/TNNrQ/3dDdV1c07YwOXPQ3PZ6dvFMhxbIbn8EC3ki3nEg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-5.0.2.tgz", + "integrity": "sha512-ChR0+pKc/2cs900jakiv8dLrb69aez5P3T+g+wfJx1j6mreAe8orKTiMrVBk+DZvCRqpdOA2m8VoFms64A3Dew==", "dev": true, "funding": [ { @@ -7058,22 +7316,25 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.2.0", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-ic-unit": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-2.0.4.tgz", - "integrity": "sha512-9W2ZbV7whWnr1Gt4qYgxMWzbevZMOvclUczT5vk4yR6vS53W/njiiUhtm/jh/BKYwQ1W3PECZjgAd2dH4ebJig==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-5.0.0.tgz", + "integrity": "sha512-/ws5d6c4uKqfM9zIL3ugcGI+3fvZEOOkJHNzAyTAGJIdZ+aSL9BVPNlHGV4QzmL0vqBSCOdU3+rhcMEj3+KzYw==", "dev": true, "funding": [ { @@ -7085,21 +7346,46 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^2.3.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-initial": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-initial/-/postcss-initial-3.0.0.tgz", + "integrity": "sha512-UVUrFmrTQyLomVepnjWlbBg7GoscLmXLwYFyjbcEnmpeGW7wde6lNpx5eM3eVwZI2M+7hCE3ykYnAsEPLcLa+Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-is-pseudo-class": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-3.2.1.tgz", - "integrity": "sha512-AtANdV34kJl04Al62is3eQRk/BfOfyAvEmRJvbt+nx5REqImLC+2XhuE6skgkcPli1l8ONS67wS+l1sBzySc3Q==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-6.0.0.tgz", + "integrity": "sha512-1Hdy/ykg9RDo8vU8RiM2o+RaXO39WpFPaIkHxlAEJFofle/lc33tdQMKhBk3jR/Fe+uZNLOs3HlowFafyFptVw==", "dev": true, "funding": [ { @@ -7111,75 +7397,210 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^2.0.0", - "postcss-selector-parser": "^6.0.10" + "@csstools/selector-specificity": "^6.0.0", + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-light-dark-function": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-3.0.0.tgz", + "integrity": "sha512-s++V5/hYazeRUCYIn2lsBVzUsxdeC46gtwpgW6lu5U/GlPOS5UTDT14kkEyPgXmFbCvaWLREqV7YTMJq1K3G6w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-logical-float-and-clear": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-1.0.1.tgz", - "integrity": "sha512-eO9z2sMLddvlfFEW5Fxbjyd03zaO7cJafDurK4rCqyRt9P7aaWwha0LcSzoROlcZrw1NBV2JAp2vMKfPMQO1xw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-4.0.0.tgz", + "integrity": "sha512-NGzdIRVj/VxOa/TjVdkHeyiJoDihONV0+uB0csUdgWbFFr8xndtfqK8iIGP9IKJzco+w0hvBF2SSk2sDSTAnOQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-overflow": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-3.0.0.tgz", + "integrity": "sha512-5cRg93QXVskM0MNepHpPcL0WLSf5Hncky0DrFDQY/4ozbH5lH7SX5ejayVpNTGSX7IpOvu7ykQDLOdMMGYzwpA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-overscroll-behavior": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-3.0.0.tgz", + "integrity": "sha512-82Jnl/5Wi5jb19nQE1XlBHrZcNL3PzOgcj268cDkfwf+xi10HBqufGo1Unwf5n8bbbEFhEKgyQW+vFsc9iY1jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-logical-resize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-1.0.1.tgz", - "integrity": "sha512-x1ge74eCSvpBkDDWppl+7FuD2dL68WP+wwP2qvdUcKY17vJksz+XoE1ZRV38uJgS6FNUwC0AxrPW5gy3MxsDHQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-4.0.0.tgz", + "integrity": "sha512-L0T3q0gei/tGetCGZU0c7VN77VTivRpz1YZRNxjXYmW+85PKeI6U9YnSvDqLU2vBT2uN4kLEzfgZ0ThIZpN18A==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-logical-viewport-units": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-1.0.3.tgz", - "integrity": "sha512-6zqcyRg9HSqIHIPMYdt6THWhRmE5/tyHKJQLysn2TeDf/ftq7Em9qwMTx98t2C/7UxIsYS8lOiHHxAVjWn2WUg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-4.0.0.tgz", + "integrity": "sha512-TA3AqVN/1IH3dKRC2UUWvprvwyOs2IeD7FDZk5Hz20w4q33yIuSg0i0gjyTUkcn90g8A4n7QpyZ2AgBrnYPnnA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@csstools/css-tokenizer": "^2.1.1" + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-media-minmax": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-1.1.2.tgz", - "integrity": "sha512-7qTRTJxW96u2yiEaTep1+8nto1O/rEDacewKqH+Riq5E6EsHTOmGHxkB4Se5Ic5xgDC4I05lLZxzzxnlnSypxA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-3.0.1.tgz", + "integrity": "sha512-I+CrmZt23fyejMItpLQFOg9gPXkDBBDjTqRT0UxCTZlYZfGrzZn4z+2kbXLRwDfR59OK8zaf26M4kwYwG0e1MA==", "dev": true, "funding": [ { @@ -7191,23 +7612,24 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/css-calc": "^1.1.6", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", - "@csstools/media-query-list-parser": "^2.1.7" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-1.0.4.tgz", - "integrity": "sha512-IwyTbyR8E2y3kh6Fhrs251KjKBJeUPV5GlnUKnpU70PRFEN2DolWbf2V4+o/B9+Oj77P/DullLTulWEQ8uFtAA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-4.0.0.tgz", + "integrity": "sha512-FDdC3lbrj8Vr0SkGIcSLTcRB7ApG6nlJFxOxkEF2C5hIZC1jtgjISFSGn/WjFdVkn8Dqe+Vx9QXI3axS2w1XHw==", "dev": true, "funding": [ { @@ -7219,60 +7641,103 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-parser-algorithms": "^2.2.0", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/media-query-list-parser": "^2.1.1" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-mixins": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-mixins/-/postcss-mixins-1.0.0.tgz", + "integrity": "sha512-rz6qjT2w9L3k65jGc2dX+3oGiSrYQ70EZPDrINSmSVoVys7lLBFH0tvEa8DW2sr9cbRVD/W+1sy8+7bfu0JUfg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-nested-calc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-2.0.2.tgz", - "integrity": "sha512-jbwrP8rN4e7LNaRcpx3xpMUjhtt34I9OV+zgbcsYAAk6k1+3kODXJBf95/JMYWhu9g1oif7r06QVUgfWsKxCFw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-5.0.0.tgz", + "integrity": "sha512-aPSw8P60e/i9BEfugauhikBqgjiwXcw3I9o4vXs+hktl4NSTgZRI0QHimxk9mst8N01A2TKDBxOln3mssRxiHQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-normalize-display-values": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-2.0.1.tgz", - "integrity": "sha512-TQT5g3JQ5gPXC239YuRK8jFceXF9d25ZvBkyjzBGGoW5st5sPXFVQS8OjYb9IJ/K3CdfK4528y483cgS2DJR/w==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-5.0.1.tgz", + "integrity": "sha512-FcbEmoxDEGYvm2W3rQzVzcuo66+dDJjzzVDs+QwRmZLHYofGmMGwIKPqzF86/YW+euMDa7sh1xjWDvz/fzByZQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-oklab-function": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-2.2.3.tgz", - "integrity": "sha512-AgJ2rWMnLCDcbSMTHSqBYn66DNLBym6JpBpCaqmwZ9huGdljjDRuH3DzOYzkgQ7Pm2K92IYIq54IvFHloUOdvA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-5.0.2.tgz", + "integrity": "sha512-3d/Wcnp2uW6Io0Tajl0croeUo46gwOVQI9N32PjA/HVQo6z1iL7yp19Gp+6e5E5CDKGpW7U822MsDVo2XK1z0Q==", "dev": true, "funding": [ { @@ -7284,23 +7749,48 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.2.0", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/postcss-progressive-custom-properties": "^2.3.0" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-position-area-property": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-position-area-property/-/postcss-position-area-property-2.0.0.tgz", + "integrity": "sha512-TeEfzsJGB23Syv7yCm8AHCD2XTFujdjr9YYu9ebH64vnfCEvY4BG319jXAYSlNlf3Yc9PNJ6WnkDkUF5XVgSKQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-progressive-custom-properties": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-2.3.0.tgz", - "integrity": "sha512-Zd8ojyMlsL919TBExQ1I0CTpBDdyCpH/yOdqatZpuC3sd22K4SwC7+Yez3Q/vmXMWSAl+shjNeFZ7JMyxMjK+Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-5.0.0.tgz", + "integrity": "sha512-NsJoZ89rxmDrUsITf8QIk5w+lQZQ8Xw5K6cLFG+cfiffsLYHb3zcbOOrHLetGl1WIhjWWQ4Cr8MMrg46Q+oACg==", "dev": true, "funding": [ { @@ -7312,20 +7802,76 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-property-rule-prelude-list": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-property-rule-prelude-list/-/postcss-property-rule-prelude-list-2.0.0.tgz", + "integrity": "sha512-qcMAkc9AhpzHgmQCD8hoJgGYifcOAxd1exXjjxilMM6euwRE619xDa4UsKBCv/v4g+sS63sd6c29LPM8s2ylSQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-random-function": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-random-function/-/postcss-random-function-3.0.1.tgz", + "integrity": "sha512-SvKGfmj+WHfn4bWHaBYlkXDyU3SlA3fL8aaYZ8Op6M8tunNf3iV9uZyZZGWMCbDw0sGeoTmYZW9nmKN8Qi/ctg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-relative-color-syntax": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-1.0.2.tgz", - "integrity": "sha512-juCoVInkgH2TZPfOhyx6tIal7jW37L/0Tt+Vcl1LoxqQA9sxcg3JWYZ98pl1BonDnki6s/M7nXzFQHWsWMeHgw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-4.0.2.tgz", + "integrity": "sha512-HaMN+qMURinllszbps2AhXKaLeibg/2VW6FriYDrqE58ji82+z2S3/eLloywVOY8BQCJ9lZMdy6TcRQNbn9u3w==", "dev": true, "funding": [ { @@ -7337,63 +7883,174 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.2.0", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/postcss-progressive-custom-properties": "^2.3.0" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-scope-pseudo-class": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-2.0.2.tgz", - "integrity": "sha512-6Pvo4uexUCXt+Hz5iUtemQAcIuCYnL+ePs1khFR6/xPgC92aQLJ0zGHonWoewiBE+I++4gXK3pr+R1rlOFHe5w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-5.0.0.tgz", + "integrity": "sha512-kBrBFJcAji3MSHS4qQIihPvJfJC5xCabXLbejqDMiQi+86HD4eMBiTayAo46Urg7tlEmZZQFymFiJt+GH6nvXw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-sign-functions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-2.0.1.tgz", + "integrity": "sha512-C3br0qcHJkQ0qSGUBnDJHXQdO8XObnCpGwai5m1L2tv2nCjt0vRHG6A9aVCQHvh08OqHNM2ty1dYDNNXV99YAQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-stepped-value-functions": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-2.1.1.tgz", - "integrity": "sha512-YCvdF0GCZK35nhLgs7ippcxDlRVe5QsSht3+EghqTjnYnyl3BbWIN6fYQ1dKWYTJ+7Bgi41TgqQFfJDcp9Xy/w==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-5.0.1.tgz", + "integrity": "sha512-vZf7zPzRb7xIi2o5Z9q6wyeEAjoRCg74O2QvYxmQgxYO5V5cdBv4phgJDyOAOP3JHy4abQlm2YaEUS3gtGQo0g==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^1.1.1", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-syntax-descriptor-syntax-production": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-syntax-descriptor-syntax-production/-/postcss-syntax-descriptor-syntax-production-2.0.0.tgz", + "integrity": "sha512-elYcbdiBXAkPqvojB9kIBRuHY6htUhjSITtFQ+XiXnt6SvZCbNGxQmaaw6uZ7SPHu/+i/XVjzIt09/1k3SIerQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-system-ui-font-family": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-system-ui-font-family/-/postcss-system-ui-font-family-2.0.0.tgz", + "integrity": "sha512-FyGZCgchFImFyiHS2x3rD5trAqatf/x23veBLTIgbaqyFfna6RNBD+Qf8HRSjt6HGMXOLhAjxJ3OoZg0bbn7Qw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-text-decoration-shorthand": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-2.2.4.tgz", - "integrity": "sha512-zPN56sQkS/7YTCVZhOBVCWf7AiNge8fXDl7JVaHLz2RyT4pnyK2gFjckWRLpO0A2xkm1lCgZ0bepYZTwAVd/5A==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-5.0.3.tgz", + "integrity": "sha512-62fjggvIM1YYfDJPcErMUDkEZB6CByG8neTJqexnZe1hRBgCjD4dnXDLoCSSurjs1LzjBq6irFDpDaOvDZfrlw==", "dev": true, "funding": [ { @@ -7405,21 +8062,22 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/color-helpers": "^2.1.0", + "@csstools/color-helpers": "^6.0.2", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, - "node_modules/@csstools/postcss-text-decoration-shorthand/node_modules/@csstools/color-helpers": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-2.1.0.tgz", - "integrity": "sha512-OWkqBa7PDzZuJ3Ha7T5bxdSVfSCfTq6K1mbAhbO1MD+GSULGjrp45i5RudyJOedstSarN/3mdwu9upJE7gDXfw==", + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-5.0.1.tgz", + "integrity": "sha512-e8me32Mhl8JeBnxVJgsQUYpV4Md4KiyvpILpQlaY/eK1Gwdb04kasiTTswPQ5q7Z8+FppJZ2Z4d8HRfn6rjD3w==", "dev": true, "funding": [ { @@ -7431,61 +8089,63 @@ "url": "https://opencollective.com/csstools" } ], - "engines": { - "node": "^14 || ^16 || >=18" - } - }, - "node_modules/@csstools/postcss-trigonometric-functions": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-2.1.1.tgz", - "integrity": "sha512-XcXmHEFfHXhvYz40FtDlA4Fp4NQln2bWTsCwthd2c+MCnYArUYU3YaMqzR5CrKP3pMoGYTBnp5fMqf1HxItNyw==", - "dev": true, + "license": "MIT-0", "dependencies": { - "@csstools/css-calc": "^1.1.1", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/@csstools/postcss-unset-value": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-2.0.1.tgz", - "integrity": "sha512-oJ9Xl29/yU8U7/pnMJRqAZd4YXNCfGEdcP4ywREuqm/xMqcgDNDppYRoCGDt40aaZQIEKBS79LytUDN/DHf0Ew==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-5.0.0.tgz", + "integrity": "sha512-EoO54sS2KCIfesvHyFYAW99RtzwHdgaJzhl7cqKZSaMYKZv3fXSOehDjAQx8WZBKn1JrMd7xJJI1T1BxPF7/jA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, - "node_modules/@csstools/selector-specificity": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz", - "integrity": "sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==", + "node_modules/@csstools/utilities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/utilities/-/utilities-3.0.0.tgz", + "integrity": "sha512-etDqA/4jYvOGBM6yfKCOsEXfH96BKztZdgGmGqKi2xHnDe0ILIBraRspwgYatJH9JsCZ5HCGoCst8w18EKOAdg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss": "^8.4" } }, "node_modules/@dabh/diagnostics": { @@ -8461,20 +9121,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" }, "engines": { @@ -8485,9 +9145,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -21488,9 +22148,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "dev": true, "funding": [ { @@ -21506,12 +22166,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -21524,6 +22184,71 @@ "postcss": "^8.1.0" } }, + "node_modules/autoprefixer/node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/autoprefixer/node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -21825,12 +22550,15 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.28", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", - "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bcryptjs": { @@ -22360,9 +23088,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001755", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001755.tgz", - "integrity": "sha512-44V+Jm6ctPj7R52Na4TLi3Zri4dWUljJd+RDm+j8LtNCc/ihLCT+X1TzoOAkRETEWqjuLnh9581Tl80FvK7jVA==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "funding": [ { "type": "opencollective", @@ -23269,24 +23997,45 @@ } }, "node_modules/css-blank-pseudo": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-5.0.2.tgz", - "integrity": "sha512-aCU4AZ7uEcVSUzagTlA9pHciz7aWPKA/YzrEkpdSopJ2pvhIxiQ5sYeMz1/KByxlIo4XBdvMNJAVKMg/GRnhfw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-8.0.1.tgz", + "integrity": "sha512-C5B2e5hCM4llrQkUms+KnWEMVW8K1n2XvX9G7ppfMZJQ7KAS/4rNnkP1Cs+HhWriOz1mWWTMFD4j1J7s31Dgug==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, + "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -23301,37 +24050,88 @@ } }, "node_modules/css-has-pseudo": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-5.0.2.tgz", - "integrity": "sha512-q+U+4QdwwB7T9VEW/LyO6CFrLAeLqOykC5mDqJXc7aKZAhDbq7BvGT13VGJe+IwBfdN2o3Xdw2kJ5IxwV1Sc9Q==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-8.0.0.tgz", + "integrity": "sha512-Uz/bsHRbOeir/5Oeuz85tq/yLJLxX+3dpoRdjNTshs6jjqwUg8XaEZGDd0ci3fw7l53Srw0EkJ8mYan0eW5uGQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^2.0.1", - "postcss-selector-parser": "^6.0.10", + "@csstools/selector-specificity": "^6.0.0", + "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, - "node_modules/css-prefers-color-scheme": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-8.0.2.tgz", - "integrity": "sha512-OvFghizHJ45x7nsJJUSYLyQNTzsCU8yWjxAc/nhPQg1pbs18LMoET8N3kOweFDPy0JV0OSXN2iqRFhPBHYOeMA==", + "node_modules/css-has-pseudo/node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-11.0.0.tgz", + "integrity": "sha512-fv0mgtwUhh2m9iio3Kxc2CkrogjIaRdMFaaqyzSFdii17JF4cfPyMNX72B15ZW2Nrr/NZUpxI4dec1VMHYJvdw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" @@ -23386,9 +24186,9 @@ "dev": true }, "node_modules/cssdb": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.10.0.tgz", - "integrity": "sha512-yGZ5tmA57gWh/uvdQBHs45wwFY0IBh3ypABk5sEubPBPSzXzkNgsWReqx7gdx6uhC+QoFBe+V8JwBB9/hQ6cIA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.8.0.tgz", + "integrity": "sha512-QbLeyz2Bgso1iRlh7IpWk6OKa3lLNGXsujVjDMPl9rOZpxKeiG69icLpbLCFxeURwmcdIfZqQyhlooKJYM4f8Q==", "dev": true, "funding": [ { @@ -23399,7 +24199,8 @@ "type": "github", "url": "https://github.com/sponsors/csstools" } - ] + ], + "license": "MIT-0" }, "node_modules/cssesc": { "version": "3.0.0", @@ -24696,9 +25497,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.254", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", - "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "license": "ISC" }, "node_modules/elliptic": { @@ -25599,9 +26400,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -26670,15 +27471,15 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -31555,19 +32356,6 @@ "node": ">= 18" } }, - "node_modules/mathjs/node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -33005,10 +33793,11 @@ "license": "MIT" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -33597,15 +34386,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -34690,24 +35470,45 @@ } }, "node_modules/postcss-attribute-case-insensitive": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-6.0.2.tgz", - "integrity": "sha512-IRuCwwAAQbgaLhxQdQcIIK0dCVXg3XDUnzgKD8iwdiYdwU4rMWRWyl/W9/0nA4ihVpq5pyALiHB2veBJ0292pw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-8.0.0.tgz", + "integrity": "sha512-fovIPEV35c2JzVXdmP+sp2xirbBMt54J+upU8u6TSj410kUU5+axgEzvBBSAX8KCybze8CFCelzFAw/FfWg2TA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, + "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-calc": { "version": "8.2.4", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", @@ -34738,9 +35539,9 @@ } }, "node_modules/postcss-color-functional-notation": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-5.1.0.tgz", - "integrity": "sha512-w2R4py6zrVE1U7FwNaAc76tNQlG9GLkrBbcFw+VhUjyDDiV28vfZG+l4LyPmpoQpeSJVtu8VgNjE8Jv5SpC7dQ==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-8.0.2.tgz", + "integrity": "sha512-tbmkk6teYpJzFcGwPIhN1gkvxqGHvNx2PMb8Y3S5Ktyn7xOlvD98XzQ99MFY5mAyvXWclDG+BgoJKYJXFJOp5Q==", "dev": true, "funding": [ { @@ -34752,21 +35553,25 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^2.3.0", - "postcss-value-parser": "^4.2.0" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-color-hex-alpha": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-9.0.3.tgz", - "integrity": "sha512-7sEHU4tAS6htlxun8AB9LDrCXoljxaC34tFVRlYKcvO+18r5fvGiXgv5bQzN40+4gXLCyWSMRK5FK31244WcCA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-11.0.0.tgz", + "integrity": "sha512-NCGa6vjIyrjosz9GqRxVKbONBklz5TeipYqTJp3IqbnBWlBq5e5EMtG6MaX4vqk9LzocPfMQkuRK9tfk+OQuKg==", "dev": true, "funding": [ { @@ -34778,30 +35583,40 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-color-rebeccapurple": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-8.0.2.tgz", - "integrity": "sha512-xWf/JmAxVoB5bltHpXk+uGRoGFwu4WDAR7210el+iyvTdqiKpDhtcT8N3edXMoVJY0WHFMrKMUieql/wRNiXkw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-11.0.0.tgz", + "integrity": "sha512-g9561mx7cbdqx7XeO/L+lJzVlzu7bICyXr72efBVKZGxIhvBBJf9fGXn3Cb6U4Bwh3LbzQO2e9NWBLVYdX5Eag==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" @@ -34844,9 +35659,9 @@ } }, "node_modules/postcss-custom-media": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-9.1.5.tgz", - "integrity": "sha512-GStyWMz7Qbo/Gtw1xVspzVSX8eipgNg4lpsO3CAeY4/A1mzok+RV6MCv3fg62trWijh/lYEj6vps4o8JcBBpDA==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-12.0.1.tgz", + "integrity": "sha512-66syE14+VeqkUf0rRX0bvbTCbNRJF132jD+ceo8th1dap2YJEAqpdh5uG98CE3IbgHT7m9XM0GIlOazNWqQdeA==", "dev": true, "funding": [ { @@ -34858,23 +35673,24 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.2", - "@csstools/css-parser-algorithms": "^2.2.0", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/media-query-list-parser": "^2.1.1" + "@csstools/cascade-layer-name-parser": "^3.0.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/media-query-list-parser": "^5.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-custom-properties": { - "version": "13.3.4", - "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-13.3.4.tgz", - "integrity": "sha512-9YN0gg9sG3OH+Z9xBrp2PWRb+O4msw+5Sbp3ZgqrblrwKspXVQe5zr5sVqi43gJGwW/Rv1A483PRQUzQOEewvA==", + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-15.0.1.tgz", + "integrity": "sha512-cuyq8sd8dLY0GLbelz1KB8IMIoDECo6RVXMeHeXY2Uw3Q05k/d1GVITdaKLsheqrHbnxlwxzSRZQQ5u+rNtbMg==", "dev": true, "funding": [ { @@ -34886,23 +35702,25 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.7", - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3", + "@csstools/cascade-layer-name-parser": "^3.0.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-custom-selectors": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-7.1.6.tgz", - "integrity": "sha512-svsjWRaxqL3vAzv71dV0/65P24/FB8TbPX+lWyyf9SZ7aZm4S4NhCn7N3Bg+Z5sZunG3FS8xQ80LrCU9hb37cw==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-9.0.1.tgz", + "integrity": "sha512-2XBELy4DmdVKimChfaZ2id9u9CSGYQhiJ53SvlfBvMTzLMW2VxuMb9rHsMSQw9kRq/zSbhT5x13EaK8JSmK8KQ==", "dev": true, "funding": [ { @@ -34914,38 +35732,74 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.5", - "@csstools/css-parser-algorithms": "^2.3.2", - "@csstools/css-tokenizer": "^2.2.1", - "postcss-selector-parser": "^6.0.13" + "@csstools/cascade-layer-name-parser": "^3.0.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, - "node_modules/postcss-dir-pseudo-class": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-7.0.2.tgz", - "integrity": "sha512-cMnslilYxBf9k3qejnovrUONZx1rXeUZJw06fgIUBzABJe3D2LiLL5WAER7Imt3nrkaIgG05XZBztueLEf5P8w==", + "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=4" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-10.0.0.tgz", + "integrity": "sha512-DmtIzULpyC8XaH4b5AaUgt4Jic4QmrECqidNCdR7u7naQFdnxX80YI06u238a+ZVRXwURDxVzy0s/UQnWmpVeg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.1.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, + "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-discard-comments": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", @@ -34999,9 +35853,9 @@ } }, "node_modules/postcss-double-position-gradients": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-4.0.4.tgz", - "integrity": "sha512-nUAbUXURemLXIrl4Xoia2tiu5z/n8sY+BVDZApoeT9BlpByyrp02P/lFCRrRvZ/zrGRE+MOGLhk8o7VcMCtPtQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-7.0.0.tgz", + "integrity": "sha512-Msr/dxj8Os7KLJE5Hdhvprwm3K5Zrh1KTY0eFN3ngPKNkej/Usy4BM9JQmqE6CLAkDpHoQVsi4snbL72CPt6qg==", "dev": true, "funding": [ { @@ -35013,55 +35867,99 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/postcss-progressive-custom-properties": "^2.3.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-focus-visible": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-8.0.2.tgz", - "integrity": "sha512-f/Vd+EC/GaKElknU59esVcRYr/Y3t1ZAQyL4u2xSOgkDy4bMCmG7VP5cGvj3+BTLNE9ETfEuz2nnt4qkZwTTeA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-11.0.0.tgz", + "integrity": "sha512-VG1a9kBKizUBWS66t5xyB4uLONBnvZLCmZXxT40FALu8EF0QgVZBYy5ApC0KhmpHsv+pvHMJHB3agKHwmocWjw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, - "node_modules/postcss-focus-within": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-7.0.2.tgz", - "integrity": "sha512-AHAJ89UQBcqBvFgQJE9XasGuwMNkKsGj4D/f9Uk60jFmEBHpAL14DrnSk3Rj+SwZTr/WUG+mh+Rvf8fid/346w==", + "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=4" + } + }, + "node_modules/postcss-focus-within": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-10.0.0.tgz", + "integrity": "sha512-dvql0fzUTG+gcJYp+KTbag5vAjuo94LDYZHkqDV1rnf5gPGer1v/SrmIZBdvKU8moep3HbcbujqGjzSb3DL53Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.1.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, + "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-font-variant": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", @@ -35072,35 +35970,50 @@ } }, "node_modules/postcss-gap-properties": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-4.0.1.tgz", - "integrity": "sha512-V5OuQGw4lBumPlwHWk/PRfMKjaq/LTGR4WDTemIMCaMevArVfCCA9wBJiL1VjDAd+rzuCIlkRoRvDsSiAaZ4Fg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-7.0.0.tgz", + "integrity": "sha512-PSDF2QoZMRUbsINvXObQgxx4HExRP85QTT8qS/YN9fBsCPWCqUuwqAD6E6PNp0BqL/jU1eyWUBORaOK/J/9LDA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, "node_modules/postcss-image-set-function": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-5.0.2.tgz", - "integrity": "sha512-Sszjwo0ubETX0Fi5MvpYzsONwrsjeabjMoc5YqHvURFItXgIu3HdCjcVuVKGMPGzKRhgaknmdM5uVWInWPJmeg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-8.0.0.tgz", + "integrity": "sha512-rEGNkOkNusf4+IuMmfEoIdLuVmvbExGbmG+MIsyV6jR5UaWSoyPcAYHV/PxzVDCmudyF+2Nh/o6Ub2saqUdnuA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { + "@csstools/utilities": "^3.0.0", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" @@ -35122,15 +36035,6 @@ "postcss": "^8.0.0" } }, - "node_modules/postcss-initial": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", - "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", - "dev": true, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, "node_modules/postcss-js": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", @@ -35150,9 +36054,9 @@ } }, "node_modules/postcss-lab-function": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-5.2.3.tgz", - "integrity": "sha512-fi32AYKzji5/rvgxo5zXHFvAYBw0u0OzELbeCNjEZVLUir18Oj+9RmNphtM8QdLUaUnrfx8zy8vVYLmFLkdmrQ==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-8.0.2.tgz", + "integrity": "sha512-1ZIAh8ODhZdnAb09Aq2BTenePKS1G/kUR0FwvzkQDfFtSOV64Ycv27YvV11fDycEvhIcEmgYkLABXKRiWcXRuA==", "dev": true, "funding": [ { @@ -35164,14 +36068,16 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/css-color-parser": "^1.2.0", - "@csstools/css-parser-algorithms": "^2.1.1", - "@csstools/css-tokenizer": "^2.1.1", - "@csstools/postcss-progressive-custom-properties": "^2.3.0" + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/utilities": "^3.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" @@ -35220,9 +36126,9 @@ } }, "node_modules/postcss-logical": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-6.2.0.tgz", - "integrity": "sha512-aqlfKGaY0nnbgI9jwUikp4gJKBqcH5noU/EdnIVceghaaDPYhZuyJVxlvWNy55tlTG5tunRKCTAX9yljLiFgmw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-9.0.0.tgz", + "integrity": "sha512-A4LNd9dk3q/juEUA9Gd8ALhBO3TeOeYurnyHLlf2aAToD94VHR8c5Uv7KNmf8YVRhTxvWsyug4c5fKtARzyIRQ==", "dev": true, "funding": [ { @@ -35234,11 +36140,12 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" @@ -35478,9 +36385,9 @@ } }, "node_modules/postcss-nesting": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-11.3.0.tgz", - "integrity": "sha512-JlS10AQm/RzyrUGgl5irVkAlZYTJ99mNueUl+Qab+TcHhVedLiylWVkKBhRale+rS9yWIJK48JVzQlq3LcSdeA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-14.0.0.tgz", + "integrity": "sha512-YGFOfVrjxYfeGTS5XctP1WCI5hu8Lr9SmntjfRC+iX5hCihEO+QZl9Ra+pkjqkgoVdDKvb2JccpElcowhZtzpw==", "dev": true, "funding": [ { @@ -35492,17 +36399,79 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/selector-specificity": "^2.0.0", - "postcss-selector-parser": "^6.0.10" + "@csstools/selector-resolve-nested": "^4.0.0", + "@csstools/selector-specificity": "^6.0.0", + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", + "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz", + "integrity": "sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.1.1" + } + }, + "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-normalize-charset": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", @@ -35647,9 +36616,9 @@ } }, "node_modules/postcss-opacity-percentage": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-2.0.0.tgz", - "integrity": "sha512-lyDrCOtntq5Y1JZpBFzIWm2wG9kbEdujpNt4NLannF+J9c8CgFIzPa80YQfdza+Y+yFfzbYj/rfoOsYsooUWTQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-3.0.0.tgz", + "integrity": "sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==", "dev": true, "funding": [ { @@ -35661,11 +36630,12 @@ "url": "https://liberapay.com/mrcgrtz" } ], + "license": "MIT", "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=18" }, "peerDependencies": { - "postcss": "^8.2" + "postcss": "^8.4" } }, "node_modules/postcss-ordered-values": { @@ -35686,19 +36656,26 @@ } }, "node_modules/postcss-overflow-shorthand": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-4.0.1.tgz", - "integrity": "sha512-HQZ0qi/9iSYHW4w3ogNqVNr2J49DHJAl7r8O2p0Meip38jsdnRPgiDW7r/LlLrrMBMe3KHkvNtAV2UmRVxzLIg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-7.0.0.tgz", + "integrity": "sha512-9SLpjoUdGRoRrzoOdX66HbUs0+uDwfIAiXsRa7piKGOqPd6F4ZlON9oaDSP5r1Qpgmzw5L9Ht0undIK6igJPMA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" @@ -35714,28 +36691,9 @@ } }, "node_modules/postcss-place": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-8.0.1.tgz", - "integrity": "sha512-Ow2LedN8sL4pq8ubukO77phSVt4QyCm35ZGCYXKvRFayAwcpgB0sjNJglDoTuRdUL32q/ZC1VkPBo0AOEr4Uiw==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.2.0" - }, - "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - }, - "peerDependencies": { - "postcss": "^8.4" - } - }, - "node_modules/postcss-preset-env": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-8.5.1.tgz", - "integrity": "sha512-qhWnJJjP6ArLUINWJ38t6Aftxnv9NW6cXK0NuwcLCcRilbuw72dSFLkCVUJeCfHGgJiKzX+pnhkGiki0PEynWg==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-11.0.0.tgz", + "integrity": "sha512-fAifpyjQ+fuDRp2nmF95WbotqbpjdazebedahXdfBxy5sHembOLpBQ1cHveZD9ZmjK26tYM8tikeNaUlp/KfHA==", "dev": true, "funding": [ { @@ -35747,90 +36705,220 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "dependencies": { - "@csstools/postcss-cascade-layers": "^3.0.1", - "@csstools/postcss-color-function": "^2.2.3", - "@csstools/postcss-color-mix-function": "^1.0.3", - "@csstools/postcss-font-format-keywords": "^2.0.2", - "@csstools/postcss-gradients-interpolation-method": "^3.0.6", - "@csstools/postcss-hwb-function": "^2.2.2", - "@csstools/postcss-ic-unit": "^2.0.4", - "@csstools/postcss-is-pseudo-class": "^3.2.1", - "@csstools/postcss-logical-float-and-clear": "^1.0.1", - "@csstools/postcss-logical-resize": "^1.0.1", - "@csstools/postcss-logical-viewport-units": "^1.0.3", - "@csstools/postcss-media-minmax": "^1.0.4", - "@csstools/postcss-media-queries-aspect-ratio-number-values": "^1.0.4", - "@csstools/postcss-nested-calc": "^2.0.2", - "@csstools/postcss-normalize-display-values": "^2.0.1", - "@csstools/postcss-oklab-function": "^2.2.3", - "@csstools/postcss-progressive-custom-properties": "^2.3.0", - "@csstools/postcss-relative-color-syntax": "^1.0.2", - "@csstools/postcss-scope-pseudo-class": "^2.0.2", - "@csstools/postcss-stepped-value-functions": "^2.1.1", - "@csstools/postcss-text-decoration-shorthand": "^2.2.4", - "@csstools/postcss-trigonometric-functions": "^2.1.1", - "@csstools/postcss-unset-value": "^2.0.1", - "autoprefixer": "^10.4.14", - "browserslist": "^4.21.9", - "css-blank-pseudo": "^5.0.2", - "css-has-pseudo": "^5.0.2", - "css-prefers-color-scheme": "^8.0.2", - "cssdb": "^7.6.0", - "postcss-attribute-case-insensitive": "^6.0.2", - "postcss-clamp": "^4.1.0", - "postcss-color-functional-notation": "^5.1.0", - "postcss-color-hex-alpha": "^9.0.2", - "postcss-color-rebeccapurple": "^8.0.2", - "postcss-custom-media": "^9.1.5", - "postcss-custom-properties": "^13.2.0", - "postcss-custom-selectors": "^7.1.3", - "postcss-dir-pseudo-class": "^7.0.2", - "postcss-double-position-gradients": "^4.0.4", - "postcss-focus-visible": "^8.0.2", - "postcss-focus-within": "^7.0.2", - "postcss-font-variant": "^5.0.0", - "postcss-gap-properties": "^4.0.1", - "postcss-image-set-function": "^5.0.2", - "postcss-initial": "^4.0.1", - "postcss-lab-function": "^5.2.3", - "postcss-logical": "^6.2.0", - "postcss-nesting": "^11.3.0", - "postcss-opacity-percentage": "^2.0.0", - "postcss-overflow-shorthand": "^4.0.1", - "postcss-page-break": "^3.0.4", - "postcss-place": "^8.0.1", - "postcss-pseudo-class-any-link": "^8.0.2", - "postcss-replace-overflow-wrap": "^4.0.0", - "postcss-selector-not": "^7.0.1", "postcss-value-parser": "^4.2.0" }, "engines": { - "node": "^14 || ^16 || >=18" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, - "node_modules/postcss-pseudo-class-any-link": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-8.0.2.tgz", - "integrity": "sha512-FYTIuRE07jZ2CW8POvctRgArQJ43yxhr5vLmImdKUvjFCkR09kh8pIdlCwdx/jbFm7MiW4QP58L4oOUv3grQYA==", + "node_modules/postcss-preset-env": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-11.2.0.tgz", + "integrity": "sha512-eNYpuj68cjGjvZMoSAbHilaCt3yIyzBL1cVuSGJfvJewsaBW/U6dI2bqCJl3iuZsL+yvBobcy4zJFA/3I68IHQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "@csstools/postcss-alpha-function": "^2.0.3", + "@csstools/postcss-cascade-layers": "^6.0.0", + "@csstools/postcss-color-function": "^5.0.2", + "@csstools/postcss-color-function-display-p3-linear": "^2.0.2", + "@csstools/postcss-color-mix-function": "^4.0.2", + "@csstools/postcss-color-mix-variadic-function-arguments": "^2.0.2", + "@csstools/postcss-content-alt-text": "^3.0.0", + "@csstools/postcss-contrast-color-function": "^3.0.2", + "@csstools/postcss-exponential-functions": "^3.0.1", + "@csstools/postcss-font-format-keywords": "^5.0.0", + "@csstools/postcss-font-width-property": "^1.0.0", + "@csstools/postcss-gamut-mapping": "^3.0.2", + "@csstools/postcss-gradients-interpolation-method": "^6.0.2", + "@csstools/postcss-hwb-function": "^5.0.2", + "@csstools/postcss-ic-unit": "^5.0.0", + "@csstools/postcss-initial": "^3.0.0", + "@csstools/postcss-is-pseudo-class": "^6.0.0", + "@csstools/postcss-light-dark-function": "^3.0.0", + "@csstools/postcss-logical-float-and-clear": "^4.0.0", + "@csstools/postcss-logical-overflow": "^3.0.0", + "@csstools/postcss-logical-overscroll-behavior": "^3.0.0", + "@csstools/postcss-logical-resize": "^4.0.0", + "@csstools/postcss-logical-viewport-units": "^4.0.0", + "@csstools/postcss-media-minmax": "^3.0.1", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^4.0.0", + "@csstools/postcss-mixins": "^1.0.0", + "@csstools/postcss-nested-calc": "^5.0.0", + "@csstools/postcss-normalize-display-values": "^5.0.1", + "@csstools/postcss-oklab-function": "^5.0.2", + "@csstools/postcss-position-area-property": "^2.0.0", + "@csstools/postcss-progressive-custom-properties": "^5.0.0", + "@csstools/postcss-property-rule-prelude-list": "^2.0.0", + "@csstools/postcss-random-function": "^3.0.1", + "@csstools/postcss-relative-color-syntax": "^4.0.2", + "@csstools/postcss-scope-pseudo-class": "^5.0.0", + "@csstools/postcss-sign-functions": "^2.0.1", + "@csstools/postcss-stepped-value-functions": "^5.0.1", + "@csstools/postcss-syntax-descriptor-syntax-production": "^2.0.0", + "@csstools/postcss-system-ui-font-family": "^2.0.0", + "@csstools/postcss-text-decoration-shorthand": "^5.0.3", + "@csstools/postcss-trigonometric-functions": "^5.0.1", + "@csstools/postcss-unset-value": "^5.0.0", + "autoprefixer": "^10.4.24", + "browserslist": "^4.28.1", + "css-blank-pseudo": "^8.0.1", + "css-has-pseudo": "^8.0.0", + "css-prefers-color-scheme": "^11.0.0", + "cssdb": "^8.8.0", + "postcss-attribute-case-insensitive": "^8.0.0", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^8.0.2", + "postcss-color-hex-alpha": "^11.0.0", + "postcss-color-rebeccapurple": "^11.0.0", + "postcss-custom-media": "^12.0.1", + "postcss-custom-properties": "^15.0.1", + "postcss-custom-selectors": "^9.0.1", + "postcss-dir-pseudo-class": "^10.0.0", + "postcss-double-position-gradients": "^7.0.0", + "postcss-focus-visible": "^11.0.0", + "postcss-focus-within": "^10.0.0", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^7.0.0", + "postcss-image-set-function": "^8.0.0", + "postcss-lab-function": "^8.0.2", + "postcss-logical": "^9.0.0", + "postcss-nesting": "^14.0.0", + "postcss-opacity-percentage": "^3.0.0", + "postcss-overflow-shorthand": "^7.0.0", + "postcss-page-break": "^3.0.4", + "postcss-place": "^11.0.0", + "postcss-pseudo-class-any-link": "^11.0.0", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^9.0.0" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, + "node_modules/postcss-preset-env/node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/postcss-preset-env/node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-11.0.0.tgz", + "integrity": "sha512-DNFZ4GMa3C3pU5dM+UCTG1CEeLtS1ZqV5DKSqCTJQMn1G5jnd/30fS8+A7H4o5bSD3MOcnx+VgI+xPE9Z5Wvig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-reduce-initial": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", @@ -35874,24 +36962,45 @@ } }, "node_modules/postcss-selector-not": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-7.0.1.tgz", - "integrity": "sha512-1zT5C27b/zeJhchN7fP0kBr16Cc61mu7Si9uWWLoA3Px/D9tIJPKchJCkUH3tPO5D0pCFmGeApAv8XpXBQJ8SQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-9.0.0.tgz", + "integrity": "sha512-xhAtTdHnVU2M/CrpYOPyRUvg3njhVlKmn2GNYXDaRJV9Ygx4d5OkSkc7NINzjUqnbDFtaKXlISOBeyMXU/zyFQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "dependencies": { - "postcss-selector-parser": "^6.0.10" + "postcss-selector-parser": "^7.1.1" }, "engines": { - "node": "^14 || ^16 || >=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "node": ">=20.19.0" }, "peerDependencies": { "postcss": "^8.4" } }, + "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-selector-parser": { "version": "6.0.15", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", diff --git a/package.json b/package.json index 9c27a7ea99..4791843326 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "devDependencies": { "@axe-core/playwright": "^4.10.1", "@eslint/compat": "^1.2.6", - "@eslint/eslintrc": "^3.3.1", + "@eslint/eslintrc": "^3.3.4", "@eslint/js": "^9.20.0", "@playwright/test": "^1.56.1", "@types/react-virtualized": "^9.22.0", @@ -165,7 +165,10 @@ "katex": "^0.16.21" } }, - "langsmith": "0.4.12" + "langsmith": "0.4.12", + "eslint": { + "ajv": "6.14.0" + } }, "nodemonConfig": { "ignore": [ From a0f9782e6082675a4e2ae7e07a61d5094fcc1fbb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 25 Feb 2026 17:41:23 -0500 Subject: [PATCH 021/110] =?UTF-8?q?=F0=9F=AA=A3=20fix:=20Prevent=20Memory?= =?UTF-8?q?=20Retention=20from=20AsyncLocalStorage=20Context=20Propagation?= =?UTF-8?q?=20(#11942)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: store hide_sequential_outputs before processStream clears config processStream now clears config.configurable after completion to break memory retention chains. Save hide_sequential_outputs to a local variable before calling runAgents so the post-stream filter still works. * feat: memory diagnostics * chore: expose garbage collection in backend inspect command Updated the backend inspect command in package.json to include the --expose-gc flag, enabling garbage collection diagnostics for improved memory management during development. * chore: update @librechat/agents dependency to version 3.1.52 Bumped the version of @librechat/agents in package.json and package-lock.json to ensure compatibility and access to the latest features and fixes. * fix: clear heavy config state after processStream to prevent memory leaks Break the reference chain from LangGraph's internal __pregel_scratchpad through @langchain/core RunTree.extra[lc:child_config] into the AsyncLocalStorage context captured by timers and I/O handles. After stream completion, null out symbol-keyed scratchpad properties (currentTaskInput), config.configurable, and callbacks. Also call Graph.clearHeavyState() to release config, signal, content maps, handler registry, and tool sessions. * chore: fix imports for memory utils * chore: add circular dependency check in API build step Enhanced the backend review workflow to include a check for circular dependencies during the API build process. If a circular dependency is detected, an error message is displayed, and the process exits with a failure status. * chore: update API build step to include circular dependency detection Modified the backend review workflow to rename the API package installation step to reflect its new functionality, which now includes detection of circular dependencies during the build process. * chore: add memory diagnostics option to .env.example Included a commented-out configuration option for enabling memory diagnostics in the .env.example file, which logs heap and RSS snapshots every 60 seconds when activated. * chore: remove redundant agentContexts cleanup in disposeClient function Streamlined the disposeClient function by eliminating duplicate cleanup logic for agentContexts, ensuring efficient memory management during client disposal. * refactor: move runOutsideTracing utility to utils and update its usage Refactored the runOutsideTracing function by relocating it to the utils module for better organization. Updated the tool execution handler to utilize the new import, ensuring consistent tracing behavior during tool execution. * refactor: enhance connection management and diagnostics Added a method to ConnectionsRepository for retrieving the active connection count. Updated UserConnectionManager to utilize this new method for app connection count reporting. Refined the OAuthReconnectionTracker's getStats method to improve clarity in diagnostics. Introduced a new tracing utility in the utils module to streamline tracing context management. Additionally, added a safeguard in memory diagnostics to prevent unnecessary snapshot collection for very short intervals. * refactor: enhance tracing utility and add memory diagnostics tests Refactored the runOutsideTracing function to improve warning logic when the AsyncLocalStorage context is missing. Added tests for memory diagnostics and tracing utilities to ensure proper functionality and error handling. Introduced a new test suite for memory diagnostics, covering snapshot collection and garbage collection behavior. --- .env.example | 3 + .github/workflows/backend-review.yml | 10 +- api/package.json | 2 +- api/server/cleanup.js | 12 +- api/server/controllers/agents/client.js | 3 +- api/server/index.js | 8 +- package-lock.json | 10 +- package.json | 2 +- packages/api/package.json | 2 +- packages/api/src/agents/handlers.ts | 205 ++++++++++-------- packages/api/src/index.ts | 2 + packages/api/src/mcp/ConnectionsRepository.ts | 5 + packages/api/src/mcp/UserConnectionManager.ts | 19 ++ packages/api/src/mcp/connection.ts | 15 +- .../src/mcp/oauth/OAuthReconnectionManager.ts | 4 + .../src/mcp/oauth/OAuthReconnectionTracker.ts | 13 ++ .../api/src/stream/GenerationJobManager.ts | 13 ++ .../api/src/utils/__tests__/memory.test.ts | 173 +++++++++++++++ .../api/src/utils/__tests__/tracing.test.ts | 137 ++++++++++++ packages/api/src/utils/index.ts | 1 + packages/api/src/utils/memory.ts | 150 +++++++++++++ packages/api/src/utils/tracing.ts | 31 +++ 22 files changed, 704 insertions(+), 116 deletions(-) create mode 100644 packages/api/src/utils/__tests__/memory.test.ts create mode 100644 packages/api/src/utils/__tests__/tracing.test.ts create mode 100644 packages/api/src/utils/memory.ts create mode 100644 packages/api/src/utils/tracing.ts diff --git a/.env.example b/.env.example index f6d2ec271f..3e94a0c63a 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,9 @@ CONSOLE_JSON=false DEBUG_LOGGING=true DEBUG_CONSOLE=false +# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect) +# MEM_DIAG=true + #=============# # Permissions # #=============# diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 2379b8fee7..e151087790 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -42,8 +42,14 @@ jobs: - name: Install Data Schemas Package run: npm run build:data-schemas - - name: Install API Package - run: npm run build:api + - name: Build API Package & Detect Circular Dependencies + run: | + output=$(npm run build:api 2>&1) + echo "$output" + if echo "$output" | grep -q "Circular depend"; then + echo "Error: Circular dependency detected in @librechat/api!" + exit 1 + fi - name: Create empty auth.json file run: | diff --git a/api/package.json b/api/package.json index 7c3c1045ed..1447087b38 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/cleanup.js b/api/server/cleanup.js index c482a2267e..364c02cd8a 100644 --- a/api/server/cleanup.js +++ b/api/server/cleanup.js @@ -35,7 +35,6 @@ const graphPropsToClean = [ 'tools', 'signal', 'config', - 'agentContexts', 'messages', 'contentData', 'stepKeyIds', @@ -277,7 +276,16 @@ function disposeClient(client) { if (client.run) { if (client.run.Graph) { - client.run.Graph.resetValues(); + if (typeof client.run.Graph.clearHeavyState === 'function') { + client.run.Graph.clearHeavyState(); + } else { + client.run.Graph.resetValues(); + } + + if (client.run.Graph.agentContexts) { + client.run.Graph.agentContexts.clear(); + client.run.Graph.agentContexts = null; + } graphPropsToClean.forEach((prop) => { if (client.run.Graph[prop] !== undefined) { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 49240a6b3b..7aea6d1e8f 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -891,9 +891,10 @@ class AgentClient extends BaseClient { config.signal = null; }; + const hideSequentialOutputs = config.configurable.hide_sequential_outputs; await runAgents(initialMessages); /** @deprecated Agent Chain */ - if (config.configurable.hide_sequential_outputs) { + if (hideSequentialOutputs) { this.contentParts = this.contentParts.filter((part, index) => { // Include parts that are either: // 1. At or after the finalContentStart index diff --git a/api/server/index.js b/api/server/index.js index 193eb423ad..2aff26ceaf 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -13,11 +13,12 @@ const mongoSanitize = require('express-mongo-sanitize'); const { isEnabled, ErrorController, + memoryDiagnostics, performStartupChecks, handleJsonParseError, - initializeFileStorage, GenerationJobManager, createStreamServices, + initializeFileStorage, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); @@ -201,6 +202,11 @@ const startServer = async () => { const streamServices = createStreamServices(); GenerationJobManager.configure(streamServices); GenerationJobManager.initialize(); + + const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect')); + if (inspectFlags || isEnabled(process.env.MEM_DIAG)) { + memoryDiagnostics.start(); + } }); }; diff --git a/package-lock.json b/package-lock.json index 3a875f9fb7..bbb379c4d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11859,9 +11859,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.51", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.51.tgz", - "integrity": "sha512-inEcLCuD7YF0yCBrnxCgemg2oyRWJtCq49tLtokrD+WyWT97benSB+UyopjWh5woOsxSws3oc60d5mxRtifoLg==", + "version": "3.1.52", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.52.tgz", + "integrity": "sha512-Bg35zp+vEDZ0AEJQPZ+ukWb/UqBrsLcr3YQWRQpuvpftEgfQz0fHM5Wrxn6l5P7PvaD1ViolxoG44nggjCt7Hw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -43719,7 +43719,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/package.json b/package.json index 4791843326..02a46df399 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "update-banner": "node config/update-banner.js", "delete-banner": "node config/delete-banner.js", "backend": "cross-env NODE_ENV=production node api/server/index.js", - "backend:inspect": "cross-env NODE_ENV=production node --inspect api/server/index.js", + "backend:inspect": "cross-env NODE_ENV=production node --inspect --expose-gc api/server/index.js", "backend:dev": "cross-env NODE_ENV=development npx nodemon api/server/index.js", "backend:experimental": "cross-env NODE_ENV=production node api/server/experimental.js", "backend:stop": "node config/stop-backend.js", diff --git a/packages/api/package.json b/packages/api/package.json index 8e55d8d901..1854457b42 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -90,7 +90,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.51", + "@librechat/agents": "^3.1.52", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 62200b1a46..07c68c9d8a 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -9,6 +9,7 @@ import type { ToolExecuteBatchRequest, } from '@librechat/agents'; import type { StructuredToolInterface } from '@langchain/core/tools'; +import { runOutsideTracing } from '~/utils'; export interface ToolEndCallbackData { output: { @@ -57,110 +58,122 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand const { toolCalls, agentId, configurable, metadata, resolve, reject } = data; try { - const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))]; - const { loadedTools, configurable: toolConfigurable } = await loadTools(toolNames, agentId); - const toolMap = new Map(loadedTools.map((t) => [t.name, t])); - const mergedConfigurable = { ...configurable, ...toolConfigurable }; + await runOutsideTracing(async () => { + try { + const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))]; + const { loadedTools, configurable: toolConfigurable } = await loadTools( + toolNames, + agentId, + ); + const toolMap = new Map(loadedTools.map((t) => [t.name, t])); + const mergedConfigurable = { ...configurable, ...toolConfigurable }; - const results: ToolExecuteResult[] = await Promise.all( - toolCalls.map(async (tc: ToolCallRequest) => { - const tool = toolMap.get(tc.name); + const results: ToolExecuteResult[] = await Promise.all( + toolCalls.map(async (tc: ToolCallRequest) => { + const tool = toolMap.get(tc.name); - if (!tool) { - logger.warn( - `[ON_TOOL_EXECUTE] Tool "${tc.name}" not found. Available: ${[...toolMap.keys()].join(', ')}`, - ); - return { - toolCallId: tc.id, - status: 'error' as const, - content: '', - errorMessage: `Tool ${tc.name} not found`, - }; - } - - try { - const toolCallConfig: Record = { - id: tc.id, - stepId: tc.stepId, - turn: tc.turn, - }; - - if ( - tc.codeSessionContext && - (tc.name === Constants.EXECUTE_CODE || - tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) - ) { - toolCallConfig.session_id = tc.codeSessionContext.session_id; - if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) { - toolCallConfig._injected_files = tc.codeSessionContext.files; - } - } - - if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) { - const toolRegistry = mergedConfigurable?.toolRegistry as LCToolRegistry | undefined; - const ptcToolMap = mergedConfigurable?.ptcToolMap as - | Map - | undefined; - if (toolRegistry) { - const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter( - (t) => - t.name !== Constants.PROGRAMMATIC_TOOL_CALLING && - t.name !== Constants.TOOL_SEARCH, + if (!tool) { + logger.warn( + `[ON_TOOL_EXECUTE] Tool "${tc.name}" not found. Available: ${[...toolMap.keys()].join(', ')}`, ); - toolCallConfig.toolDefs = toolDefs; - toolCallConfig.toolMap = ptcToolMap ?? toolMap; + return { + toolCallId: tc.id, + status: 'error' as const, + content: '', + errorMessage: `Tool ${tc.name} not found`, + }; } - } - const result = await tool.invoke(tc.args, { - toolCall: toolCallConfig, - configurable: mergedConfigurable, - metadata, - } as Record); + try { + const toolCallConfig: Record = { + id: tc.id, + stepId: tc.stepId, + turn: tc.turn, + }; - if (toolEndCallback) { - await toolEndCallback( - { - output: { - name: tc.name, - tool_call_id: tc.id, - content: result.content, - artifact: result.artifact, - }, - }, - { - run_id: (metadata as Record)?.run_id as string | undefined, - thread_id: (metadata as Record)?.thread_id as - | string - | undefined, - ...metadata, - }, - ); - } + if ( + tc.codeSessionContext && + (tc.name === Constants.EXECUTE_CODE || + tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) + ) { + toolCallConfig.session_id = tc.codeSessionContext.session_id; + if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) { + toolCallConfig._injected_files = tc.codeSessionContext.files; + } + } - return { - toolCallId: tc.id, - content: result.content, - artifact: result.artifact, - status: 'success' as const, - }; - } catch (toolError) { - const error = toolError as Error; - logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error); - return { - toolCallId: tc.id, - status: 'error' as const, - content: '', - errorMessage: error.message, - }; - } - }), - ); + if (tc.name === Constants.PROGRAMMATIC_TOOL_CALLING) { + const toolRegistry = mergedConfigurable?.toolRegistry as + | LCToolRegistry + | undefined; + const ptcToolMap = mergedConfigurable?.ptcToolMap as + | Map + | undefined; + if (toolRegistry) { + const toolDefs: LCTool[] = Array.from(toolRegistry.values()).filter( + (t) => + t.name !== Constants.PROGRAMMATIC_TOOL_CALLING && + t.name !== Constants.TOOL_SEARCH, + ); + toolCallConfig.toolDefs = toolDefs; + toolCallConfig.toolMap = ptcToolMap ?? toolMap; + } + } - resolve(results); - } catch (error) { - logger.error('[ON_TOOL_EXECUTE] Fatal error:', error); - reject(error as Error); + const result = await tool.invoke(tc.args, { + toolCall: toolCallConfig, + configurable: mergedConfigurable, + metadata, + } as Record); + + if (toolEndCallback) { + await toolEndCallback( + { + output: { + name: tc.name, + tool_call_id: tc.id, + content: result.content, + artifact: result.artifact, + }, + }, + { + run_id: (metadata as Record)?.run_id as string | undefined, + thread_id: (metadata as Record)?.thread_id as + | string + | undefined, + ...metadata, + }, + ); + } + + return { + toolCallId: tc.id, + content: result.content, + artifact: result.artifact, + status: 'success' as const, + }; + } catch (toolError) { + const error = toolError as Error; + logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error); + return { + toolCallId: tc.id, + status: 'error' as const, + content: '', + errorMessage: error.message, + }; + } + }), + ); + + resolve(results); + } catch (error) { + logger.error('[ON_TOOL_EXECUTE] Fatal error:', error); + reject(error as Error); + } + }); + } catch (outerError) { + logger.error('[ON_TOOL_EXECUTE] Unexpected error:', outerError); + reject(outerError as Error); } }, }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fefdafaefd..a7edb3882d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -43,6 +43,8 @@ export * from './web'; export * from './cache'; /* Stream */ export * from './stream'; +/* Diagnostics */ +export { memoryDiagnostics } from './utils/memory'; /* types */ export type * from './mcp/types'; export type * from './flow/types'; diff --git a/packages/api/src/mcp/ConnectionsRepository.ts b/packages/api/src/mcp/ConnectionsRepository.ts index b14af57b29..3e0c2aca2d 100644 --- a/packages/api/src/mcp/ConnectionsRepository.ts +++ b/packages/api/src/mcp/ConnectionsRepository.ts @@ -25,6 +25,11 @@ export class ConnectionsRepository { this.oauthOpts = oauthOpts; } + /** Returns the number of active connections in this repository */ + public getConnectionCount(): number { + return this.connections.size; + } + /** Checks whether this repository can connect to a specific server */ async has(serverName: string): Promise { const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, this.ownerId); diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index e5d94689a0..1b90072618 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -237,4 +237,23 @@ export abstract class UserConnectionManager { } } } + + /** Returns counts of tracked users and connections for diagnostics */ + public getConnectionStats(): { + trackedUsers: number; + totalConnections: number; + activityEntries: number; + appConnectionCount: number; + } { + let totalConnections = 0; + for (const serverMap of this.userConnections.values()) { + totalConnections += serverMap.size; + } + return { + trackedUsers: this.userConnections.size, + totalConnections, + activityEntries: this.userLastActivity.size, + appConnectionCount: this.appConnections?.getConnectionCount() ?? 0, + }; + } } diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 5744059708..8ac55224f8 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -18,10 +18,11 @@ import type { Response as UndiciResponse, } from 'undici'; import type { MCPOAuthTokens } from './oauth/types'; -import { withTimeout } from '~/utils/promise'; import type * as t from './types'; import { createSSRFSafeUndiciConnect, resolveHostnameSSRF } from '~/auth'; +import { runOutsideTracing } from '~/utils/tracing'; import { sanitizeUrlForLogging } from './utils'; +import { withTimeout } from '~/utils/promise'; import { mcpConfig } from './mcpConfig'; type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -698,14 +699,16 @@ export class MCPConnection extends EventEmitter { await this.closeAgents(); } - this.transport = await this.constructTransport(this.options); + this.transport = await runOutsideTracing(() => this.constructTransport(this.options)); this.setupTransportDebugHandlers(); const connectTimeout = this.options.initTimeout ?? 120000; - await withTimeout( - this.client.connect(this.transport), - connectTimeout, - `Connection timeout after ${connectTimeout}ms`, + await runOutsideTracing(() => + withTimeout( + this.client.connect(this.transport!), + connectTimeout, + `Connection timeout after ${connectTimeout}ms`, + ), ); this.connectionState = 'connected'; diff --git a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts index ca9ce5c71f..f14c4abf15 100644 --- a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts +++ b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts @@ -147,6 +147,10 @@ export class OAuthReconnectionManager { } } + public getTrackerStats() { + return this.reconnectionsTracker.getStats(); + } + private async canReconnect(userId: string, serverName: string) { if (this.mcpManager == null) { return false; diff --git a/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts b/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts index b65f8ad115..9f6ef4abd3 100644 --- a/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts +++ b/packages/api/src/mcp/oauth/OAuthReconnectionTracker.ts @@ -86,4 +86,17 @@ export class OAuthReconnectionTracker { const key = `${userId}:${serverName}`; this.activeTimestamps.delete(key); } + + /** Returns map sizes for diagnostics */ + public getStats(): { + usersWithFailedServers: number; + usersWithActiveReconnections: number; + activeTimestamps: number; + } { + return { + usersWithFailedServers: this.failed.size, + usersWithActiveReconnections: this.active.size, + activeTimestamps: this.activeTimestamps.size, + }; + } } diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index 815133d616..cd5ff04eb0 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -1142,6 +1142,19 @@ class GenerationJobManagerClass { return this.jobStore.getJobCount(); } + /** Returns sizes of internal runtime maps for diagnostics */ + getRuntimeStats(): { + runtimeStateSize: number; + runStepBufferSize: number; + eventTransportStreams: number; + } { + return { + runtimeStateSize: this.runtimeState.size, + runStepBufferSize: this.runStepBuffers?.size ?? 0, + eventTransportStreams: this.eventTransport.getTrackedStreamIds().length, + }; + } + /** * Get job count by status. */ diff --git a/packages/api/src/utils/__tests__/memory.test.ts b/packages/api/src/utils/__tests__/memory.test.ts new file mode 100644 index 0000000000..c821088856 --- /dev/null +++ b/packages/api/src/utils/__tests__/memory.test.ts @@ -0,0 +1,173 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('~/stream', () => ({ + GenerationJobManager: { + getRuntimeStats: jest.fn(() => null), + }, +})); + +jest.mock('~/mcp/oauth/OAuthReconnectionManager', () => ({ + OAuthReconnectionManager: { + getInstance: jest.fn(() => ({ + getTrackerStats: jest.fn(() => null), + })), + }, +})); + +jest.mock('~/mcp/MCPManager', () => ({ + MCPManager: { + getInstance: jest.fn(() => ({ + getConnectionStats: jest.fn(() => null), + })), + }, +})); + +import { logger } from '@librechat/data-schemas'; +import { memoryDiagnostics } from '../memory'; + +type MockFn = jest.Mock; + +const debugMock = logger.debug as unknown as MockFn; +const infoMock = logger.info as unknown as MockFn; +const warnMock = logger.warn as unknown as MockFn; + +function callsContaining(mock: MockFn, substring: string): unknown[][] { + return mock.mock.calls.filter( + (args) => typeof args[0] === 'string' && (args[0] as string).includes(substring), + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + memoryDiagnostics.stop(); + + const snaps = memoryDiagnostics.getSnapshots() as unknown[]; + snaps.length = 0; +}); + +afterEach(() => { + memoryDiagnostics.stop(); + jest.useRealTimers(); +}); + +describe('memoryDiagnostics', () => { + describe('collectSnapshot', () => { + it('pushes a snapshot with expected shape', () => { + memoryDiagnostics.collectSnapshot(); + + const snaps = memoryDiagnostics.getSnapshots(); + expect(snaps).toHaveLength(1); + expect(snaps[0]).toEqual( + expect.objectContaining({ + ts: expect.any(Number), + rss: expect.any(Number), + heapUsed: expect.any(Number), + heapTotal: expect.any(Number), + external: expect.any(Number), + arrayBuffers: expect.any(Number), + }), + ); + }); + + it('caps history at 120 snapshots', () => { + for (let i = 0; i < 130; i++) { + memoryDiagnostics.collectSnapshot(); + } + expect(memoryDiagnostics.getSnapshots()).toHaveLength(120); + }); + + it('does not log trend with fewer than 3 snapshots', () => { + memoryDiagnostics.collectSnapshot(); + memoryDiagnostics.collectSnapshot(); + + expect(callsContaining(debugMock, 'Trend')).toHaveLength(0); + }); + + it('skips trend when elapsed time is under 0.1 minutes', () => { + memoryDiagnostics.collectSnapshot(); + memoryDiagnostics.collectSnapshot(); + memoryDiagnostics.collectSnapshot(); + + expect(callsContaining(debugMock, 'Trend')).toHaveLength(0); + }); + + it('logs trend data when enough time has elapsed', () => { + memoryDiagnostics.collectSnapshot(); + + jest.advanceTimersByTime(7_000); + memoryDiagnostics.collectSnapshot(); + + jest.advanceTimersByTime(7_000); + memoryDiagnostics.collectSnapshot(); + + const trendCalls = callsContaining(debugMock, 'Trend'); + expect(trendCalls.length).toBeGreaterThanOrEqual(1); + + const trendPayload = trendCalls[0][1] as Record; + expect(trendPayload).toHaveProperty('rssRate'); + expect(trendPayload).toHaveProperty('heapRate'); + expect(trendPayload.rssRate).toMatch(/MB\/hr$/); + expect(trendPayload.heapRate).toMatch(/MB\/hr$/); + expect(trendPayload.rssRate).not.toBe('Infinity MB/hr'); + expect(trendPayload.heapRate).not.toBe('Infinity MB/hr'); + }); + }); + + describe('start / stop', () => { + it('start is idempotent — calling twice does not create two intervals', () => { + memoryDiagnostics.start(); + memoryDiagnostics.start(); + + expect(callsContaining(infoMock, 'Starting')).toHaveLength(1); + }); + + it('stop is idempotent — calling twice does not error', () => { + memoryDiagnostics.start(); + memoryDiagnostics.stop(); + memoryDiagnostics.stop(); + + expect(callsContaining(infoMock, 'Stopped')).toHaveLength(1); + }); + + it('collects an immediate snapshot on start', () => { + expect(memoryDiagnostics.getSnapshots()).toHaveLength(0); + memoryDiagnostics.start(); + expect(memoryDiagnostics.getSnapshots().length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('forceGC', () => { + it('returns false and warns when gc is not exposed', () => { + const origGC = global.gc; + global.gc = undefined; + + const result = memoryDiagnostics.forceGC(); + + expect(result).toBe(false); + expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('GC not exposed')); + + global.gc = origGC; + }); + + it('calls gc and returns true when gc is exposed', () => { + const mockGC = jest.fn(); + global.gc = mockGC; + + const result = memoryDiagnostics.forceGC(); + + expect(result).toBe(true); + expect(mockGC).toHaveBeenCalledTimes(1); + expect(infoMock).toHaveBeenCalledWith(expect.stringContaining('Forced garbage collection')); + + global.gc = undefined; + }); + }); +}); diff --git a/packages/api/src/utils/__tests__/tracing.test.ts b/packages/api/src/utils/__tests__/tracing.test.ts new file mode 100644 index 0000000000..679b28e327 --- /dev/null +++ b/packages/api/src/utils/__tests__/tracing.test.ts @@ -0,0 +1,137 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const TRACING_ALS_KEY = Symbol.for('ls:tracing_async_local_storage'); +const typedGlobal = globalThis as typeof globalThis & Record>; + +let originalStorage: AsyncLocalStorage | undefined; + +beforeEach(() => { + originalStorage = typedGlobal[TRACING_ALS_KEY]; + jest.restoreAllMocks(); +}); + +afterEach(() => { + if (originalStorage) { + typedGlobal[TRACING_ALS_KEY] = originalStorage; + } else { + delete typedGlobal[TRACING_ALS_KEY]; + } + delete process.env.LANGCHAIN_TRACING_V2; +}); + +async function freshImport(): Promise { + jest.resetModules(); + return import('../tracing'); +} + +describe('runOutsideTracing', () => { + it('clears the ALS context to undefined inside fn', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + let captured: string | undefined = 'NOT_CLEARED'; + als.run('should-not-propagate', () => { + runOutsideTracing(() => { + captured = als.getStore(); + }); + }); + + expect(captured).toBeUndefined(); + }); + + it('returns the value produced by fn (sync)', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + const result = als.run('ctx', () => runOutsideTracing(() => 42)); + expect(result).toBe(42); + }); + + it('returns the promise produced by fn (async)', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + const result = await als.run('ctx', () => + runOutsideTracing(async () => { + await Promise.resolve(); + return 'async-value'; + }), + ); + expect(result).toBe('async-value'); + }); + + it('propagates sync errors thrown inside fn', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + expect(() => + runOutsideTracing(() => { + throw new Error('boom'); + }), + ).toThrow('boom'); + }); + + it('propagates async rejections from fn', async () => { + const als = new AsyncLocalStorage(); + typedGlobal[TRACING_ALS_KEY] = als as AsyncLocalStorage; + + const { runOutsideTracing } = await freshImport(); + + await expect( + runOutsideTracing(async () => { + throw new Error('async-boom'); + }), + ).rejects.toThrow('async-boom'); + }); + + it('falls back to fn() when ALS is not on globalThis', async () => { + delete typedGlobal[TRACING_ALS_KEY]; + + const { runOutsideTracing } = await freshImport(); + + const result = runOutsideTracing(() => 'fallback'); + expect(result).toBe('fallback'); + }); + + it('does not warn when LANGCHAIN_TRACING_V2 is not set', async () => { + delete typedGlobal[TRACING_ALS_KEY]; + delete process.env.LANGCHAIN_TRACING_V2; + + const warnSpy = jest.fn(); + jest.resetModules(); + jest.doMock('@librechat/data-schemas', () => ({ + logger: { warn: warnSpy }, + })); + const { runOutsideTracing } = await import('../tracing'); + + runOutsideTracing(() => 'ok'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns once when LANGCHAIN_TRACING_V2 is set but ALS is missing', async () => { + delete typedGlobal[TRACING_ALS_KEY]; + process.env.LANGCHAIN_TRACING_V2 = 'true'; + + const warnSpy = jest.fn(); + jest.resetModules(); + jest.doMock('@librechat/data-schemas', () => ({ + logger: { warn: warnSpy }, + })); + const { runOutsideTracing } = await import('../tracing'); + + runOutsideTracing(() => 'first'); + runOutsideTracing(() => 'second'); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('LANGCHAIN_TRACING_V2 is set but ALS not found'), + ); + }); +}); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index d4351eb5a0..470780cd5c 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -25,3 +25,4 @@ export * from './http'; export * from './tokens'; export * from './url'; export * from './message'; +export * from './tracing'; diff --git a/packages/api/src/utils/memory.ts b/packages/api/src/utils/memory.ts new file mode 100644 index 0000000000..214548d14b --- /dev/null +++ b/packages/api/src/utils/memory.ts @@ -0,0 +1,150 @@ +import { logger } from '@librechat/data-schemas'; +import { GenerationJobManager } from '~/stream'; +import { OAuthReconnectionManager } from '~/mcp/oauth/OAuthReconnectionManager'; +import { MCPManager } from '~/mcp/MCPManager'; + +type ConnectionStats = ReturnType['getConnectionStats']>; +type TrackerStats = ReturnType['getTrackerStats']>; +type RuntimeStats = ReturnType<(typeof GenerationJobManager)['getRuntimeStats']>; + +const INTERVAL_MS = 60_000; +const SNAPSHOT_HISTORY_LIMIT = 120; + +interface MemorySnapshot { + ts: number; + rss: number; + heapUsed: number; + heapTotal: number; + external: number; + arrayBuffers: number; + mcpConnections: ConnectionStats | null; + oauthTracker: TrackerStats | null; + generationJobs: RuntimeStats | null; +} + +const snapshots: MemorySnapshot[] = []; +let interval: NodeJS.Timeout | null = null; + +function toMB(bytes: number): string { + return (bytes / 1024 / 1024).toFixed(2); +} + +function getMCPStats(): { + mcpConnections: ConnectionStats | null; + oauthTracker: TrackerStats | null; +} { + let mcpConnections: ConnectionStats | null = null; + let oauthTracker: TrackerStats | null = null; + + try { + mcpConnections = MCPManager.getInstance().getConnectionStats(); + } catch { + /* not initialized yet */ + } + + try { + oauthTracker = OAuthReconnectionManager.getInstance().getTrackerStats(); + } catch { + /* not initialized yet */ + } + + return { mcpConnections, oauthTracker }; +} + +function getJobStats(): { generationJobs: RuntimeStats | null } { + try { + return { generationJobs: GenerationJobManager.getRuntimeStats() }; + } catch { + return { generationJobs: null }; + } +} + +function collectSnapshot(): void { + const mem = process.memoryUsage(); + const mcpStats = getMCPStats(); + const jobStats = getJobStats(); + + const snapshot: MemorySnapshot = { + ts: Date.now(), + rss: mem.rss, + heapUsed: mem.heapUsed, + heapTotal: mem.heapTotal, + external: mem.external, + arrayBuffers: mem.arrayBuffers ?? 0, + ...mcpStats, + ...jobStats, + }; + + snapshots.push(snapshot); + if (snapshots.length > SNAPSHOT_HISTORY_LIMIT) { + snapshots.shift(); + } + + logger.debug('[MemDiag] Snapshot', { + rss: `${toMB(mem.rss)} MB`, + heapUsed: `${toMB(mem.heapUsed)} MB`, + heapTotal: `${toMB(mem.heapTotal)} MB`, + external: `${toMB(mem.external)} MB`, + arrayBuffers: `${toMB(mem.arrayBuffers ?? 0)} MB`, + mcp: mcpStats, + jobs: jobStats, + snapshotCount: snapshots.length, + }); + + if (snapshots.length < 3) { + return; + } + + const first = snapshots[0]; + const last = snapshots[snapshots.length - 1]; + const elapsedMin = (last.ts - first.ts) / 60_000; + if (elapsedMin < 0.1) { + return; + } + const rssDelta = last.rss - first.rss; + const heapDelta = last.heapUsed - first.heapUsed; + logger.debug('[MemDiag] Trend', { + overMinutes: elapsedMin.toFixed(1), + rssDelta: `${toMB(rssDelta)} MB`, + heapDelta: `${toMB(heapDelta)} MB`, + rssRate: `${toMB((rssDelta / elapsedMin) * 60)} MB/hr`, + heapRate: `${toMB((heapDelta / elapsedMin) * 60)} MB/hr`, + }); +} + +function forceGC(): boolean { + if (global.gc) { + global.gc(); + logger.info('[MemDiag] Forced garbage collection'); + return true; + } + logger.warn('[MemDiag] GC not exposed. Start with --expose-gc to enable.'); + return false; +} + +function getSnapshots(): readonly MemorySnapshot[] { + return snapshots; +} + +function start(): void { + if (interval) { + return; + } + logger.info(`[MemDiag] Starting memory diagnostics (interval: ${INTERVAL_MS / 1000}s)`); + collectSnapshot(); + interval = setInterval(collectSnapshot, INTERVAL_MS); + if (interval.unref) { + interval.unref(); + } +} + +function stop(): void { + if (!interval) { + return; + } + clearInterval(interval); + interval = null; + logger.info('[MemDiag] Stopped memory diagnostics'); +} + +export const memoryDiagnostics = { start, stop, forceGC, getSnapshots, collectSnapshot }; diff --git a/packages/api/src/utils/tracing.ts b/packages/api/src/utils/tracing.ts new file mode 100644 index 0000000000..6a82caf092 --- /dev/null +++ b/packages/api/src/utils/tracing.ts @@ -0,0 +1,31 @@ +import { logger } from '@librechat/data-schemas'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { isEnabled } from '~/utils/common'; + +/** @see https://github.com/langchain-ai/langchainjs — @langchain/core RunTree ALS */ +const TRACING_ALS_KEY = Symbol.for('ls:tracing_async_local_storage'); + +let warnedMissing = false; + +/** + * Runs `fn` outside the LangGraph/LangSmith tracing AsyncLocalStorage context + * so I/O handles (child processes, sockets, timers) created during `fn` + * do not permanently retain the RunTree → graph config → message data chain. + * + * Relies on the private symbol `ls:tracing_async_local_storage` from `@langchain/core`. + * If the symbol is absent, falls back to calling `fn()` directly. + */ +export function runOutsideTracing(fn: () => T): T { + const storage = (globalThis as typeof globalThis & Record>)[ + TRACING_ALS_KEY + ]; + if (!storage && !warnedMissing && isEnabled(process.env.LANGCHAIN_TRACING_V2)) { + warnedMissing = true; + logger.warn( + '[runOutsideTracing] LANGCHAIN_TRACING_V2 is set but ALS not found — ' + + 'runOutsideTracing will be a no-op. ' + + 'Verify @langchain/core version still uses Symbol.for("ls:tracing_async_local_storage").', + ); + } + return storage ? storage.run(undefined as unknown, fn) : fn(); +} From e978a934fc5ee9b84c46545aef1b9b18159d9f7b Mon Sep 17 00:00:00 2001 From: Vamsi Konakanchi <39833739+vmskonakanchi@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:51:19 +0530 Subject: [PATCH 022/110] =?UTF-8?q?=F0=9F=93=8D=20feat:=20Preserve=20Deep?= =?UTF-8?q?=20Link=20Destinations=20Through=20the=20Auth=20Redirect=20Flow?= =?UTF-8?q?=20(#10275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added support for url query param persistance * refactor: authentication redirect handling - Introduced utility functions for managing login redirects, including `persistRedirectToSession`, `buildLoginRedirectUrl`, and `getPostLoginRedirect`. - Updated `Login` and `AuthContextProvider` components to utilize these utilities for improved redirect logic. - Refactored `useAuthRedirect` to streamline navigation to the login page while preserving intended destinations. - Cleaned up the `StartupLayout` to remove unnecessary redirect handling, ensuring a more straightforward navigation flow. - Added a new `redirect.ts` file to encapsulate redirect-related logic, enhancing code organization and maintainability. * fix: enhance safe redirect validation logic - Updated the `isSafeRedirect` function to improve validation of redirect URLs. - Ensured that only safe relative paths are accepted, specifically excluding paths that lead to the login page. - Refactored the logic to streamline the checks for valid redirect targets. * test: add unit tests for redirect utility functions - Introduced comprehensive tests for `isSafeRedirect`, `buildLoginRedirectUrl`, `getPostLoginRedirect`, and `persistRedirectToSession` functions. - Validated various scenarios including safe and unsafe redirects, URL encoding, and session storage behavior. - Enhanced test coverage to ensure robust handling of redirect logic and prevent potential security issues. * chore: streamline authentication and redirect handling - Removed unused `useLocation` import from `AuthContextProvider` and replaced its usage with `window.location` for better clarity. - Updated `StartupLayout` to check for pending redirects before navigating to the new chat page, ensuring users are directed appropriately based on their session state. - Enhanced unit tests for `useAuthRedirect` to verify correct handling of redirect parameters, including encoding of the current path and query parameters. * test: add unit tests for StartupLayout redirect behavior - Introduced a new test suite for the StartupLayout component to validate redirect logic based on authentication status and session storage. - Implemented tests to ensure correct navigation to the new conversation page when authenticated without pending redirects, and to prevent navigation when a redirect URL parameter or session storage redirect is present. - Enhanced coverage for scenarios where users are not authenticated, ensuring robust handling of redirect conditions. --------- Co-authored-by: Vamsi Konakanchi Co-authored-by: Danny Avila --- client/src/components/Auth/Login.tsx | 26 ++- client/src/hooks/AuthContext.tsx | 54 ++--- client/src/routes/Layouts/Startup.tsx | 10 +- .../routes/__tests__/StartupLayout.spec.tsx | 128 ++++++++++++ .../routes/__tests__/useAuthRedirect.spec.tsx | 80 ++++++- client/src/routes/useAuthRedirect.ts | 19 +- client/src/utils/__tests__/redirect.test.ts | 197 ++++++++++++++++++ client/src/utils/index.ts | 1 + client/src/utils/redirect.ts | 58 ++++++ 9 files changed, 529 insertions(+), 44 deletions(-) create mode 100644 client/src/routes/__tests__/StartupLayout.spec.tsx create mode 100644 client/src/utils/__tests__/redirect.test.ts create mode 100644 client/src/utils/redirect.ts diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index 48a506879f..e0bf89bacd 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -1,15 +1,19 @@ import { useEffect, useState } from 'react'; import { ErrorTypes, registerPage } from 'librechat-data-provider'; import { OpenIDIcon, useToastContext } from '@librechat/client'; -import { useOutletContext, useSearchParams } from 'react-router-dom'; +import { useOutletContext, useSearchParams, useLocation } from 'react-router-dom'; import type { TLoginLayoutContext } from '~/common'; +import { getLoginError, persistRedirectToSession } from '~/utils'; import { ErrorMessage } from '~/components/Auth/ErrorMessage'; import SocialButton from '~/components/Auth/SocialButton'; import { useAuthContext } from '~/hooks/AuthContext'; -import { getLoginError } from '~/utils'; import { useLocalize } from '~/hooks'; import LoginForm from './LoginForm'; +interface LoginLocationState { + redirect_to?: string; +} + function Login() { const localize = useLocalize(); const { showToast } = useToastContext(); @@ -17,13 +21,22 @@ function Login() { const { startupConfig } = useOutletContext(); const [searchParams, setSearchParams] = useSearchParams(); - // Determine if auto-redirect should be disabled based on the URL parameter + const location = useLocation(); const disableAutoRedirect = searchParams.get('redirect') === 'false'; - // Persist the disable flag locally so that once detected, auto-redirect stays disabled. const [isAutoRedirectDisabled, setIsAutoRedirectDisabled] = useState(disableAutoRedirect); useEffect(() => { + const redirectTo = searchParams.get('redirect_to'); + if (redirectTo) { + persistRedirectToSession(decodeURIComponent(redirectTo)); + } else { + const state = location.state as LoginLocationState | null; + if (state?.redirect_to) { + persistRedirectToSession(state.redirect_to); + } + } + const oauthError = searchParams?.get('error'); if (oauthError && oauthError === ErrorTypes.AUTH_FAILED) { showToast({ @@ -34,9 +47,8 @@ function Login() { newParams.delete('error'); setSearchParams(newParams, { replace: true }); } - }, [searchParams, setSearchParams, showToast, localize]); + }, [searchParams, setSearchParams, showToast, localize, location.state]); - // Once the disable flag is detected, update local state and remove the parameter from the URL. useEffect(() => { if (disableAutoRedirect) { setIsAutoRedirectDisabled(true); @@ -46,7 +58,6 @@ function Login() { } }, [disableAutoRedirect, searchParams, setSearchParams]); - // Determine whether we should auto-redirect to OpenID. const shouldAutoRedirect = startupConfig?.openidLoginEnabled && startupConfig?.openidAutoRedirect && @@ -60,7 +71,6 @@ function Login() { } }, [shouldAutoRedirect, startupConfig]); - // Render fallback UI if auto-redirect is active. if (shouldAutoRedirect) { return (
diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index d9d583783a..04bc3445c9 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -3,7 +3,6 @@ import { useMemo, useState, useEffect, - ReactNode, useContext, useCallback, createContext, @@ -12,6 +11,7 @@ import { debounce } from 'lodash'; import { useRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; import { setTokenHeader, SystemRoles } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; import type * as t from 'librechat-data-provider'; import { useGetRole, @@ -20,6 +20,7 @@ import { useLogoutUserMutation, useRefreshTokenMutation, } from '~/data-provider'; +import { isSafeRedirect, buildLoginRedirectUrl, getPostLoginRedirect } from '~/utils'; import { TAuthConfig, TUserContext, TAuthContext, TResError } from '~/common'; import useTimeout from './useTimeout'; import store from '~/store'; @@ -58,20 +59,22 @@ const AuthContextProvider = ({ setTokenHeader(token); setIsAuthenticated(isAuthenticated); - // Use a custom redirect if set - const finalRedirect = logoutRedirectRef.current || redirect; - // Clear the stored redirect + const searchParams = new URLSearchParams(window.location.search); + const postLoginRedirect = getPostLoginRedirect(searchParams); + + const logoutRedirect = logoutRedirectRef.current; logoutRedirectRef.current = undefined; + const finalRedirect = + logoutRedirect ?? + postLoginRedirect ?? + (redirect && isSafeRedirect(redirect) ? redirect : null); + if (finalRedirect == null) { return; } - if (finalRedirect.startsWith('http://') || finalRedirect.startsWith('https://')) { - window.location.href = finalRedirect; - } else { - navigate(finalRedirect, { replace: true }); - } + navigate(finalRedirect, { replace: true }); }, 50), [navigate, setUser], ); @@ -81,7 +84,6 @@ const AuthContextProvider = ({ onSuccess: (data: t.TLoginResponse) => { const { user, token, twoFAPending, tempToken } = data; if (twoFAPending) { - // Redirect to the two-factor authentication route. navigate(`/login/2fa?tempToken=${tempToken}`, { replace: true }); return; } @@ -91,7 +93,9 @@ const AuthContextProvider = ({ onError: (error: TResError | unknown) => { const resError = error as TResError; doSetError(resError.message); - navigate('/login', { replace: true }); + const redirectTo = new URLSearchParams(window.location.search).get('redirect_to'); + const loginPath = redirectTo ? `/login?redirect_to=${redirectTo}` : '/login'; + navigate(loginPath, { replace: true }); }, }); const logoutUser = useLogoutUserMutation({ @@ -141,30 +145,30 @@ const AuthContextProvider = ({ const { user, token = '' } = data ?? {}; if (token) { setUserContext({ token, isAuthenticated: true, user }); - } else { - console.log('Token is not present. User is not authenticated.'); - if (authConfig?.test === true) { - return; - } - navigate('/login'); + return; } + console.log('Token is not present. User is not authenticated.'); + if (authConfig?.test === true) { + return; + } + navigate(buildLoginRedirectUrl()); }, onError: (error) => { console.log('refreshToken mutation error:', error); if (authConfig?.test === true) { return; } - navigate('/login'); + navigate(buildLoginRedirectUrl()); }, }); - }, []); + }, [authConfig?.test, refreshToken, setUserContext, navigate]); useEffect(() => { if (userQuery.data) { setUser(userQuery.data); } else if (userQuery.isError) { doSetError((userQuery.error as Error).message); - navigate('/login', { replace: true }); + navigate(buildLoginRedirectUrl(), { replace: true }); } if (error != null && error && isAuthenticated) { doSetError(undefined); @@ -186,24 +190,22 @@ const AuthContextProvider = ({ ]); useEffect(() => { - const handleTokenUpdate = (event) => { + const handleTokenUpdate = (event: CustomEvent) => { console.log('tokenUpdated event received event'); - const newToken = event.detail; setUserContext({ - token: newToken, + token: event.detail, isAuthenticated: true, user: user, }); }; - window.addEventListener('tokenUpdated', handleTokenUpdate); + window.addEventListener('tokenUpdated', handleTokenUpdate as EventListener); return () => { - window.removeEventListener('tokenUpdated', handleTokenUpdate); + window.removeEventListener('tokenUpdated', handleTokenUpdate as EventListener); }; }, [setUserContext, user]); - // Make the provider update only when it should const memoedValue = useMemo( () => ({ user, diff --git a/client/src/routes/Layouts/Startup.tsx b/client/src/routes/Layouts/Startup.tsx index 9c9e0952dd..bb0e5ef254 100644 --- a/client/src/routes/Layouts/Startup.tsx +++ b/client/src/routes/Layouts/Startup.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import type { TStartupConfig } from 'librechat-data-provider'; +import { TranslationKeys, useLocalize } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; import AuthLayout from '~/components/Auth/AuthLayout'; -import { TranslationKeys, useLocalize } from '~/hooks'; +import { REDIRECT_PARAM, SESSION_KEY } from '~/utils'; const headerMap: Record = { '/login': 'com_auth_welcome_back', @@ -30,7 +31,12 @@ export default function StartupLayout({ isAuthenticated }: { isAuthenticated?: b useEffect(() => { if (isAuthenticated) { - navigate('/c/new', { replace: true }); + const hasPendingRedirect = + new URLSearchParams(window.location.search).has(REDIRECT_PARAM) || + sessionStorage.getItem(SESSION_KEY) != null; + if (!hasPendingRedirect) { + navigate('/c/new', { replace: true }); + } } if (data) { setStartupConfig(data); diff --git a/client/src/routes/__tests__/StartupLayout.spec.tsx b/client/src/routes/__tests__/StartupLayout.spec.tsx new file mode 100644 index 0000000000..8d2c183137 --- /dev/null +++ b/client/src/routes/__tests__/StartupLayout.spec.tsx @@ -0,0 +1,128 @@ +/* eslint-disable i18next/no-literal-string */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import { SESSION_KEY } from '~/utils'; +import StartupLayout from '../Layouts/Startup'; + +if (typeof Request === 'undefined') { + global.Request = class Request { + constructor( + public url: string, + public init?: RequestInit, + ) {} + } as any; +} + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: jest.fn(() => ({ + data: null, + isFetching: false, + error: null, + })), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: jest.fn(() => (key: string) => key), + TranslationKeys: {}, +})); + +jest.mock('~/components/Auth/AuthLayout', () => { + return function MockAuthLayout({ children }: { children: React.ReactNode }) { + return
{children}
; + }; +}); + +function ChildRoute() { + return
Child
; +} + +function NewConversation() { + return
New Conversation
; +} + +const createTestRouter = (initialEntry: string, isAuthenticated: boolean) => + createMemoryRouter( + [ + { + path: '/login', + element: , + children: [{ index: true, element: }], + }, + { + path: '/c/new', + element: , + }, + ], + { initialEntries: [initialEntry] }, + ); + +describe('StartupLayout — redirect race condition', () => { + const originalLocation = window.location; + + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { value: originalLocation, writable: true }); + jest.restoreAllMocks(); + }); + + it('navigates to /c/new when authenticated with no pending redirect', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true, + }); + + const router = createTestRouter('/login', true); + render(); + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/c/new'); + }); + }); + + it('does NOT navigate to /c/new when redirect_to URL param is present', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '?redirect_to=%2Fc%2Fabc123' }, + writable: true, + }); + + const router = createTestRouter('/login?redirect_to=%2Fc%2Fabc123', true); + render(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.state.location.pathname).toBe('/login'); + }); + + it('does NOT navigate to /c/new when sessionStorage redirect is present', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true, + }); + sessionStorage.setItem(SESSION_KEY, '/c/abc123'); + + const router = createTestRouter('/login', true); + render(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.state.location.pathname).toBe('/login'); + }); + + it('does NOT navigate when not authenticated', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, search: '' }, + writable: true, + }); + + const router = createTestRouter('/login', false); + render(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(router.state.location.pathname).toBe('/login'); + }); +}); diff --git a/client/src/routes/__tests__/useAuthRedirect.spec.tsx b/client/src/routes/__tests__/useAuthRedirect.spec.tsx index 19226aa29f..2f3a47c022 100644 --- a/client/src/routes/__tests__/useAuthRedirect.spec.tsx +++ b/client/src/routes/__tests__/useAuthRedirect.spec.tsx @@ -33,9 +33,8 @@ function TestComponent() { * Creates a test router with optional basename to verify navigation works correctly * with subdirectory deployments (e.g., /librechat) */ -const createTestRouter = (basename = '/') => { - // When using basename, initialEntries must include the basename - const initialEntry = basename === '/' ? '/' : `${basename}/`; +const createTestRouter = (basename = '/', initialEntry?: string) => { + const defaultEntry = basename === '/' ? '/' : `${basename}/`; return createMemoryRouter( [ @@ -47,10 +46,14 @@ const createTestRouter = (basename = '/') => { path: '/login', element:
Login Page
, }, + { + path: '/c/:id', + element: , + }, ], { basename, - initialEntries: [initialEntry], + initialEntries: [initialEntry ?? defaultEntry], }, ); }; @@ -199,4 +202,73 @@ describe('useAuthRedirect', () => { expect(testResult.isAuthenticated).toBe(true); }); }); + + it('should include redirect_to param with encoded current path when redirecting', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createTestRouter('/', '/c/abc123'); + render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/login'); + const search = router.state.location.search; + const params = new URLSearchParams(search); + const redirectTo = params.get('redirect_to'); + expect(redirectTo).not.toBeNull(); + expect(decodeURIComponent(redirectTo!)).toBe('/c/abc123'); + }, + { timeout: 1000 }, + ); + }); + + it('should encode query params and hash from the source URL', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createTestRouter('/', '/c/abc123?q=hello&submit=true#section'); + render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/login'); + const params = new URLSearchParams(router.state.location.search); + const decoded = decodeURIComponent(params.get('redirect_to')!); + expect(decoded).toBe('/c/abc123?q=hello&submit=true#section'); + }, + { timeout: 1000 }, + ); + }); + + it('should not append redirect_to when already on /login', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createMemoryRouter( + [ + { + path: '/login', + element: , + }, + ], + { initialEntries: ['/login'] }, + ); + render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/login'); + }, + { timeout: 1000 }, + ); + + expect(router.state.location.search).toBe(''); + }); }); diff --git a/client/src/routes/useAuthRedirect.ts b/client/src/routes/useAuthRedirect.ts index 86d8103384..7303952155 100644 --- a/client/src/routes/useAuthRedirect.ts +++ b/client/src/routes/useAuthRedirect.ts @@ -1,22 +1,33 @@ import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { buildLoginRedirectUrl } from '~/utils'; import { useAuthContext } from '~/hooks'; export default function useAuthRedirect() { const { user, roles, isAuthenticated } = useAuthContext(); const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { const timeout = setTimeout(() => { - if (!isAuthenticated) { - navigate('/login', { replace: true }); + if (isAuthenticated) { + return; } + + if (location.pathname.startsWith('/login')) { + navigate('/login', { replace: true }); + return; + } + + navigate(buildLoginRedirectUrl(location.pathname, location.search, location.hash), { + replace: true, + }); }, 300); return () => { clearTimeout(timeout); }; - }, [isAuthenticated, navigate]); + }, [isAuthenticated, navigate, location]); return { user, diff --git a/client/src/utils/__tests__/redirect.test.ts b/client/src/utils/__tests__/redirect.test.ts new file mode 100644 index 0000000000..36336b0d94 --- /dev/null +++ b/client/src/utils/__tests__/redirect.test.ts @@ -0,0 +1,197 @@ +import { + isSafeRedirect, + buildLoginRedirectUrl, + getPostLoginRedirect, + persistRedirectToSession, + SESSION_KEY, +} from '../redirect'; + +describe('isSafeRedirect', () => { + it('accepts a simple relative path', () => { + expect(isSafeRedirect('/c/new')).toBe(true); + }); + + it('accepts a path with query params and hash', () => { + expect(isSafeRedirect('/c/new?q=hello&submit=true#section')).toBe(true); + }); + + it('accepts a nested path', () => { + expect(isSafeRedirect('/dashboard/settings/profile')).toBe(true); + }); + + it('rejects an absolute http URL', () => { + expect(isSafeRedirect('https://evil.com')).toBe(false); + }); + + it('rejects an absolute http URL with path', () => { + expect(isSafeRedirect('https://evil.com/phishing')).toBe(false); + }); + + it('rejects a protocol-relative URL', () => { + expect(isSafeRedirect('//evil.com')).toBe(false); + }); + + it('rejects a bare domain', () => { + expect(isSafeRedirect('evil.com')).toBe(false); + }); + + it('rejects an empty string', () => { + expect(isSafeRedirect('')).toBe(false); + }); + + it('rejects /login to prevent redirect loops', () => { + expect(isSafeRedirect('/login')).toBe(false); + }); + + it('rejects /login with query params', () => { + expect(isSafeRedirect('/login?redirect_to=/c/new')).toBe(false); + }); + + it('rejects /login sub-paths', () => { + expect(isSafeRedirect('/login/2fa')).toBe(false); + }); + + it('rejects /login with hash', () => { + expect(isSafeRedirect('/login#foo')).toBe(false); + }); + + it('accepts the root path', () => { + expect(isSafeRedirect('/')).toBe(true); + }); +}); + +describe('buildLoginRedirectUrl', () => { + const originalLocation = window.location; + + beforeEach(() => { + Object.defineProperty(window, 'location', { + value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' }, + writable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { value: originalLocation, writable: true }); + }); + + it('builds a login URL from explicit args', () => { + const result = buildLoginRedirectUrl('/c/new', '?q=hello', ''); + expect(result).toBe('/login?redirect_to=%2Fc%2Fnew%3Fq%3Dhello'); + }); + + it('encodes complex paths with query and hash', () => { + const result = buildLoginRedirectUrl('/c/new', '?q=hello&submit=true', '#section'); + expect(result).toContain('redirect_to='); + const encoded = result.split('redirect_to=')[1]; + expect(decodeURIComponent(encoded)).toBe('/c/new?q=hello&submit=true#section'); + }); + + it('falls back to window.location when no args provided', () => { + const result = buildLoginRedirectUrl(); + const encoded = result.split('redirect_to=')[1]; + expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5'); + }); + + it('falls back to "/" when all location parts are empty', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '', search: '', hash: '' }, + writable: true, + }); + const result = buildLoginRedirectUrl(); + expect(result).toBe('/login?redirect_to=%2F'); + }); +}); + +describe('getPostLoginRedirect', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('returns the redirect_to param when valid', () => { + const params = new URLSearchParams('redirect_to=%2Fc%2Fnew'); + expect(getPostLoginRedirect(params)).toBe('/c/new'); + }); + + it('falls back to sessionStorage when no URL param', () => { + sessionStorage.setItem(SESSION_KEY, '/c/abc123'); + const params = new URLSearchParams(); + expect(getPostLoginRedirect(params)).toBe('/c/abc123'); + }); + + it('prefers URL param over sessionStorage', () => { + sessionStorage.setItem(SESSION_KEY, '/c/old'); + const params = new URLSearchParams('redirect_to=%2Fc%2Fnew'); + expect(getPostLoginRedirect(params)).toBe('/c/new'); + }); + + it('clears sessionStorage after reading', () => { + sessionStorage.setItem(SESSION_KEY, '/c/abc123'); + const params = new URLSearchParams(); + getPostLoginRedirect(params); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('returns null when no redirect source exists', () => { + const params = new URLSearchParams(); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects an absolute URL from params', () => { + const params = new URLSearchParams('redirect_to=https%3A%2F%2Fevil.com'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects a protocol-relative URL from params', () => { + const params = new URLSearchParams('redirect_to=%2F%2Fevil.com'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects an absolute URL from sessionStorage', () => { + sessionStorage.setItem(SESSION_KEY, 'https://evil.com'); + const params = new URLSearchParams(); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects /login redirect to prevent loops', () => { + const params = new URLSearchParams('redirect_to=%2Flogin'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('rejects /login sub-path redirect', () => { + const params = new URLSearchParams('redirect_to=%2Flogin%2F2fa'); + expect(getPostLoginRedirect(params)).toBeNull(); + }); + + it('still clears sessionStorage even when target is unsafe', () => { + sessionStorage.setItem(SESSION_KEY, 'https://evil.com'); + const params = new URLSearchParams(); + getPostLoginRedirect(params); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); +}); + +describe('persistRedirectToSession', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('stores a valid relative path', () => { + persistRedirectToSession('/c/new?q=hello'); + expect(sessionStorage.getItem(SESSION_KEY)).toBe('/c/new?q=hello'); + }); + + it('rejects an absolute URL', () => { + persistRedirectToSession('https://evil.com'); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('rejects a protocol-relative URL', () => { + persistRedirectToSession('//evil.com'); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); + + it('rejects /login paths', () => { + persistRedirectToSession('/login?redirect_to=/c/new'); + expect(sessionStorage.getItem(SESSION_KEY)).toBeNull(); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index b8117b2677..6f081c7300 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -13,6 +13,7 @@ export * from './agents'; export * from './drafts'; export * from './convos'; export * from './routes'; +export * from './redirect'; export * from './presets'; export * from './prompts'; export * from './textarea'; diff --git a/client/src/utils/redirect.ts b/client/src/utils/redirect.ts new file mode 100644 index 0000000000..d2b7588151 --- /dev/null +++ b/client/src/utils/redirect.ts @@ -0,0 +1,58 @@ +const REDIRECT_PARAM = 'redirect_to'; +const SESSION_KEY = 'post_login_redirect_to'; + +/** Validates that a redirect target is a safe relative path (not an absolute or protocol-relative URL) */ +function isSafeRedirect(url: string): boolean { + if (!url.startsWith('/') || url.startsWith('//')) { + return false; + } + const path = url.split('?')[0].split('#')[0]; + return !path.startsWith('/login'); +} + +/** Builds a `/login?redirect_to=...` URL, reading from window.location when no args are provided */ +function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string { + const p = pathname ?? window.location.pathname; + const s = search ?? window.location.search; + const h = hash ?? window.location.hash; + const currentPath = `${p}${s}${h}`; + const encoded = encodeURIComponent(currentPath || '/'); + return `/login?${REDIRECT_PARAM}=${encoded}`; +} + +/** + * Resolves the post-login redirect from URL params and sessionStorage, + * cleans up both sources, and returns the validated target (or null). + */ +function getPostLoginRedirect(searchParams: URLSearchParams): string | null { + const encoded = searchParams.get(REDIRECT_PARAM); + const urlRedirect = encoded ? decodeURIComponent(encoded) : null; + const storedRedirect = sessionStorage.getItem(SESSION_KEY); + + const target = urlRedirect ?? storedRedirect; + + if (storedRedirect) { + sessionStorage.removeItem(SESSION_KEY); + } + + if (target == null || !isSafeRedirect(target)) { + return null; + } + + return target; +} + +function persistRedirectToSession(value: string): void { + if (isSafeRedirect(value)) { + sessionStorage.setItem(SESSION_KEY, value); + } +} + +export { + SESSION_KEY, + REDIRECT_PARAM, + isSafeRedirect, + persistRedirectToSession, + buildLoginRedirectUrl, + getPostLoginRedirect, +}; From 13df8ed67c45438ba1da7b8000619c433c6e8d94 Mon Sep 17 00:00:00 2001 From: Juri Kuehn Date: Thu, 26 Feb 2026 04:31:03 +0100 Subject: [PATCH 023/110] =?UTF-8?q?=F0=9F=AA=AA=20feat:=20Add=20OPENID=5FE?= =?UTF-8?q?MAIL=5FCLAIM=20for=20Configurable=20OpenID=20User=20Identifier?= =?UTF-8?q?=20(#11699)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow setting the claim field to be used when OpenID login is configured * fix(openid): harden getOpenIdEmail and expand test coverage Guard against non-string claim values in getOpenIdEmail to prevent a TypeError crash in isEmailDomainAllowed when domain restrictions are configured. Improve warning messages to name the fallback chain explicitly and distinguish missing vs. non-string claim values. Fix the domain-block error log to record the resolved identifier rather than userinfo.email, which was misleading when OPENID_EMAIL_CLAIM resolved to a different field (e.g. upn). Fix a latent test defect in openIdJwtStrategy.spec.js where the ~/server/services/Config mock exported getCustomConfig instead of getAppConfig, the symbol actually consumed by openidStrategy.js. Add refreshController tests covering the OPENID_EMAIL_CLAIM paths, which were previously untested despite being a stated fix target. Expand JWT strategy tests with null-payload, empty/whitespace OPENID_EMAIL_CLAIM, migration-via-preferred_username, and call-order assertions for the findUser lookup sequence. * test(auth): enhance AuthController and openIdJwtStrategy tests for openidId updates Added a new test in AuthController to verify that the openidId is updated correctly when a migration is triggered during the refresh process. Expanded the openIdJwtStrategy tests to include assertions for the updateUser function, ensuring that the correct parameters are passed when a user is found with a legacy email. This improves test coverage for OpenID-related functionality. --------- Co-authored-by: Danny Avila --- .env.example | 3 + api/server/controllers/AuthController.js | 4 +- api/server/controllers/AuthController.spec.js | 166 +++++++++++++++++- api/strategies/index.js | 3 +- api/strategies/openIdJwtStrategy.js | 3 +- api/strategies/openIdJwtStrategy.spec.js | 166 +++++++++++++++++- api/strategies/openidStrategy.js | 34 +++- api/strategies/openidStrategy.spec.js | 81 ++++++++- 8 files changed, 447 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 3e94a0c63a..e19346c4bf 100644 --- a/.env.example +++ b/.env.example @@ -513,6 +513,9 @@ OPENID_ADMIN_ROLE_TOKEN_KIND= OPENID_USERNAME_CLAIM= # Set to determine which user info property returned from OpenID Provider to store as the User's name OPENID_NAME_CLAIM= +# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID) +# When not set, defaults to: email -> preferred_username -> upn +OPENID_EMAIL_CLAIM= # Optional audience parameter for OpenID authorization requests OPENID_AUDIENCE= diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 58d2427512..13d024cd03 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -18,7 +18,7 @@ const { findUser, } = require('~/models'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); -const { getOpenIdConfig } = require('~/strategies'); +const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); const registrationController = async (req, res) => { try { @@ -87,7 +87,7 @@ const refreshController = async (req, res) => { const claims = tokenset.claims(); const { user, error, migration } = await findOpenIDUser({ findUser, - email: claims.email, + email: getOpenIdEmail(claims), openidId: claims.sub, idOnTheSource: claims.oid, strategyName: 'refreshController', diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index cbf72657fb..fef670baa8 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -1,5 +1,5 @@ jest.mock('@librechat/data-schemas', () => ({ - logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn() }, + logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() }, })); jest.mock('~/server/services/GraphTokenService', () => ({ getGraphApiToken: jest.fn(), @@ -11,7 +11,8 @@ jest.mock('~/server/services/AuthService', () => ({ setAuthTokens: jest.fn(), registerUser: jest.fn(), })); -jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn() })); +jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() })); +jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() })); jest.mock('~/models', () => ({ deleteAllUserSessions: jest.fn(), getUserById: jest.fn(), @@ -24,9 +25,13 @@ jest.mock('@librechat/api', () => ({ findOpenIDUser: jest.fn(), })); -const { isEnabled } = require('@librechat/api'); +const openIdClient = require('openid-client'); +const { isEnabled, findOpenIDUser } = require('@librechat/api'); +const { graphTokenController, refreshController } = require('./AuthController'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); -const { graphTokenController } = require('./AuthController'); +const { setOpenIDAuthTokens } = require('~/server/services/AuthService'); +const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); +const { updateUser } = require('~/models'); describe('graphTokenController', () => { let req, res; @@ -142,3 +147,156 @@ describe('graphTokenController', () => { }); }); }); + +describe('refreshController – OpenID path', () => { + const mockTokenset = { + claims: jest.fn(), + access_token: 'new-access', + id_token: 'new-id', + refresh_token: 'new-refresh', + }; + + const baseClaims = { + sub: 'oidc-sub-123', + oid: 'oid-456', + email: 'user@example.com', + exp: 9999999999, + }; + + let req, res; + + beforeEach(() => { + jest.clearAllMocks(); + + isEnabled.mockReturnValue(true); + getOpenIdConfig.mockReturnValue({ some: 'config' }); + openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset); + mockTokenset.claims.mockReturnValue(baseClaims); + getOpenIdEmail.mockReturnValue(baseClaims.email); + setOpenIDAuthTokens.mockReturnValue('new-app-token'); + updateUser.mockResolvedValue({}); + + req = { + headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' }, + session: {}, + }; + + res = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + redirect: jest.fn(), + }; + }); + + it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => { + const user = { + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: false }); + + await refreshController(req, res); + + expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims); + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ email: baseClaims.email }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => { + const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' }; + mockTokenset.claims.mockReturnValue(claimsWithUpn); + getOpenIdEmail.mockReturnValue('user@corp.example.com'); + + const user = { + _id: 'user-db-id', + email: 'user@corp.example.com', + openidId: baseClaims.sub, + }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: false }); + + await refreshController(req, res); + + expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn); + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'user@corp.example.com' }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('should fall back to claims.email when configured claim is absent from token claims', async () => { + getOpenIdEmail.mockReturnValue(baseClaims.email); + + const user = { + _id: 'user-db-id', + email: baseClaims.email, + openidId: baseClaims.sub, + }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: false }); + + await refreshController(req, res); + + expect(findOpenIDUser).toHaveBeenCalledWith( + expect.objectContaining({ email: baseClaims.email }), + ); + }); + + it('should update openidId when migration is triggered on refresh', async () => { + const user = { _id: 'user-db-id', email: baseClaims.email, openidId: null }; + findOpenIDUser.mockResolvedValue({ user, error: null, migration: true }); + + await refreshController(req, res); + + expect(updateUser).toHaveBeenCalledWith( + 'user-db-id', + expect.objectContaining({ provider: 'openid', openidId: baseClaims.sub }), + ); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('should return 401 and redirect to /login when findOpenIDUser returns no user', async () => { + findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false }); + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.redirect).toHaveBeenCalledWith('/login'); + }); + + it('should return 401 and redirect when findOpenIDUser returns an error', async () => { + findOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false }); + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.redirect).toHaveBeenCalledWith('/login'); + }); + + it('should skip OpenID path when token_provider is not openid', async () => { + req.headers.cookie = 'token_provider=local; refreshToken=some-token'; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled(); + }); + + it('should skip OpenID path when OPENID_REUSE_TOKENS is disabled', async () => { + isEnabled.mockReturnValue(false); + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled(); + }); + + it('should return 200 with token not provided when refresh token is absent', async () => { + req.headers.cookie = 'token_provider=openid'; + req.session = {}; + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.send).toHaveBeenCalledWith('Refresh token not provided'); + }); +}); diff --git a/api/strategies/index.js b/api/strategies/index.js index b4f7bd3cac..9a1c58ad38 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -1,4 +1,4 @@ -const { setupOpenId, getOpenIdConfig } = require('./openidStrategy'); +const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy'); const openIdJwtLogin = require('./openIdJwtStrategy'); const facebookLogin = require('./facebookStrategy'); const discordLogin = require('./discordStrategy'); @@ -20,6 +20,7 @@ module.exports = { facebookLogin, setupOpenId, getOpenIdConfig, + getOpenIdEmail, ldapLogin, setupSaml, openIdJwtLogin, diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 997dcec397..ececf8df54 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SystemRoles } = require('librechat-data-provider'); const { isEnabled, findOpenIDUser, math } = require('@librechat/api'); +const { getOpenIdEmail } = require('./openidStrategy'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const { updateUser, findUser } = require('~/models'); @@ -53,7 +54,7 @@ const openIdJwtLogin = (openIdConfig) => { const { user, error, migration } = await findOpenIDUser({ findUser, - email: payload?.email, + email: payload ? getOpenIdEmail(payload) : undefined, openidId: payload?.sub, idOnTheSource: payload?.oid, strategyName: 'openIdJwtLogin', diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index 566afe5a90..79af848046 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -29,10 +29,21 @@ jest.mock('~/models', () => ({ findUser: jest.fn(), updateUser: jest.fn(), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({ + saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), + })), +})); +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({}), +})); +jest.mock('~/cache/getLogStores', () => + jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }), +); const { findOpenIDUser } = require('@librechat/api'); -const { updateUser } = require('~/models'); const openIdJwtLogin = require('./openIdJwtStrategy'); +const { findUser, updateUser } = require('~/models'); // Helper: build a mock openIdConfig const mockOpenIdConfig = { @@ -181,3 +192,156 @@ describe('openIdJwtStrategy – token source handling', () => { expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token); }); }); + +describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { + const payload = { + sub: 'oidc-123', + email: 'test@example.com', + preferred_username: 'testuser', + upn: 'test@corp.example.com', + exp: 9999999999, + }; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.OPENID_EMAIL_CLAIM; + + // Use real findOpenIDUser so it delegates to the findUser mock + const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser; + findOpenIDUser.mockImplementation(realFindOpenIDUser); + + findUser.mockResolvedValue(null); + updateUser.mockResolvedValue({}); + + openIdJwtLogin(mockOpenIdConfig); + }); + + afterEach(() => { + delete process.env.OPENID_EMAIL_CLAIM; + }); + + it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => { + const existingUser = { + _id: 'user-id-1', + provider: 'openid', + openidId: payload.sub, + email: payload.email, + role: SystemRoles.USER, + }; + findUser.mockImplementation(async (query) => { + if (query.$or && query.$or.some((c) => c.openidId === payload.sub)) { + return existingUser; + } + return null; + }); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith( + expect.objectContaining({ + $or: expect.arrayContaining([{ openidId: payload.sub }]), + }), + ); + }); + + it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => { + process.env.OPENID_EMAIL_CLAIM = 'upn'; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledTimes(2); + expect(findUser.mock.calls[0][0]).toMatchObject({ + $or: expect.arrayContaining([{ openidId: payload.sub }]), + }); + expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' }); + expect(user).toBe(false); + }); + + it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => { + process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim'; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: payload.email }); + expect(user).toBe(false); + }); + + it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => { + process.env.OPENID_EMAIL_CLAIM = ' upn '; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' }); + }); + + it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ''; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: payload.email }); + }); + + it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ' '; + findUser.mockResolvedValue(null); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + await invokeVerify(req, payload); + + expect(findUser).toHaveBeenCalledWith({ email: payload.email }); + }); + + it('should resolve undefined email when payload is null', async () => { + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, null); + + expect(user).toBe(false); + }); + + it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => { + const payloadNoEmail = { + sub: 'oidc-new-sub', + preferred_username: 'legacy@corp.com', + upn: 'legacy@corp.com', + exp: 9999999999, + }; + + const legacyUser = { + _id: 'legacy-db-id', + email: 'legacy@corp.com', + openidId: null, + role: SystemRoles.USER, + }; + + findUser.mockImplementation(async (query) => { + if (query.$or) { + return null; + } + if (query.email === 'legacy@corp.com') { + return legacyUser; + } + return null; + }); + + const req = { headers: { authorization: 'Bearer tok' }, session: {} }; + const { user } = await invokeVerify(req, payloadNoEmail); + + expect(findUser).toHaveBeenCalledTimes(2); + expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' }); + expect(user).toBeTruthy(); + expect(updateUser).toHaveBeenCalledWith( + 'legacy-db-id', + expect.objectContaining({ provider: 'openid', openidId: payloadNoEmail.sub }), + ); + }); +}); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 15e21f67ef..0ebdcb04e1 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -267,6 +267,34 @@ function getFullName(userinfo) { return userinfo.username || userinfo.email; } +/** + * Resolves the user identifier from OpenID claims. + * Configurable via OPENID_EMAIL_CLAIM; defaults to: email -> preferred_username -> upn. + * + * @param {Object} userinfo - The user information object from OpenID Connect + * @returns {string|undefined} The resolved identifier string + */ +function getOpenIdEmail(userinfo) { + const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim(); + if (claimKey) { + const value = userinfo[claimKey]; + if (typeof value === 'string' && value) { + return value; + } + if (value !== undefined && value !== null) { + logger.warn( + `[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`, + ); + } else { + logger.warn( + `[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`, + ); + } + } + const fallback = userinfo.email || userinfo.preferred_username || userinfo.upn; + return typeof fallback === 'string' ? fallback : undefined; +} + /** * Converts an input into a string suitable for a username. * If the input is a string, it will be returned as is. @@ -379,11 +407,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { } const appConfig = await getAppConfig(); - /** Azure AD sometimes doesn't return email, use preferred_username as fallback */ - const email = userinfo.email || userinfo.preferred_username || userinfo.upn; + const email = getOpenIdEmail(userinfo); if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { logger.error( - `[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`, + `[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`, ); throw new Error('Email domain not allowed'); } @@ -728,4 +755,5 @@ function getOpenIdConfig() { module.exports = { setupOpenId, getOpenIdConfig, + getOpenIdEmail, }; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 00c65106ad..485b77829e 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -1,6 +1,6 @@ +const undici = require('undici'); const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); -const undici = require('undici'); const { ErrorTypes } = require('librechat-data-provider'); const { findUser, createUser, updateUser } = require('~/models'); const { setupOpenId } = require('./openidStrategy'); @@ -152,6 +152,7 @@ describe('setupOpenId', () => { process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; delete process.env.OPENID_USERNAME_CLAIM; delete process.env.OPENID_NAME_CLAIM; + delete process.env.OPENID_EMAIL_CLAIM; delete process.env.PROXY; delete process.env.OPENID_USE_PKCE; @@ -1402,4 +1403,82 @@ describe('setupOpenId', () => { expect(user).toBe(false); }); }); + + describe('OPENID_EMAIL_CLAIM', () => { + it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => { + const { user } = await validate(tokenset); + expect(user.email).toBe('test@example.com'); + }); + + it('should use the configured claim when OPENID_EMAIL_CLAIM is set', async () => { + process.env.OPENID_EMAIL_CLAIM = 'upn'; + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'user@corp.example.com' }), + expect.anything(), + true, + true, + ); + }); + + it('should fall back to preferred_username when email is missing and OPENID_EMAIL_CLAIM is not set', async () => { + const userinfo = { ...tokenset.claims() }; + delete userinfo.email; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('testusername'); + }); + + it('should fall back to upn when email and preferred_username are missing and OPENID_EMAIL_CLAIM is not set', async () => { + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + delete userinfo.email; + delete userinfo.preferred_username; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + }); + + it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ''; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + }); + + it('should trim whitespace from OPENID_EMAIL_CLAIM and resolve correctly', async () => { + process.env.OPENID_EMAIL_CLAIM = ' upn '; + const userinfo = { ...tokenset.claims(), upn: 'user@corp.example.com' }; + + const { user } = await validate({ ...tokenset, claims: () => userinfo }); + + expect(user.email).toBe('user@corp.example.com'); + }); + + it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => { + process.env.OPENID_EMAIL_CLAIM = ' '; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + }); + + it('should fall back to default chain with warning when configured claim is missing from userinfo', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim'; + + const { user } = await validate(tokenset); + + expect(user.email).toBe('test@example.com'); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('OPENID_EMAIL_CLAIM="nonexistent_claim" not present in userinfo'), + ); + }); + }); }); From 3a079b980af9be3c0b3d164c6175c8a544360d73 Mon Sep 17 00:00:00 2001 From: marbence101 <72440997+marbence101@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:16:45 +0100 Subject: [PATCH 024/110] =?UTF-8?q?=F0=9F=93=8C=20fix:=20Populate=20userMe?= =?UTF-8?q?ssage.files=20Before=20First=20DB=20Save=20(#11939)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: populate userMessage.files before first DB save * fix: ESLint error fixed * fix: deduplicate file-population logic and add test coverage Extract `buildMessageFiles` helper into `packages/api/src/utils/message` to replace three near-identical loops in BaseClient and both agent controllers. Fixes set poisoning from undefined file_id entries, moves file population inside the skipSaveUserMessage guard to avoid wasted work, and adds full unit test coverage for the new behavior. * chore: reorder import statements in openIdJwtStrategy.js for consistency --------- Co-authored-by: Danny Avila --- api/app/clients/BaseClient.js | 9 ++ api/app/clients/specs/BaseClient.test.js | 119 +++++++++++++++++++++ api/server/controllers/agents/request.js | 25 ++--- api/strategies/openIdJwtStrategy.js | 2 +- packages/api/src/utils/message.spec.ts | 127 +++++++++++++++++------ packages/api/src/utils/message.ts | 24 +++++ 6 files changed, 258 insertions(+), 48 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index fab82db93b..e5771aac55 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -4,6 +4,7 @@ const { logger } = require('@librechat/data-schemas'); const { countTokens, getBalanceConfig, + buildMessageFiles, extractFileContext, encodeAndFormatAudios, encodeAndFormatVideos, @@ -670,6 +671,14 @@ class BaseClient { } if (!isEdited && !this.skipSaveUserMessage) { + const reqFiles = this.options.req?.body?.files; + if (reqFiles && Array.isArray(this.options.attachments)) { + const files = buildMessageFiles(reqFiles, this.options.attachments); + if (files.length > 0) { + userMessage.files = files; + } + delete userMessage.image_urls; + } userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user); this.savedMessageIds.add(userMessage.messageId); if (typeof opts?.getReqData === 'function') { diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index fed80de28c..15328af644 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -928,4 +928,123 @@ describe('BaseClient', () => { expect(result.remainingContextTokens).toBe(2); // 25 - 20 - 3(assistant label) }); }); + + describe('sendMessage file population', () => { + const attachment = { + file_id: 'file-abc', + filename: 'image.png', + filepath: '/uploads/image.png', + type: 'image/png', + bytes: 1024, + object: 'file', + user: 'user-1', + embedded: false, + usage: 0, + text: 'large ocr blob that should be stripped', + _id: 'mongo-id-1', + }; + + beforeEach(() => { + TestClient.options.req = { body: { files: [{ file_id: 'file-abc' }] } }; + TestClient.options.attachments = [attachment]; + }); + + test('populates userMessage.files before saveMessageToDatabase is called', async () => { + TestClient.saveMessageToDatabase = jest.fn().mockImplementation((msg) => { + return Promise.resolve({ message: msg }); + }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave).toBeDefined(); + expect(userSave[0].files).toBeDefined(); + expect(userSave[0].files).toHaveLength(1); + expect(userSave[0].files[0].file_id).toBe('file-abc'); + }); + + test('strips text and _id from files before saving', async () => { + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files[0].text).toBeUndefined(); + expect(userSave[0].files[0]._id).toBeUndefined(); + expect(userSave[0].files[0].filename).toBe('image.png'); + }); + + test('deletes image_urls from userMessage when files are present', async () => { + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + TestClient.options.attachments = [ + { ...attachment, image_urls: ['data:image/png;base64,...'] }, + ]; + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].image_urls).toBeUndefined(); + }); + + test('does not set files when no attachments match request file IDs', async () => { + TestClient.options.req = { body: { files: [{ file_id: 'file-nomatch' }] } }; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files).toBeUndefined(); + }); + + test('skips file population when attachments is not an array (Promise case)', async () => { + TestClient.options.attachments = Promise.resolve([attachment]); + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files).toBeUndefined(); + }); + + test('skips file population when skipSaveUserMessage is true', async () => { + TestClient.skipSaveUserMessage = true; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg?.isCreatedByUser, + ); + expect(userSave).toBeUndefined(); + }); + + test('ignores file_id: undefined entries in req.body.files (no set poisoning)', async () => { + TestClient.options.req = { + body: { files: [{ file_id: undefined }, { file_id: 'file-abc' }] }, + }; + TestClient.options.attachments = [ + { ...attachment, file_id: undefined }, + { ...attachment, file_id: 'file-abc' }, + ]; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Hello'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].files).toHaveLength(1); + expect(userSave[0].files[0].file_id).toBe('file-abc'); + }); + }); }); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 79387b6e89..dea5400036 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -3,9 +3,9 @@ const { Constants, ViolationTypes } = require('librechat-data-provider'); const { sendEvent, getViolationInfo, + buildMessageFiles, GenerationJobManager, decrementPendingRequest, - sanitizeFileForTransmit, sanitizeMessageForTransmit, checkAndIncrementPendingRequest, } = require('@librechat/api'); @@ -252,13 +252,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit conversation.title = conversation && !conversation.title ? null : conversation?.title || 'New Chat'; - if (req.body.files && client.options?.attachments) { - userMessage.files = []; - const messageFiles = new Set(req.body.files.map((file) => file.file_id)); - for (const attachment of client.options.attachments) { - if (messageFiles.has(attachment.file_id)) { - userMessage.files.push(sanitizeFileForTransmit(attachment)); - } + if (req.body.files && Array.isArray(client.options.attachments)) { + const files = buildMessageFiles(req.body.files, client.options.attachments); + if (files.length > 0) { + userMessage.files = files; } delete userMessage.image_urls; } @@ -639,14 +636,10 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle conversation.title = conversation && !conversation.title ? null : conversation?.title || 'New Chat'; - // Process files if needed (sanitize to remove large text fields before transmission) - if (req.body.files && client.options?.attachments) { - userMessage.files = []; - const messageFiles = new Set(req.body.files.map((file) => file.file_id)); - for (const attachment of client.options.attachments) { - if (messageFiles.has(attachment.file_id)) { - userMessage.files.push(sanitizeFileForTransmit(attachment)); - } + if (req.body.files && Array.isArray(client.options.attachments)) { + const files = buildMessageFiles(req.body.files, client.options.attachments); + if (files.length > 0) { + userMessage.files = files; } delete userMessage.image_urls; } diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index ececf8df54..83a40bf948 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -4,8 +4,8 @@ const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { SystemRoles } = require('librechat-data-provider'); const { isEnabled, findOpenIDUser, math } = require('@librechat/api'); -const { getOpenIdEmail } = require('./openidStrategy'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const { getOpenIdEmail } = require('./openidStrategy'); const { updateUser, findUser } = require('~/models'); /** diff --git a/packages/api/src/utils/message.spec.ts b/packages/api/src/utils/message.spec.ts index ba626c83fd..7fe6cf5239 100644 --- a/packages/api/src/utils/message.spec.ts +++ b/packages/api/src/utils/message.spec.ts @@ -1,5 +1,10 @@ import { Constants } from 'librechat-data-provider'; -import { sanitizeFileForTransmit, sanitizeMessageForTransmit, getThreadData } from './message'; +import { + sanitizeMessageForTransmit, + sanitizeFileForTransmit, + buildMessageFiles, + getThreadData, +} from './message'; /** Cast to string for type compatibility with ThreadMessage */ const NO_PARENT = Constants.NO_PARENT as string; @@ -125,47 +130,107 @@ describe('sanitizeMessageForTransmit', () => { }); }); +describe('buildMessageFiles', () => { + const baseAttachment = { + file_id: 'file-1', + filename: 'test.png', + filepath: '/uploads/test.png', + type: 'image/png', + bytes: 512, + object: 'file' as const, + user: 'user-1', + embedded: false, + usage: 0, + text: 'big ocr text', + _id: 'mongo-id', + }; + + it('returns sanitized files matching request file IDs', () => { + const result = buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment]); + expect(result).toHaveLength(1); + expect(result?.[0].file_id).toBe('file-1'); + expect(result?.[0]).not.toHaveProperty('text'); + expect(result?.[0]).not.toHaveProperty('_id'); + }); + + it('returns undefined when no attachments match request IDs', () => { + const result = buildMessageFiles([{ file_id: 'file-nomatch' }], [baseAttachment]); + expect(result).toEqual([]); + }); + + it('returns undefined for empty attachments array', () => { + const result = buildMessageFiles([{ file_id: 'file-1' }], []); + expect(result).toEqual([]); + }); + + it('returns undefined for empty request files array', () => { + const result = buildMessageFiles([], [baseAttachment]); + expect(result).toEqual([]); + }); + + it('filters out undefined file_id entries in request files (no set poisoning)', () => { + const undefinedAttachment = { ...baseAttachment, file_id: undefined as unknown as string }; + const result = buildMessageFiles( + [{ file_id: undefined }, { file_id: 'file-1' }], + [undefinedAttachment, baseAttachment], + ); + expect(result).toHaveLength(1); + expect(result?.[0].file_id).toBe('file-1'); + }); + + it('returns only attachments whose file_id is in the request set', () => { + const attachment2 = { ...baseAttachment, file_id: 'file-2', filename: 'b.png' }; + const result = buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment, attachment2]); + expect(result).toHaveLength(1); + expect(result?.[0].file_id).toBe('file-1'); + }); + + it('does not mutate original attachment objects', () => { + buildMessageFiles([{ file_id: 'file-1' }], [baseAttachment]); + expect(baseAttachment.text).toBe('big ocr text'); + expect(baseAttachment._id).toBe('mongo-id'); + }); +}); + describe('getThreadData', () => { - describe('edge cases - empty and null inputs', () => { - it('should return empty result for empty messages array', () => { - const result = getThreadData([], 'parent-123'); + it('should return empty result for empty messages array', () => { + const result = getThreadData([], 'parent-123'); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); + }); - it('should return empty result for null parentMessageId', () => { - const messages = [ - { messageId: 'msg-1', parentMessageId: null }, - { messageId: 'msg-2', parentMessageId: 'msg-1' }, - ]; + it('should return empty result for null parentMessageId', () => { + const messages = [ + { messageId: 'msg-1', parentMessageId: null }, + { messageId: 'msg-2', parentMessageId: 'msg-1' }, + ]; - const result = getThreadData(messages, null); + const result = getThreadData(messages, null); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); + }); - it('should return empty result for undefined parentMessageId', () => { - const messages = [{ messageId: 'msg-1', parentMessageId: null }]; + it('should return empty result for undefined parentMessageId', () => { + const messages = [{ messageId: 'msg-1', parentMessageId: null }]; - const result = getThreadData(messages, undefined); + const result = getThreadData(messages, undefined); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); + }); - it('should return empty result when parentMessageId not found in messages', () => { - const messages = [ - { messageId: 'msg-1', parentMessageId: null }, - { messageId: 'msg-2', parentMessageId: 'msg-1' }, - ]; + it('should return empty result when parentMessageId not found in messages', () => { + const messages = [ + { messageId: 'msg-1', parentMessageId: null }, + { messageId: 'msg-2', parentMessageId: 'msg-1' }, + ]; - const result = getThreadData(messages, 'non-existent'); + const result = getThreadData(messages, 'non-existent'); - expect(result.messageIds).toEqual([]); - expect(result.fileIds).toEqual([]); - }); + expect(result.messageIds).toEqual([]); + expect(result.fileIds).toEqual([]); }); describe('thread traversal', () => { diff --git a/packages/api/src/utils/message.ts b/packages/api/src/utils/message.ts index b1e939c6d7..719d04b838 100644 --- a/packages/api/src/utils/message.ts +++ b/packages/api/src/utils/message.ts @@ -1,6 +1,9 @@ import { Constants } from 'librechat-data-provider'; import type { TFile, TMessage } from 'librechat-data-provider'; +/** Minimal shape for request file entries (from `req.body.files`) */ +type RequestFile = { file_id?: string }; + /** Fields to strip from files before client transmission */ const FILE_STRIP_FIELDS = ['text', '_id', '__v'] as const; @@ -32,6 +35,27 @@ export function sanitizeFileForTransmit>( return sanitized; } +/** Filters attachments to those whose `file_id` appears in `requestFiles`, then sanitizes each. */ +export function buildMessageFiles>( + requestFiles: RequestFile[], + attachments: T[], +): Omit[] { + const requestFileIds = new Set(); + for (const f of requestFiles) { + if (f.file_id) { + requestFileIds.add(f.file_id); + } + } + + const files: Omit[] = []; + for (const attachment of attachments) { + if (attachment.file_id != null && requestFileIds.has(attachment.file_id)) { + files.push(sanitizeFileForTransmit(attachment)); + } + } + return files; +} + /** * Sanitizes a message object before transmitting to client. * Removes large fields like `fileContext` and strips `text` from embedded files. From 046e92217f5c2ad1fe1ed7708115911f85d1857c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 14:39:49 -0500 Subject: [PATCH 025/110] =?UTF-8?q?=F0=9F=A7=A9=20feat:=20OpenDocument=20F?= =?UTF-8?q?ormat=20File=20Upload=20and=20Native=20ODS=20Parsing=20(#11959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add support for OpenDocument MIME types in file configuration Updated the applicationMimeTypes regex to include support for OASIS OpenDocument formats, enhancing the file type recognition capabilities of the data provider. * feat: document processing with OpenDocument support Added support for OpenDocument Spreadsheet (ODS) MIME type in the file processing service and updated the document parser to handle ODS files. Included tests to verify correct parsing of ODS documents and updated file configuration to recognize OpenDocument formats. * refactor: Enhance document processing to support additional Excel MIME types Updated the document processing logic to utilize a regex for matching Excel MIME types, improving flexibility in handling various Excel file formats. Added tests to ensure correct parsing of new MIME types, including multiple Excel variants and OpenDocument formats. Adjusted file configuration to include these MIME types for better recognition in the file processing service. * feat: Add support for additional OpenDocument MIME types in file processing Enhanced the document processing service to support ODT, ODP, and ODG MIME types. Updated tests to verify correct routing through the OCR strategy for these new formats. Adjusted documentation to reflect changes in handled MIME types for improved clarity. --- api/server/services/Files/process.js | 10 +- api/server/services/Files/process.spec.js | 24 ++++ packages/api/src/files/documents/crud.spec.ts | 44 +++++++ packages/api/src/files/documents/crud.ts | 31 ++--- packages/api/src/files/documents/sample.ods | Bin 0 -> 8040 bytes .../data-provider/src/file-config.spec.ts | 113 ++++++++++++++++++ packages/data-provider/src/file-config.ts | 24 +++- 7 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 packages/api/src/files/documents/sample.ods diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index d69be6a00c..d01128927a 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -16,6 +16,7 @@ const { removeNullishValues, isAssistantsEndpoint, getEndpointFileConfig, + documentParserMimeTypes, } = require('librechat-data-provider'); const { EnvVar } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); @@ -559,19 +560,12 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { const fileConfig = mergeFileConfig(appConfig.fileConfig); - const documentParserMimeTypes = [ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - ]; - const shouldUseConfiguredOCR = appConfig?.ocr != null && fileConfig.checkType(file.mimetype, fileConfig.ocr?.supportedMimeTypes || []); const shouldUseDocumentParser = - !shouldUseConfiguredOCR && documentParserMimeTypes.includes(file.mimetype); + !shouldUseConfiguredOCR && documentParserMimeTypes.some((regex) => regex.test(file.mimetype)); const shouldUseOCR = shouldUseConfiguredOCR || shouldUseDocumentParser; diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index 2938391ff2..7737255a52 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -83,6 +83,10 @@ const PDF_MIME = 'application/pdf'; const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const XLS_MIME = 'application/vnd.ms-excel'; +const ODS_MIME = 'application/vnd.oasis.opendocument.spreadsheet'; +const ODT_MIME = 'application/vnd.oasis.opendocument.text'; +const ODP_MIME = 'application/vnd.oasis.opendocument.presentation'; +const ODG_MIME = 'application/vnd.oasis.opendocument.graphics'; const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({ user: { id: 'user-123' }, @@ -138,6 +142,9 @@ describe('processAgentFileUpload', () => { ['DOCX', DOCX_MIME], ['XLSX', XLSX_MIME], ['XLS', XLS_MIME], + ['ODS', ODS_MIME], + ['Excel variant (msexcel)', 'application/msexcel'], + ['Excel variant (x-msexcel)', 'application/x-msexcel'], ])('uses document_parser automatically for %s when no OCR is configured', async (_, mime) => { mergeFileConfig.mockReturnValue(makeFileConfig()); const req = makeReq({ mimetype: mime, ocrConfig: null }); @@ -229,6 +236,23 @@ describe('processAgentFileUpload', () => { expect(getStrategyFunctions).not.toHaveBeenCalled(); }); + test.each([ + ['ODT', ODT_MIME], + ['ODP', ODP_MIME], + ['ODG', ODG_MIME], + ])('routes %s through configured OCR when OCR supports the type', async (_, mime) => { + mergeFileConfig.mockReturnValue(makeFileConfig({ ocrSupportedMimeTypes: [mime] })); + const req = makeReq({ + mimetype: mime, + ocrConfig: { strategy: FileSources.mistral_ocr }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(checkCapability).toHaveBeenCalledWith(expect.anything(), AgentCapabilities.ocr); + expect(getStrategyFunctions).toHaveBeenCalledWith(FileSources.mistral_ocr); + }); + test('throws instead of falling back to parseText when document_parser fails for a document MIME type', async () => { getStrategyFunctions.mockReturnValue({ handleFileUpload: jest.fn().mockRejectedValue(new Error('No text found in document')), diff --git a/packages/api/src/files/documents/crud.spec.ts b/packages/api/src/files/documents/crud.spec.ts index 3b9e1636ef..a360b7f760 100644 --- a/packages/api/src/files/documents/crud.spec.ts +++ b/packages/api/src/files/documents/crud.spec.ts @@ -56,6 +56,50 @@ describe('Document Parser', () => { }); }); + test('parseDocument() parses text from ods', async () => { + const file = { + originalname: 'sample.ods', + path: path.join(__dirname, 'sample.ods'), + mimetype: 'application/vnd.oasis.opendocument.spreadsheet', + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document).toEqual({ + bytes: 66, + filename: 'sample.ods', + filepath: 'document_parser', + images: [], + text: 'Sheet One:\nData,on,first,sheet\nSecond Sheet:\nData,On\nSecond,Sheet\n', + }); + }); + + test.each([ + 'application/msexcel', + 'application/x-msexcel', + 'application/x-ms-excel', + 'application/x-excel', + 'application/x-dos_ms_excel', + 'application/xls', + 'application/x-xls', + ])('parseDocument() parses xls with variant MIME type: %s', async (mimetype) => { + const file = { + originalname: 'sample.xls', + path: path.join(__dirname, 'sample.xls'), + mimetype, + } as Express.Multer.File; + + const document = await parseDocument({ file }); + + expect(document).toEqual({ + bytes: 31, + filename: 'sample.xls', + filepath: 'document_parser', + images: [], + text: 'Sheet One:\nData,on,first,sheet\n', + }); + }); + test('parseDocument() throws error for unhandled document type', async () => { const file = { originalname: 'nonexistent.file', diff --git a/packages/api/src/files/documents/crud.ts b/packages/api/src/files/documents/crud.ts index f2d45644d4..94a563bc96 100644 --- a/packages/api/src/files/documents/crud.ts +++ b/packages/api/src/files/documents/crud.ts @@ -1,12 +1,13 @@ import * as fs from 'fs'; -import { FileSources } from 'librechat-data-provider'; +import { excelMimeTypes, FileSources } from 'librechat-data-provider'; import type { TextItem } from 'pdfjs-dist/types/src/display/api'; import type { MistralOCRUploadResult } from '~/types'; /** * Parses an uploaded document and extracts its text content and metadata. + * Handled types must stay in sync with `documentParserMimeTypes` from data-provider. * - * Throws an Error if it fails to parse or no text is found. + * @throws {Error} if `file.mimetype` is not handled or no text is found. */ export async function parseDocument({ file, @@ -14,19 +15,19 @@ export async function parseDocument({ file: Express.Multer.File; }): Promise { let text: string; - switch (file.mimetype) { - case 'application/pdf': - text = await pdfToText(file); - break; - case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': - text = await wordDocToText(file); - break; - case 'application/vnd.ms-excel': - case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': - text = await excelSheetToText(file); - break; - default: - throw new Error(`Unsupported file type in document parser: ${file.mimetype}`); + if (file.mimetype === 'application/pdf') { + text = await pdfToText(file); + } else if ( + file.mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) { + text = await wordDocToText(file); + } else if ( + excelMimeTypes.test(file.mimetype) || + file.mimetype === 'application/vnd.oasis.opendocument.spreadsheet' + ) { + text = await excelSheetToText(file); + } else { + throw new Error(`Unsupported file type in document parser: ${file.mimetype}`); } if (!text?.trim()) { diff --git a/packages/api/src/files/documents/sample.ods b/packages/api/src/files/documents/sample.ods new file mode 100644 index 0000000000000000000000000000000000000000..81e333dc2e76012c5ea4caaf08574a851258e0ca GIT binary patch literal 8040 zcmeGhO>f*pbPJ`WDF_aLxFA`sxPZLg?1E@kyIUbbBxuuyrbvj}#2)XO+8%4ho8A2Z zT=)fCIC4Uq=nvs1aO2bqC%9C+Hy`%c$tF%nq^cs3tsT$%nm2FW*X-SW`RZF8^LPEH zH~)I_Qm2E@+wgJs^GBb0U)att> zf_X}_VpxYCO5TRp&=RTdPBt8;Vi?5<^?(3hIVE^BPZBRD#cb^QZuPlFrTU!GFeYBH z%3(ny&yzSH1+3pc%)+jexN8gA6*;FQ6f;T-*FTdi0m}-I6g6#0WwS5nvhZ>GY=lLz zN~pMax+yKlB~UdM$HQoi9_(!Zs1LPDMy0iuhBeu%nR!2%z5nBtP6wY?!OYDH&TrW) zisFC{jh%W(Q5(0KYHQk>kTh&<>_Dn5HnLTRLg9wN*qs$cKJ@*3&XX?VQ$GxRnovAV z!r$y}g6S*RWfEtPOYp^F(OnEA!sgc2mM>>rM~hcGBa@N-irgn7DZ`Xzl#_z-$vz%| zFZZ2Zx8LgyU~f>wYI@|?kj<#3$j0t{pKSfmg&*ENV-G zMWB}cX$Kp`q})>mJQ*pOQ}rO9V>it=G5q5PobwX0V)Meoc&;W5ff)oy z%KiG#OK4PR^e`4VV2|C1B!WV;)0`v5K%B<#WJLdn%#+IV)K_=J1xZxVa6;Fgz2p4y zMyG?%O>h`i)V?^6p^5(QF3gtd8zfX8|3Wh!VJ zpD_r7T2w4MR1PJxwSKSnk#=E5Qh}k;MAJYoQ4wMdLf==@C9r_KTtoVs-Fy4NjHaXlj!y^n;tcE3fEp}WpPjymVm25Q z-wAsMIBwrsm457VnloM)K4*`|IwzkMX;Sj38ztoAIBQr;c^DoG3R^MoA?=4`!#j-W z;+DaXN#V(swB<`6gMsNRD0?Y`G3(K{P61zqYjC`rB>J)N# zL|7O#DK^|iAjF_DH_HqjJ@j$TlO2IIN!*5G>N7Gg7s5oO%?MJ z4D~zlN500ALz_%UR{x#IiV{d_aZe1NYK`)0{%gv<%GlOQX116 zwoM~4Prykj2;{qTpa^?qnoO(|ocGo^j;s0sO$i zdMMvgf&ZC4%Py086mwBrCIcBGTQR8X&f1#Vgwf}ETcA+)gpM?c7tkCYv@?gA&gyn< zYpy+waf- z#OF1r>CH_+7n;lGFX{n#_;7ni7x6G!o6q(7e!uSlgeO*z2ra!#+*0=#NMoC-IslR; z2gl?9+A3q$YCLEsIl@_Nh)OUh@YtLO5LI=y3Q7>_#=^toUKS0R&?N(Og!{5Z>qAy@ z>oau~u>+SUCK@*+!F`bY)<|bW%0zECBVrGVJFN)#a#oS@$5TjNQG#kw_VRV`Jfnyz zl*tPWZB-YhzkPY*@;s~0TKxI6hdqf)(cLZ(#*E~7)?T^z^XbkUjtj9jJRIBlnsn`9 z9r?%$yb*Z5D{#lV{^l3lpY?a_J#dpm^H#0G{}WglT{W?qw>cHo&)|kreYexZYTnmW xSpNW4Wz|i*X4|&H``|{ciQ7bM#)b;<*}L^fv3K{CYdCui{=bC_#=qag(Z9eHhcf^G literal 0 HcmV?d00001 diff --git a/packages/data-provider/src/file-config.spec.ts b/packages/data-provider/src/file-config.spec.ts index 4b9c866061..018b4dbfcf 100644 --- a/packages/data-provider/src/file-config.spec.ts +++ b/packages/data-provider/src/file-config.spec.ts @@ -3,9 +3,122 @@ import { fileConfig as baseFileConfig, getEndpointFileConfig, mergeFileConfig, + applicationMimeTypes, + defaultOCRMimeTypes, + documentParserMimeTypes, + supportedMimeTypes, } from './file-config'; import { EModelEndpoint } from './schemas'; +describe('applicationMimeTypes', () => { + const odfTypes = [ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + ]; + + it.each(odfTypes)('matches ODF type: %s', (mimeType) => { + expect(applicationMimeTypes.test(mimeType)).toBe(true); + }); + + const existingTypes = [ + 'application/pdf', + 'application/json', + 'application/csv', + 'application/msword', + 'application/xml', + 'application/zip', + 'application/epub+zip', + 'application/x-tar', + 'application/x-sh', + 'application/typescript', + 'application/sql', + 'application/yaml', + 'application/x-parquet', + 'application/vnd.apache.parquet', + 'application/vnd.coffeescript', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]; + + it.each(existingTypes)('matches existing type: %s', (mimeType) => { + expect(applicationMimeTypes.test(mimeType)).toBe(true); + }); + + const invalidTypes = [ + 'application/vnd.oasis.opendocument.text-template', + 'application/vnd.oasis.opendocument.texts', + 'application/vnd.oasis.opendocument.chart', + 'application/vnd.oasis.opendocument.formula', + 'application/vnd.oasis.opendocument.image', + 'application/vnd.oasis.opendocument.text-master', + 'text/plain', + 'image/png', + ]; + + it.each(invalidTypes)('does not match invalid type: %s', (mimeType) => { + expect(applicationMimeTypes.test(mimeType)).toBe(false); + }); +}); + +describe('defaultOCRMimeTypes', () => { + const checkOCRType = (mimeType: string): boolean => + defaultOCRMimeTypes.some((regex) => regex.test(mimeType)); + + it.each([ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + ])('matches ODF type for OCR: %s', (mimeType) => { + expect(checkOCRType(mimeType)).toBe(true); + }); +}); + +describe('supportedMimeTypes', () => { + const checkSupported = (mimeType: string): boolean => + supportedMimeTypes.some((regex) => regex.test(mimeType)); + + it.each([ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + ])('ODF type flows through supportedMimeTypes: %s', (mimeType) => { + expect(checkSupported(mimeType)).toBe(true); + }); +}); + +describe('documentParserMimeTypes', () => { + const check = (mimeType: string): boolean => + documentParserMimeTypes.some((regex) => regex.test(mimeType)); + + it.each([ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/msexcel', + 'application/x-msexcel', + 'application/x-ms-excel', + 'application/vnd.oasis.opendocument.spreadsheet', + ])('matches natively parseable type: %s', (mimeType) => { + expect(check(mimeType)).toBe(true); + }); + + it.each([ + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', + 'text/plain', + 'image/png', + ])('does not match OCR-only or unsupported type: %s', (mimeType) => { + expect(check(mimeType)).toBe(false); + }); +}); + describe('getEndpointFileConfig', () => { describe('custom endpoint lookup', () => { it('should find custom endpoint by direct lookup', () => { diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index 5a117eb760..033c868a80 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -61,6 +61,10 @@ export const fullMimeTypesList = [ 'application/xml', 'application/zip', 'application/x-parquet', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.graphics', 'image/svg', 'image/svg+xml', // Video formats @@ -179,7 +183,7 @@ export const textMimeTypes = /^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv|xml))$/; export const applicationMimeTypes = - /^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/; + /^(application\/(epub\+zip|csv|json|msword|pdf|x-tar|x-sh|typescript|sql|yaml|x-parquet|vnd\.apache\.parquet|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)|xml|zip))$/; export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/; @@ -190,10 +194,20 @@ export const videoMimeTypes = /^video\/(mp4|avi|mov|wmv|flv|webm|mkv|m4v|3gp|ogv export const defaultOCRMimeTypes = [ imageMimeTypes, + excelMimeTypes, /^application\/pdf$/, - /^application\/vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)$/, - /^application\/vnd\.ms-(word|powerpoint|excel)$/, + /^application\/vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation)$/, + /^application\/vnd\.ms-(word|powerpoint)$/, /^application\/epub\+zip$/, + /^application\/vnd\.oasis\.opendocument\.(text|spreadsheet|presentation|graphics)$/, +]; + +/** MIME types handled by the built-in document parser (pdf, docx, excel variants, ods) */ +export const documentParserMimeTypes = [ + excelMimeTypes, + /^application\/pdf$/, + /^application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document$/, + /^application\/vnd\.oasis\.opendocument\.spreadsheet$/, ]; export const defaultTextMimeTypes = [/^[\w.-]+\/[\w.-]+$/]; @@ -331,6 +345,10 @@ export const codeTypeMapping: { [key: string]: string } = { tcl: 'text/plain', // .tcl - Tcl source awk: 'text/plain', // .awk - AWK script sed: 'text/plain', // .sed - Sed script + odt: 'application/vnd.oasis.opendocument.text', // .odt - OpenDocument Text + ods: 'application/vnd.oasis.opendocument.spreadsheet', // .ods - OpenDocument Spreadsheet + odp: 'application/vnd.oasis.opendocument.presentation', // .odp - OpenDocument Presentation + odg: 'application/vnd.oasis.opendocument.graphics', // .odg - OpenDocument Graphics }; /** Maps image extensions to MIME types for formats browsers may not recognize */ From 0568f1c1ebc54d8901ad3c83c7740aae19049f69 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 16:10:14 -0500 Subject: [PATCH 026/110] =?UTF-8?q?=F0=9F=AA=83=20fix:=20Prevent=20Recursi?= =?UTF-8?q?ve=20Login=20Redirect=20Loop=20(#11964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Prevent recursive login redirect loop buildLoginRedirectUrl() would blindly encode the current URL into a redirect_to param even when already on /login, causing infinite nesting (/login?redirect_to=%2Flogin%3Fredirect_to%3D...). Guard at source so it returns plain /login when pathname starts with /login. Also validates redirect_to in the login error handler with isSafeRedirect to close an open-redirect vector, and removes a redundant /login guard from useAuthRedirect now handled by the centralized check. * 🔀 fix: Handle basename-prefixed login paths and remove double URL decoding buildLoginRedirectUrl now uses isLoginPath() which matches /login, /librechat/login, and /login/2fa — covering subdirectory deployments where window.location.pathname includes the basename prefix. Remove redundant decodeURIComponent calls on URLSearchParams.get() results (which already returns decoded values) in getPostLoginRedirect, Login.tsx, and AuthContext login error handler. The extra decode could throw URIError on inputs containing literal percent signs. * 🔀 fix: Tighten login path matching and add onError redirect tests Replace overbroad `endsWith('/login')` with a single regex `/(^|\/)login(\/|$)/` that matches `/login` only as a full path segment. Unifies `isSafeRedirect` and `buildLoginRedirectUrl` to use the same `LOGIN_PATH_RE` constant — no more divergent definitions. Add tests for the AuthContext onError redirect_to preservation logic (valid path preserved, open-redirect blocked, /login loop blocked), and a false-positive guard proving `/c/loginhistory` is not matched. Update JSDoc on `buildLoginRedirectUrl` to document the plain `/login` early-return, and add explanatory comment in AuthContext `onError` for why `buildLoginRedirectUrl()` cannot be used there. * test: Add unit tests for AuthContextProvider login error handling Introduced a new test suite for AuthContextProvider to validate the handling of login errors and the preservation of redirect parameters. The tests cover various scenarios including valid redirect preservation, open-redirect prevention, and recursive redirect prevention. This enhances the robustness of the authentication flow and ensures proper navigation behavior during login failures. --- client/src/components/Auth/Login.tsx | 2 +- client/src/hooks/AuthContext.tsx | 8 +- .../src/hooks/__tests__/AuthContext.spec.tsx | 174 ++++++++++++++++++ client/src/routes/useAuthRedirect.ts | 5 - client/src/utils/__tests__/redirect.test.ts | 69 +++++++ client/src/utils/redirect.ts | 16 +- 6 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 client/src/hooks/__tests__/AuthContext.spec.tsx diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index e0bf89bacd..7c3adf51bd 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -29,7 +29,7 @@ function Login() { useEffect(() => { const redirectTo = searchParams.get('redirect_to'); if (redirectTo) { - persistRedirectToSession(decodeURIComponent(redirectTo)); + persistRedirectToSession(redirectTo); } else { const state = location.state as LoginLocationState | null; if (state?.redirect_to) { diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 04bc3445c9..ca69a68f8b 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -93,8 +93,14 @@ const AuthContextProvider = ({ onError: (error: TResError | unknown) => { const resError = error as TResError; doSetError(resError.message); + // Preserve a valid redirect_to across login failures so the deep link survives retries. + // Cannot use buildLoginRedirectUrl() here — it reads the current pathname (already /login) + // and would return plain /login, dropping the redirect_to destination. const redirectTo = new URLSearchParams(window.location.search).get('redirect_to'); - const loginPath = redirectTo ? `/login?redirect_to=${redirectTo}` : '/login'; + const loginPath = + redirectTo && isSafeRedirect(redirectTo) + ? `/login?redirect_to=${encodeURIComponent(redirectTo)}` + : '/login'; navigate(loginPath, { replace: true }); }, }); diff --git a/client/src/hooks/__tests__/AuthContext.spec.tsx b/client/src/hooks/__tests__/AuthContext.spec.tsx new file mode 100644 index 0000000000..5a24a31ec4 --- /dev/null +++ b/client/src/hooks/__tests__/AuthContext.spec.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; + +import type { TAuthConfig } from '~/common'; + +import { AuthContextProvider, useAuthContext } from '../AuthContext'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + setTokenHeader: jest.fn(), +})); + +let mockCapturedLoginOptions: { + onSuccess: (...args: unknown[]) => void; + onError: (...args: unknown[]) => void; +}; + +jest.mock('~/data-provider', () => ({ + useLoginUserMutation: jest.fn( + (options: { + onSuccess: (...args: unknown[]) => void; + onError: (...args: unknown[]) => void; + }) => { + mockCapturedLoginOptions = options; + return { mutate: jest.fn() }; + }, + ), + useLogoutUserMutation: jest.fn(() => ({ mutate: jest.fn() })), + useRefreshTokenMutation: jest.fn(() => ({ mutate: jest.fn() })), + useGetUserQuery: jest.fn(() => ({ + data: undefined, + isError: false, + error: null, + })), + useGetRole: jest.fn(() => ({ data: null })), +})); + +const authConfig: TAuthConfig = { loginRedirect: '/login', test: true }; + +function TestConsumer() { + const ctx = useAuthContext(); + return
; +} + +function renderProvider() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + + + + + + + + + , + ); +} + +describe('AuthContextProvider — login onError redirect handling', () => { + const originalLocation = window.location; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, 'location', { + value: { ...originalLocation, pathname: '/login', search: '', hash: '' }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + it('preserves a valid redirect_to param across login failure', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc123', hash: '' }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login?redirect_to=%2Fc%2Fabc123', { + replace: true, + }); + }); + + it('drops redirect_to when it contains an absolute URL (open-redirect prevention)', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=https%3A%2F%2Fevil.com', hash: '' }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + + it('drops redirect_to when it points to /login (recursive redirect prevention)', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=%2Flogin', hash: '' }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + + it('navigates to plain /login when no redirect_to param exists', () => { + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Server error' }); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true }); + }); + + it('preserves redirect_to with query params and hash', () => { + const target = '/c/abc123?model=gpt-4#section'; + Object.defineProperty(window, 'location', { + value: { + pathname: '/login', + search: `?redirect_to=${encodeURIComponent(target)}`, + hash: '', + }, + writable: true, + configurable: true, + }); + + renderProvider(); + + act(() => { + mockCapturedLoginOptions.onError({ message: 'Invalid credentials' }); + }); + + const navigatedUrl = mockNavigate.mock.calls[0][0] as string; + const params = new URLSearchParams(navigatedUrl.split('?')[1]); + expect(decodeURIComponent(params.get('redirect_to')!)).toBe(target); + }); +}); diff --git a/client/src/routes/useAuthRedirect.ts b/client/src/routes/useAuthRedirect.ts index 7303952155..5508162543 100644 --- a/client/src/routes/useAuthRedirect.ts +++ b/client/src/routes/useAuthRedirect.ts @@ -14,11 +14,6 @@ export default function useAuthRedirect() { return; } - if (location.pathname.startsWith('/login')) { - navigate('/login', { replace: true }); - return; - } - navigate(buildLoginRedirectUrl(location.pathname, location.search, location.hash), { replace: true, }); diff --git a/client/src/utils/__tests__/redirect.test.ts b/client/src/utils/__tests__/redirect.test.ts index 36336b0d94..1d402d2025 100644 --- a/client/src/utils/__tests__/redirect.test.ts +++ b/client/src/utils/__tests__/redirect.test.ts @@ -100,6 +100,45 @@ describe('buildLoginRedirectUrl', () => { const result = buildLoginRedirectUrl(); expect(result).toBe('/login?redirect_to=%2F'); }); + + it('returns plain /login when pathname is /login (prevents recursive redirect)', () => { + const result = buildLoginRedirectUrl('/login', '?redirect_to=%2Fc%2Fnew', ''); + expect(result).toBe('/login'); + }); + + it('returns plain /login when window.location is already /login', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' }, + writable: true, + }); + const result = buildLoginRedirectUrl(); + expect(result).toBe('/login'); + }); + + it('returns plain /login for /login sub-paths', () => { + const result = buildLoginRedirectUrl('/login/2fa', '', ''); + expect(result).toBe('/login'); + }); + + it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => { + Object.defineProperty(window, 'location', { + value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' }, + writable: true, + }); + const result = buildLoginRedirectUrl(); + expect(result).toBe('/login'); + }); + + it('returns plain /login for basename-prefixed /login sub-paths', () => { + const result = buildLoginRedirectUrl('/librechat/login/2fa', '', ''); + expect(result).toBe('/login'); + }); + + it('does NOT match paths where "login" is a substring of a segment', () => { + const result = buildLoginRedirectUrl('/c/loginhistory', '', ''); + expect(result).toContain('redirect_to='); + expect(decodeURIComponent(result.split('redirect_to=')[1])).toBe('/c/loginhistory'); + }); }); describe('getPostLoginRedirect', () => { @@ -170,6 +209,36 @@ describe('getPostLoginRedirect', () => { }); }); +describe('login error redirect_to preservation (AuthContext onError pattern)', () => { + /** Mirrors the logic in AuthContext.tsx loginUser.onError */ + function buildLoginErrorPath(search: string): string { + const redirectTo = new URLSearchParams(search).get('redirect_to'); + return redirectTo && isSafeRedirect(redirectTo) + ? `/login?redirect_to=${encodeURIComponent(redirectTo)}` + : '/login'; + } + + it('preserves a valid redirect_to across login failure', () => { + const result = buildLoginErrorPath('?redirect_to=%2Fc%2Fnew'); + expect(result).toBe('/login?redirect_to=%2Fc%2Fnew'); + }); + + it('drops an open-redirect attempt (absolute URL)', () => { + const result = buildLoginErrorPath('?redirect_to=https%3A%2F%2Fevil.com'); + expect(result).toBe('/login'); + }); + + it('drops a /login redirect_to to prevent loops', () => { + const result = buildLoginErrorPath('?redirect_to=%2Flogin'); + expect(result).toBe('/login'); + }); + + it('returns plain /login when no redirect_to param exists', () => { + const result = buildLoginErrorPath(''); + expect(result).toBe('/login'); + }); +}); + describe('persistRedirectToSession', () => { beforeEach(() => { sessionStorage.clear(); diff --git a/client/src/utils/redirect.ts b/client/src/utils/redirect.ts index d2b7588151..1fb4e66d41 100644 --- a/client/src/utils/redirect.ts +++ b/client/src/utils/redirect.ts @@ -1,18 +1,27 @@ const REDIRECT_PARAM = 'redirect_to'; const SESSION_KEY = 'post_login_redirect_to'; +/** Matches `/login` as a full path segment, with optional basename prefix (e.g. `/librechat/login/2fa`) */ +const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/; + /** Validates that a redirect target is a safe relative path (not an absolute or protocol-relative URL) */ function isSafeRedirect(url: string): boolean { if (!url.startsWith('/') || url.startsWith('//')) { return false; } const path = url.split('?')[0].split('#')[0]; - return !path.startsWith('/login'); + return !LOGIN_PATH_RE.test(path); } -/** Builds a `/login?redirect_to=...` URL, reading from window.location when no args are provided */ +/** + * Builds a `/login?redirect_to=...` URL from the given or current location. + * Returns plain `/login` (no param) when already on a login route to prevent recursive nesting. + */ function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string { const p = pathname ?? window.location.pathname; + if (LOGIN_PATH_RE.test(p)) { + return '/login'; + } const s = search ?? window.location.search; const h = hash ?? window.location.hash; const currentPath = `${p}${s}${h}`; @@ -25,8 +34,7 @@ function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string * cleans up both sources, and returns the validated target (or null). */ function getPostLoginRedirect(searchParams: URLSearchParams): string | null { - const encoded = searchParams.get(REDIRECT_PARAM); - const urlRedirect = encoded ? decodeURIComponent(encoded) : null; + const urlRedirect = searchParams.get(REDIRECT_PARAM); const storedRedirect = sessionStorage.getItem(SESSION_KEY); const target = urlRedirect ?? storedRedirect; From 09d5b1a7391f2353cd257f80cfa61ca04d41dd9c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 16:10:33 -0500 Subject: [PATCH 027/110] =?UTF-8?q?=F0=9F=93=A6=20chore:=20bump=20`minimat?= =?UTF-8?q?ch`=20due=20to=20ReDoS=20vulnerability,=20bump=20`rimraf`,=20`r?= =?UTF-8?q?ollup`=20(#11963)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore: bump minimatch due to ReDoS vulnerability - Removed deprecated dependencies: @isaacs/balanced-match and @isaacs/brace-expansion. - Upgraded Rollup packages from version 4.37.0 to 4.59.0 for improved performance and stability across multiple platforms. * 🔧 chore: update Rollup version across multiple packages - Bumped Rollup dependency from various versions to 4.34.9 in package.json and package-lock.json files for improved performance and compatibility across the project. * 🔧 chore: update rimraf dependency to version 6.1.3 across multiple packages - Bumped rimraf version from 6.1.2 to 6.1.3 in package.json and package-lock.json files for improved performance and compatibility. --- package-lock.json | 502 ++++++++++++++++------------ packages/api/package.json | 4 +- packages/client/package.json | 4 +- packages/data-provider/package.json | 4 +- packages/data-schemas/package.json | 4 +- 5 files changed, 298 insertions(+), 220 deletions(-) diff --git a/package-lock.json b/package-lock.json index bbb379c4d4..1ad97628a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10439,29 +10439,6 @@ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -18697,9 +18674,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz", - "integrity": "sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -18711,9 +18688,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.37.0.tgz", - "integrity": "sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -18725,9 +18702,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.37.0.tgz", - "integrity": "sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -18739,9 +18716,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.37.0.tgz", - "integrity": "sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -18753,9 +18730,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.37.0.tgz", - "integrity": "sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -18767,9 +18744,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.37.0.tgz", - "integrity": "sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -18781,9 +18758,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.37.0.tgz", - "integrity": "sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -18795,9 +18772,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.37.0.tgz", - "integrity": "sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -18809,9 +18786,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.37.0.tgz", - "integrity": "sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -18823,9 +18800,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.37.0.tgz", - "integrity": "sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -18836,10 +18813,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.37.0.tgz", - "integrity": "sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -18850,10 +18827,38 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.37.0.tgz", - "integrity": "sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -18865,9 +18870,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.37.0.tgz", - "integrity": "sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -18879,9 +18884,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.37.0.tgz", - "integrity": "sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -18893,9 +18898,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.37.0.tgz", - "integrity": "sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -18907,9 +18912,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.37.0.tgz", - "integrity": "sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -18921,9 +18926,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.37.0.tgz", - "integrity": "sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -18934,10 +18939,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.37.0.tgz", - "integrity": "sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -18949,9 +18982,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.37.0.tgz", - "integrity": "sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -18962,10 +18995,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.37.0.tgz", - "integrity": "sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -20679,9 +20726,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -21233,13 +21280,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -21749,9 +21796,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -22074,9 +22121,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -22589,9 +22636,9 @@ "license": "MIT" }, "node_modules/bn.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", - "integrity": "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true, "license": "MIT" }, @@ -23876,9 +23923,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -25292,9 +25339,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -25519,9 +25566,9 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz", - "integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -27150,10 +27197,11 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -27775,18 +27823,18 @@ } }, "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -27803,36 +27851,59 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "ISC", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -27840,7 +27911,7 @@ "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -28351,9 +28422,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -30742,13 +30813,13 @@ } }, "node_modules/jest/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -33711,9 +33782,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -33814,9 +33885,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -37336,9 +37408,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "dev": true, "license": "MIT" }, @@ -37359,9 +37431,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -39087,12 +39159,12 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -39168,13 +39240,13 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.37.0.tgz", - "integrity": "sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -39184,26 +39256,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.37.0", - "@rollup/rollup-android-arm64": "4.37.0", - "@rollup/rollup-darwin-arm64": "4.37.0", - "@rollup/rollup-darwin-x64": "4.37.0", - "@rollup/rollup-freebsd-arm64": "4.37.0", - "@rollup/rollup-freebsd-x64": "4.37.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.37.0", - "@rollup/rollup-linux-arm-musleabihf": "4.37.0", - "@rollup/rollup-linux-arm64-gnu": "4.37.0", - "@rollup/rollup-linux-arm64-musl": "4.37.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.37.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.37.0", - "@rollup/rollup-linux-riscv64-gnu": "4.37.0", - "@rollup/rollup-linux-riscv64-musl": "4.37.0", - "@rollup/rollup-linux-s390x-gnu": "4.37.0", - "@rollup/rollup-linux-x64-gnu": "4.37.0", - "@rollup/rollup-linux-x64-musl": "4.37.0", - "@rollup/rollup-win32-arm64-msvc": "4.37.0", - "@rollup/rollup-win32-ia32-msvc": "4.37.0", - "@rollup/rollup-win32-x64-msvc": "4.37.0", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -40557,12 +40634,12 @@ } }, "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -43097,10 +43174,11 @@ } }, "node_modules/workbox-build/node_modules/rollup": { - "version": "2.79.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", - "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -43702,8 +43780,8 @@ "mammoth": "^1.11.0", "mongodb": "^6.14.2", "pdfjs-dist": "^5.4.624", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "ts-node": "^10.9.2", "typescript": "^5.0.4", @@ -43769,13 +43847,13 @@ } }, "packages/api/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { @@ -43820,8 +43898,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.4.0", - "rimraf": "^6.1.2", - "rollup": "^4.0.0", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript2": "^0.35.0", @@ -45943,13 +46021,13 @@ } }, "packages/client/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { @@ -46106,8 +46184,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "openapi-types": "^12.1.3", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "typescript": "^5.0.4" @@ -46117,13 +46195,13 @@ } }, "packages/data-provider/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { @@ -46154,8 +46232,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "mongodb-memory-server": "^10.1.4", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "ts-node": "^10.9.2", @@ -46200,13 +46278,13 @@ } }, "packages/data-schemas/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "glob": "^13.0.0", + "glob": "^13.0.3", "package-json-from-dist": "^1.0.1" }, "bin": { diff --git a/packages/api/package.json b/packages/api/package.json index 1854457b42..903e15947b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -70,8 +70,8 @@ "mammoth": "^1.11.0", "mongodb": "^6.14.2", "pdfjs-dist": "^5.4.624", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "ts-node": "^10.9.2", "typescript": "^5.0.4", diff --git a/packages/client/package.json b/packages/client/package.json index 118186c9a9..98a1cd7e3c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -104,8 +104,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^15.4.0", - "rimraf": "^6.1.2", - "rollup": "^4.0.0", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript2": "^0.35.0", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 5d6c6b8e46..d8cbe63f8b 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -62,8 +62,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "openapi-types": "^12.1.3", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "typescript": "^5.0.4" diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 0c401c5a24..57c1d21234 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -50,8 +50,8 @@ "jest": "^30.2.0", "jest-junit": "^16.0.0", "mongodb-memory-server": "^10.1.4", - "rimraf": "^6.1.2", - "rollup": "^4.22.4", + "rimraf": "^6.1.3", + "rollup": "^4.34.9", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-typescript2": "^0.35.0", "ts-node": "^10.9.2", From b01f3ccada1243c76c390ce880485cd906bcf6ba Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 26 Feb 2026 16:43:24 -0500 Subject: [PATCH 028/110] =?UTF-8?q?=F0=9F=A7=A9=20fix:=20Redirect=20Stabil?= =?UTF-8?q?ity=20and=20Build=20Chunking=20(#11965)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 chore: Update Vite configuration to include additional package checks - Enhanced the Vite configuration to recognize 'dnd-core' and 'flip-toolkit' alongside existing checks for 'react-dnd' and 'react-flip-toolkit' for improved handling of React interactions. - Updated the markdown highlighting logic to also include 'lowlight' in addition to 'highlight.js' for better syntax highlighting support. * 🔧 fix: Update AuthContextProvider to prevent infinite re-fire of useEffect - Modified the dependency array of the useEffect hook in AuthContextProvider to an empty array, preventing unnecessary re-executions and potential infinite loops. Added an ESLint comment to clarify the decision regarding stable dependencies at mount. * chore: import order --- client/src/hooks/AuthContext.tsx | 5 +++-- client/vite.config.ts | 23 +++++++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index ca69a68f8b..86f80cde6b 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -11,8 +11,8 @@ import { debounce } from 'lodash'; import { useRecoilState } from 'recoil'; import { useNavigate } from 'react-router-dom'; import { setTokenHeader, SystemRoles } from 'librechat-data-provider'; -import type { ReactNode } from 'react'; import type * as t from 'librechat-data-provider'; +import type { ReactNode } from 'react'; import { useGetRole, useGetUserQuery, @@ -167,7 +167,8 @@ const AuthContextProvider = ({ navigate(buildLoginRedirectUrl()); }, }); - }, [authConfig?.test, refreshToken, setUserContext, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- deps are stable at mount; adding refreshToken causes infinite re-fire + }, []); useEffect(() => { if (userQuery.data) { diff --git a/client/vite.config.ts b/client/vite.config.ts index b3f6541ab3..a185215837 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -8,15 +8,18 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ -const backendPort = process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT) || 3080; -const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`; +const backendPort = (process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT)) || 3080; +const backendURL = process.env.HOST + ? `http://${process.env.HOST}:${backendPort}` + : `http://localhost:${backendPort}`; export default defineConfig(({ command }) => ({ base: '', server: { - allowedHosts: process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',') || [], + allowedHosts: + (process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',')) || [], host: process.env.HOST || 'localhost', - port: process.env.PORT && Number(process.env.PORT) || 3090, + port: (process.env.PORT && Number(process.env.PORT)) || 3090, strictPort: false, proxy: { '/api': { @@ -143,7 +146,12 @@ export default defineConfig(({ command }) => ({ if (normalizedId.includes('@dicebear')) { return 'avatars'; } - if (normalizedId.includes('react-dnd') || normalizedId.includes('react-flip-toolkit')) { + if ( + normalizedId.includes('react-dnd') || + normalizedId.includes('dnd-core') || + normalizedId.includes('react-flip-toolkit') || + normalizedId.includes('flip-toolkit') + ) { return 'react-interactions'; } if (normalizedId.includes('react-hook-form')) { @@ -219,7 +227,10 @@ export default defineConfig(({ command }) => ({ if (normalizedId.includes('framer-motion')) { return 'framer-motion'; } - if (normalizedId.includes('node_modules/highlight.js')) { + if ( + normalizedId.includes('node_modules/highlight.js') || + normalizedId.includes('node_modules/lowlight') + ) { return 'markdown_highlight'; } if (normalizedId.includes('katex') || normalizedId.includes('node_modules/katex')) { From a17a38b8ede3ed5aab55626a78b32344199e44f3 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:24:02 +0100 Subject: [PATCH 029/110] =?UTF-8?q?=F0=9F=9A=85=20docs:=20update=20Railway?= =?UTF-8?q?=20template=20link=20(#11966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update railway template link * Fix link for Deploy on Railway button in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6e04396637..e82b3ebc2c 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@

- - Deploy on Railway + + Deploy on Railway Deploy on Zeabur From 6169d4f70b44e4cdc913ac4ad28e2b264804f49d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 27 Feb 2026 22:49:54 -0500 Subject: [PATCH 030/110] =?UTF-8?q?=F0=9F=9A=A6=20fix:=20404=20JSON=20Resp?= =?UTF-8?q?onses=20for=20Unmatched=20API=20Routes=20(#11976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Implement 404 JSON response for unmatched API routes - Added middleware to return a 404 JSON response with a message for undefined API routes. - Updated SPA fallback to serve index.html for non-API unmatched routes. - Ensured the error handler is positioned correctly as the last middleware in the stack. * fix: Enhance logging in BaseClient for better token usage tracking - Updated `getTokenCountForResponse` to log the messageId of the response for improved debugging. - Enhanced userMessage logging to include messageId, tokenCount, and conversationId for clearer context during token count mapping. * chore: Improve logging in processAddedConvo for better debugging - Updated the logging structure in the processAddedConvo function to provide clearer context when processing added conversations. - Removed redundant logging and enhanced the output to include model, agent ID, and endpoint details for improved traceability. * chore: Enhance logging in BaseClient for improved token usage tracking - Added debug logging in the BaseClient to track response token usage, including messageId, model, promptTokens, and completionTokens for better debugging and traceability. * chore: Enhance logging in MemoryAgent for improved context - Updated logging in the MemoryAgent to include userId, conversationId, messageId, and provider details for better traceability during memory processing. - Adjusted log messages to provide clearer context when content is returned or not, aiding in debugging efforts. * chore: Refactor logging in initializeClient for improved clarity - Consolidated multiple debug log statements into a single message that provides a comprehensive overview of the tool context being stored for the primary agent, including the number of tools and the size of the tool registry. This enhances traceability and debugging efficiency. * feat: Implement centralized 404 handling for unmatched API routes - Introduced a new middleware function `apiNotFound` to standardize 404 JSON responses for undefined API routes. - Updated the server configuration to utilize the new middleware, enhancing code clarity and maintainability. - Added tests to ensure correct 404 responses for various non-GET methods and the `/api` root path. * fix: Enhance logging in apiNotFound middleware for improved safety - Updated the `apiNotFound` function to sanitize the request path by replacing problematic characters and limiting its length, ensuring safer logging of 404 errors. * refactor: Move apiNotFound middleware to a separate file for better organization - Extracted the `apiNotFound` function from the error middleware into its own file, enhancing code organization and maintainability. - Updated the index file to export the new `notFound` middleware, ensuring it is included in the middleware stack. * docs: Add comment to clarify usage of unsafeChars regex in notFound middleware - Included a comment in the notFound middleware file to explain that the unsafeChars regex is safe to reuse with .replace() at the module scope, as it does not retain lastIndex state. --- api/app/clients/BaseClient.js | 18 ++++++++-- api/server/experimental.js | 10 ++++-- api/server/index.js | 8 ++++- api/server/index.spec.js | 34 +++++++++++++++++++ .../services/Endpoints/agents/addedConvo.js | 12 +++---- .../services/Endpoints/agents/initialize.js | 8 +---- packages/api/src/agents/memory.ts | 14 ++++++-- packages/api/src/middleware/index.ts | 1 + packages/api/src/middleware/notFound.ts | 12 +++++++ 9 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 packages/api/src/middleware/notFound.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index e5771aac55..85963aec58 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -124,7 +124,9 @@ class BaseClient { * @returns {number} */ getTokenCountForResponse(responseMessage) { - logger.debug('[BaseClient] `recordTokenUsage` not implemented.', responseMessage); + logger.debug('[BaseClient] `recordTokenUsage` not implemented.', { + messageId: responseMessage?.messageId, + }); } /** @@ -661,10 +663,13 @@ class BaseClient { ); if (tokenCountMap) { - logger.debug('[BaseClient] tokenCountMap', tokenCountMap); if (tokenCountMap[userMessage.messageId]) { userMessage.tokenCount = tokenCountMap[userMessage.messageId]; - logger.debug('[BaseClient] userMessage', userMessage); + logger.debug('[BaseClient] userMessage', { + messageId: userMessage.messageId, + tokenCount: userMessage.tokenCount, + conversationId: userMessage.conversationId, + }); } this.handleTokenCountMap(tokenCountMap); @@ -793,6 +798,13 @@ class BaseClient { model: responseMessage.model, }); } + + logger.debug('[BaseClient] Response token usage', { + messageId: responseMessage.messageId, + model: responseMessage.model, + promptTokens, + completionTokens, + }); } if (userMessagePromise) { diff --git a/api/server/experimental.js b/api/server/experimental.js index 4a457abf61..7b60ad7fd2 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -14,6 +14,7 @@ const { logger } = require('@librechat/data-schemas'); const mongoSanitize = require('express-mongo-sanitize'); const { isEnabled, + apiNotFound, ErrorController, performStartupChecks, handleJsonParseError, @@ -297,6 +298,7 @@ if (cluster.isMaster) { /** Routes */ app.use('/oauth', routes.oauth); app.use('/api/auth', routes.auth); + app.use('/api/admin', routes.adminAuth); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/api-keys', routes.apiKeys); @@ -310,7 +312,6 @@ if (cluster.isMaster) { app.use('/api/endpoints', routes.endpoints); app.use('/api/balance', routes.balance); app.use('/api/models', routes.models); - app.use('/api/plugins', routes.plugins); app.use('/api/config', routes.config); app.use('/api/assistants', routes.assistants); app.use('/api/files', await routes.files.initialize()); @@ -324,8 +325,8 @@ if (cluster.isMaster) { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); - /** Error handler */ - app.use(ErrorController); + /** 404 for unmatched API routes */ + app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ app.use((req, res) => { @@ -343,6 +344,9 @@ if (cluster.isMaster) { res.send(updatedIndexHtml); }); + /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ + app.use(ErrorController); + /** Start listening on shared port (cluster will distribute connections) */ app.listen(port, host, async (err) => { if (err) { diff --git a/api/server/index.js b/api/server/index.js index 2aff26ceaf..f034f10236 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -12,6 +12,7 @@ const { logger } = require('@librechat/data-schemas'); const mongoSanitize = require('express-mongo-sanitize'); const { isEnabled, + apiNotFound, ErrorController, memoryDiagnostics, performStartupChecks, @@ -163,8 +164,10 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); - app.use(ErrorController); + /** 404 for unmatched API routes */ + app.use('/api', apiNotFound); + /** SPA fallback - serve index.html for all unmatched routes */ app.use((req, res) => { res.set({ 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', @@ -180,6 +183,9 @@ const startServer = async () => { res.send(updatedIndexHtml); }); + /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ + app.use(ErrorController); + app.listen(port, host, async (err) => { if (err) { logger.error('Failed to start server:', err); diff --git a/api/server/index.spec.js b/api/server/index.spec.js index c73c605518..7b3d062fce 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -100,6 +100,40 @@ describe('Server Configuration', () => { expect(response.headers['expires']).toBe('0'); }); + it('should return 404 JSON for undefined API routes', async () => { + const response = await request(app).get('/api/nonexistent'); + expect(response.status).toBe(404); + expect(response.body).toEqual({ message: 'Endpoint not found' }); + }); + + it('should return 404 JSON for nested undefined API routes', async () => { + const response = await request(app).get('/api/nonexistent/nested/path'); + expect(response.status).toBe(404); + expect(response.body).toEqual({ message: 'Endpoint not found' }); + }); + + it('should return 404 JSON for non-GET methods on undefined API routes', async () => { + const post = await request(app).post('/api/nonexistent'); + expect(post.status).toBe(404); + expect(post.body).toEqual({ message: 'Endpoint not found' }); + + const del = await request(app).delete('/api/nonexistent'); + expect(del.status).toBe(404); + expect(del.body).toEqual({ message: 'Endpoint not found' }); + }); + + it('should return 404 JSON for the /api root path', async () => { + const response = await request(app).get('/api'); + expect(response.status).toBe(404); + expect(response.body).toEqual({ message: 'Endpoint not found' }); + }); + + it('should serve SPA HTML for non-API unmatched routes', async () => { + const response = await request(app).get('/this/does/not/exist'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toMatch(/html/); + }); + it('should return 500 for unknown errors via ErrorController', async () => { // Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index 7e9385267a..25b1327991 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -55,16 +55,16 @@ const processAddedConvo = async ({ userMCPAuthMap, }) => { const addedConvo = endpointOption.addedConvo; - logger.debug('[processAddedConvo] Called with addedConvo:', { - hasAddedConvo: addedConvo != null, - addedConvoEndpoint: addedConvo?.endpoint, - addedConvoModel: addedConvo?.model, - addedConvoAgentId: addedConvo?.agent_id, - }); if (addedConvo == null) { return { userMCPAuthMap }; } + logger.debug('[processAddedConvo] Processing added conversation', { + model: addedConvo.model, + agentId: addedConvo.agent_id, + endpoint: addedConvo.endpoint, + }); + try { const addedAgent = await loadAddedAgent({ req, conversation: addedConvo, primaryAgent }); if (!addedAgent) { diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 0888f23cd5..fd2e42511d 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -204,13 +204,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { ); logger.debug( - `[initializeClient] Tool definitions for primary agent: ${primaryConfig.toolDefinitions?.length ?? 0}`, - ); - - /** Store primary agent's tool context for ON_TOOL_EXECUTE callback */ - logger.debug(`[initializeClient] Storing tool context for agentId: ${primaryConfig.id}`); - logger.debug( - `[initializeClient] toolRegistry size: ${primaryConfig.toolRegistry?.size ?? 'undefined'}`, + `[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`, ); agentToolContexts.set(primaryConfig.id, { agent: primaryAgent, diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index bb4bd38282..b8f65a9772 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -475,13 +475,21 @@ ${memory ?? 'No existing memories'}`; }; const content = await run.processStream(inputs, config); if (content) { - logger.debug('Memory Agent processed memory successfully', content); + logger.debug('[MemoryAgent] Processed successfully', { + userId, + conversationId, + messageId, + provider: llmConfig?.provider, + }); } else { - logger.warn('Memory Agent processed memory but returned no content'); + logger.debug('[MemoryAgent] Returned no content', { userId, conversationId, messageId }); } return await Promise.all(artifactPromises); } catch (error) { - logger.error('Memory Agent failed to process memory', error); + logger.error( + `[MemoryAgent] Failed to process memory | userId: ${userId} | conversationId: ${conversationId} | messageId: ${messageId}`, + { error }, + ); } } diff --git a/packages/api/src/middleware/index.ts b/packages/api/src/middleware/index.ts index a208923a49..1f0cbc16fb 100644 --- a/packages/api/src/middleware/index.ts +++ b/packages/api/src/middleware/index.ts @@ -1,6 +1,7 @@ export * from './access'; export * from './admin'; export * from './error'; +export * from './notFound'; export * from './balance'; export * from './json'; export * from './concurrency'; diff --git a/packages/api/src/middleware/notFound.ts b/packages/api/src/middleware/notFound.ts new file mode 100644 index 0000000000..1eac18091f --- /dev/null +++ b/packages/api/src/middleware/notFound.ts @@ -0,0 +1,12 @@ +import { logger } from '@librechat/data-schemas'; +import type { Request, Response } from 'express'; + +/** Safe to reuse with .replace() at module scope - does not retain lastIndex state */ +// eslint-disable-next-line no-control-regex +const unsafeChars = /[\r\n\u0000]/g; + +export function apiNotFound(req: Request, res: Response): void { + const safePath = req.path.replace(unsafeChars, '_').slice(0, 200); + logger.debug(`[API 404] ${req.method} ${safePath}`); + res.status(404).json({ message: 'Endpoint not found' }); +} From 8b159079f5bbb04ef6f301f5c93c78cd8e0b32be Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 27 Feb 2026 23:50:13 -0500 Subject: [PATCH 031/110] =?UTF-8?q?=F0=9F=AA=99=20feat:=20Add=20`messageId?= =?UTF-8?q?`=20to=20Transactions=20(#11987)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add messageId to transactions * chore: field order * feat: Enhance token usage tracking by adding messageId parameter - Updated `recordTokenUsage` method in BaseClient to accept a new `messageId` parameter for improved tracking. - Propagated `messageId` in the AgentClient when recording usage. - Added tests to ensure `messageId` is correctly passed and handled in various scenarios, including propagation across multiple usage entries. * chore: Correct field order in createGeminiImageTool function - Moved the conversationId field to the correct position in the object being passed to the recordTokenUsage method, ensuring proper parameter alignment for improved functionality. * refactor: Update OpenAIChatCompletionController and createResponse to use responseId instead of requestId - Replaced instances of requestId with responseId in the OpenAIChatCompletionController for improved clarity in logging and tracking. - Updated createResponse to include responseId in the requestBody, ensuring consistency across the handling of message identifiers. * test: Add messageId to agent client tests - Included messageId in the agent client tests to ensure proper handling and propagation of message identifiers during transaction recording. - This update enhances the test coverage for scenarios involving messageId, aligning with recent changes in the tracking of message identifiers. * fix: Update OpenAIChatCompletionController to use requestId for context - Changed the context object in OpenAIChatCompletionController to use `requestId` instead of `responseId` for improved clarity and consistency in handling request identifiers. * chore: field order --- api/app/clients/BaseClient.js | 5 +- .../tools/structured/GeminiImageGen.js | 8 +- api/server/controllers/agents/client.js | 4 + api/server/controllers/agents/client.test.js | 1 + api/server/controllers/agents/openai.js | 24 +++-- api/server/controllers/agents/responses.js | 10 ++ api/server/middleware/abortMiddleware.js | 11 ++- packages/api/src/agents/usage.spec.ts | 91 +++++++++++++++++++ packages/api/src/agents/usage.ts | 6 +- .../data-schemas/src/schema/transaction.ts | 2 + 10 files changed, 149 insertions(+), 13 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 85963aec58..2d008b9991 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -137,12 +137,14 @@ class BaseClient { * @param {AppConfig['balance']} [balance] * @param {number} promptTokens * @param {number} completionTokens + * @param {string} [messageId] * @returns {Promise} */ - async recordTokenUsage({ model, balance, promptTokens, completionTokens }) { + async recordTokenUsage({ model, balance, promptTokens, completionTokens, messageId }) { logger.debug('[BaseClient] `recordTokenUsage` not implemented.', { model, balance, + messageId, promptTokens, completionTokens, }); @@ -796,6 +798,7 @@ class BaseClient { completionTokens, balance: balanceConfig, model: responseMessage.model, + messageId: this.responseMessageId, }); } diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index b201db019d..0bd1e302ed 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -251,8 +251,9 @@ function checkForSafetyBlock(response) { * @param {string} params.userId - The user ID * @param {string} params.conversationId - The conversation ID * @param {string} params.model - The model name + * @param {string} [params.messageId] - The response message ID for transaction correlation */ -async function recordTokenUsage({ usageMetadata, req, userId, conversationId, model }) { +async function recordTokenUsage({ usageMetadata, req, userId, conversationId, model, messageId }) { if (!usageMetadata) { logger.debug('[GeminiImageGen] No usage metadata available for balance tracking'); return; @@ -288,6 +289,7 @@ async function recordTokenUsage({ usageMetadata, req, userId, conversationId, mo { user: userId, model, + messageId, conversationId, context: 'image_generation', balance, @@ -445,10 +447,14 @@ function createGeminiImageTool(fields = {}) { ]; const conversationId = runnableConfig?.configurable?.thread_id; + const messageId = + runnableConfig?.configurable?.run_id ?? + runnableConfig?.configurable?.requestBody?.messageId; recordTokenUsage({ usageMetadata: apiResponse.usageMetadata, req, userId, + messageId, conversationId, model: geminiModel, }).catch((error) => { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 7aea6d1e8f..d69281d49c 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -663,6 +663,7 @@ class AgentClient extends BaseClient { context, balance, transactions, + messageId: this.responseMessageId, conversationId: this.conversationId, user: this.user ?? this.options.req.user?.id, endpointTokenConfig: this.options.endpointTokenConfig, @@ -1148,6 +1149,7 @@ class AgentClient extends BaseClient { model: clientOptions.model, balance: balanceConfig, transactions: transactionsConfig, + messageId: this.responseMessageId, }).catch((err) => { logger.error( '[api/server/controllers/agents/client.js #titleConvo] Error recording collected usage', @@ -1186,6 +1188,7 @@ class AgentClient extends BaseClient { model, context, balance, + messageId: this.responseMessageId, conversationId: this.conversationId, user: this.user ?? this.options.req.user?.id, endpointTokenConfig: this.options.endpointTokenConfig, @@ -1204,6 +1207,7 @@ class AgentClient extends BaseClient { model, balance, context: 'reasoning', + messageId: this.responseMessageId, conversationId: this.conversationId, user: this.user ?? this.options.req.user?.id, endpointTokenConfig: this.options.endpointTokenConfig, diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 9dd3567047..42481e1644 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -263,6 +263,7 @@ describe('AgentClient - titleConvo', () => { transactions: { enabled: true, }, + messageId: 'response-123', }); }); diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index b334580eb1..a083bd9291 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -129,7 +129,6 @@ const OpenAIChatCompletionController = async (req, res) => { const appConfig = req.config; const requestStartTime = Date.now(); - // Validate request const validation = validateRequest(req.body); if (isChatCompletionValidationFailure(validation)) { return sendErrorResponse(res, 400, validation.error); @@ -150,20 +149,20 @@ const OpenAIChatCompletionController = async (req, res) => { ); } - // Generate IDs - const requestId = `chatcmpl-${nanoid()}`; + const responseId = `chatcmpl-${nanoid()}`; const conversationId = request.conversation_id ?? nanoid(); const parentMessageId = request.parent_message_id ?? null; const created = Math.floor(Date.now() / 1000); + /** @type {import('@librechat/api').OpenAIResponseContext} — key must be `requestId` to match the type used by createChunk/buildNonStreamingResponse */ const context = { created, - requestId, + requestId: responseId, model: agentId, }; logger.debug( - `[OpenAI API] Request ${requestId} started for agent ${agentId}, stream: ${request.stream}`, + `[OpenAI API] Response ${responseId} started for agent ${agentId}, stream: ${request.stream}`, ); // Set up abort controller @@ -450,11 +449,11 @@ const OpenAIChatCompletionController = async (req, res) => { agents: [primaryConfig], messages: formattedMessages, indexTokenCountMap, - runId: requestId, + runId: responseId, signal: abortController.signal, customHandlers: handlers, requestBody: { - messageId: requestId, + messageId: responseId, conversationId, }, user: { id: userId }, @@ -471,6 +470,10 @@ const OpenAIChatCompletionController = async (req, res) => { thread_id: conversationId, user_id: userId, user: createSafeUser(req.user), + requestBody: { + messageId: responseId, + conversationId, + }, ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, @@ -496,6 +499,7 @@ const OpenAIChatCompletionController = async (req, res) => { conversationId, collectedUsage, context: 'message', + messageId: responseId, balance: balanceConfig, transactions: transactionsConfig, model: primaryConfig.model || agent.model_parameters?.model, @@ -509,7 +513,7 @@ const OpenAIChatCompletionController = async (req, res) => { if (isStreaming) { sendFinalChunk(handlerConfig); res.end(); - logger.debug(`[OpenAI API] Request ${requestId} completed in ${duration}ms (streaming)`); + logger.debug(`[OpenAI API] Response ${responseId} completed in ${duration}ms (streaming)`); // Wait for artifact processing after response ends (non-blocking) if (artifactPromises.length > 0) { @@ -548,7 +552,9 @@ const OpenAIChatCompletionController = async (req, res) => { usage, ); res.json(response); - logger.debug(`[OpenAI API] Request ${requestId} completed in ${duration}ms (non-streaming)`); + logger.debug( + `[OpenAI API] Response ${responseId} completed in ${duration}ms (non-streaming)`, + ); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'An error occurred'; diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index afdb96be9f..8ce15766c7 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -486,6 +486,10 @@ const createResponse = async (req, res) => { thread_id: conversationId, user_id: userId, user: createSafeUser(req.user), + requestBody: { + messageId: responseId, + conversationId, + }, ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, @@ -511,6 +515,7 @@ const createResponse = async (req, res) => { conversationId, collectedUsage, context: 'message', + messageId: responseId, balance: balanceConfig, transactions: transactionsConfig, model: primaryConfig.model || agent.model_parameters?.model, @@ -630,6 +635,10 @@ const createResponse = async (req, res) => { thread_id: conversationId, user_id: userId, user: createSafeUser(req.user), + requestBody: { + messageId: responseId, + conversationId, + }, ...(userMCPAuthMap != null && { userMCPAuthMap }), }, signal: abortController.signal, @@ -655,6 +664,7 @@ const createResponse = async (req, res) => { conversationId, collectedUsage, context: 'message', + messageId: responseId, balance: balanceConfig, transactions: transactionsConfig, model: primaryConfig.model || agent.model_parameters?.model, diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index d07a09682d..acc9299b04 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -27,8 +27,15 @@ const { abortRun } = require('./abortRun'); * @param {string} params.conversationId - Conversation ID * @param {Array} params.collectedUsage - Usage metadata from all models * @param {string} [params.fallbackModel] - Fallback model name if not in usage + * @param {string} [params.messageId] - The response message ID for transaction correlation */ -async function spendCollectedUsage({ userId, conversationId, collectedUsage, fallbackModel }) { +async function spendCollectedUsage({ + userId, + conversationId, + collectedUsage, + fallbackModel, + messageId, +}) { if (!collectedUsage || collectedUsage.length === 0) { return; } @@ -50,6 +57,7 @@ async function spendCollectedUsage({ userId, conversationId, collectedUsage, fal const txMetadata = { context: 'abort', + messageId, conversationId, user: userId, model: usage.model ?? fallbackModel, @@ -144,6 +152,7 @@ async function abortMessage(req, res) { conversationId: jobData?.conversationId, collectedUsage, fallbackModel: jobData?.model, + messageId: jobData?.responseMessageId, }); } else { // Fallback: no collected usage, use text-based token counting for primary model only diff --git a/packages/api/src/agents/usage.spec.ts b/packages/api/src/agents/usage.spec.ts index 9c06567dc4..1937af5011 100644 --- a/packages/api/src/agents/usage.spec.ts +++ b/packages/api/src/agents/usage.spec.ts @@ -379,6 +379,7 @@ describe('recordCollectedUsage', () => { await recordCollectedUsage(deps, { ...baseParams, + messageId: 'msg-123', endpointTokenConfig, collectedUsage, }); @@ -389,6 +390,7 @@ describe('recordCollectedUsage', () => { conversationId: 'convo-123', model: 'gpt-4', context: 'message', + messageId: 'msg-123', balance: { enabled: true }, transactions: { enabled: true }, endpointTokenConfig, @@ -431,4 +433,93 @@ describe('recordCollectedUsage', () => { ); }); }); + + describe('messageId propagation', () => { + it('should pass messageId to spendTokens', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 10, output_tokens: 5, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + messageId: 'msg-1', + collectedUsage, + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ messageId: 'msg-1' }), + expect.any(Object), + ); + }); + + it('should pass messageId to spendStructuredTokens for cache paths', async () => { + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'claude-3', + cache_creation_input_tokens: 25, + cache_read_input_tokens: 15, + }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + messageId: 'msg-cache-1', + collectedUsage, + }); + + expect(mockSpendStructuredTokens).toHaveBeenCalledWith( + expect.objectContaining({ messageId: 'msg-cache-1' }), + expect.any(Object), + ); + expect(mockSpendTokens).not.toHaveBeenCalled(); + }); + + it('should pass undefined messageId when not provided', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 10, output_tokens: 5, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + user: 'user-123', + conversationId: 'convo-123', + collectedUsage, + }); + + expect(mockSpendTokens).toHaveBeenCalledWith( + expect.objectContaining({ messageId: undefined }), + expect.any(Object), + ); + }); + + it('should propagate messageId across multiple usage entries', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + { + input_tokens: 150, + output_tokens: 30, + model: 'gpt-4', + input_token_details: { cache_creation: 10, cache_read: 5 }, + }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + messageId: 'msg-multi', + collectedUsage, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(2); + expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); + + for (const call of mockSpendTokens.mock.calls) { + expect(call[0]).toEqual(expect.objectContaining({ messageId: 'msg-multi' })); + } + expect(mockSpendStructuredTokens.mock.calls[0][0]).toEqual( + expect.objectContaining({ messageId: 'msg-multi' }), + ); + }); + }); }); diff --git a/packages/api/src/agents/usage.ts b/packages/api/src/agents/usage.ts index 545be9195d..351452d698 100644 --- a/packages/api/src/agents/usage.ts +++ b/packages/api/src/agents/usage.ts @@ -23,6 +23,7 @@ interface TxMetadata { user: string; model?: string; context: string; + messageId?: string; conversationId: string; balance?: Partial | null; transactions?: Partial; @@ -46,6 +47,7 @@ export interface RecordUsageParams { collectedUsage: UsageMetadata[]; model?: string; context?: string; + messageId?: string; balance?: Partial | null; transactions?: Partial; endpointTokenConfig?: EndpointTokenConfig; @@ -68,6 +70,7 @@ export async function recordCollectedUsage( user, model, balance, + messageId, transactions, conversationId, collectedUsage, @@ -108,11 +111,12 @@ export async function recordCollectedUsage( total_output_tokens += Number(usage.output_tokens) || 0; const txMetadata: TxMetadata = { + user, context, balance, + messageId, transactions, conversationId, - user, endpointTokenConfig, model: usage.model ?? model, }; diff --git a/packages/data-schemas/src/schema/transaction.ts b/packages/data-schemas/src/schema/transaction.ts index 6377db6d6c..6faf684b12 100644 --- a/packages/data-schemas/src/schema/transaction.ts +++ b/packages/data-schemas/src/schema/transaction.ts @@ -14,6 +14,7 @@ export interface ITransaction extends Document { inputTokens?: number; writeTokens?: number; readTokens?: number; + messageId?: string; createdAt?: Date; updatedAt?: Date; } @@ -52,6 +53,7 @@ const transactionSchema: Schema = new Schema( inputTokens: { type: Number }, writeTokens: { type: Number }, readTokens: { type: Number }, + messageId: { type: String }, }, { timestamps: true, From 43ff3f8473e95b1de9baecadf2f246feca029654 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 28 Feb 2026 09:06:32 -0500 Subject: [PATCH 032/110] =?UTF-8?q?=F0=9F=92=B8=20fix:=20Model=20Identifie?= =?UTF-8?q?r=20Edge=20Case=20in=20Agent=20Transactions=20(#11988)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: Add skippedAgentIds tracking in initializeClient error handling - Enhanced error handling in the initializeClient function to track agent IDs that are skipped during processing. This addition improves the ability to monitor and debug issues related to agent initialization failures. * 🔧 fix: Update model assignment in BaseClient to use instance model - Modified the model assignment in BaseClient to use `this.model` instead of `responseMessage.model`, clarifying that when using agents, the model refers to the agent ID rather than the model itself. This change improves code clarity and correctness in the context of agent usage. * 🔧 test: Add tests for recordTokenUsage model assignment in BaseClient - Introduced new test cases in BaseClient to ensure that the correct model is passed to the recordTokenUsage method, verifying that it uses this.model instead of the agent ID from responseMessage.model. This enhances the accuracy of token usage tracking in agent scenarios. - Improved error handling in the initializeClient function to log errors when processing agents, ensuring that skipped agent IDs are tracked for better debugging. --- api/app/clients/BaseClient.js | 3 +- api/app/clients/specs/BaseClient.test.js | 50 +++++++++++++++++++ .../services/Endpoints/agents/initialize.js | 8 ++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 2d008b9991..8f931f8a5e 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -797,7 +797,8 @@ class BaseClient { promptTokens, completionTokens, balance: balanceConfig, - model: responseMessage.model, + /** Note: When using agents, responseMessage.model is the agent ID, not the model */ + model: this.model, messageId: this.responseMessageId, }); } diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 15328af644..f13c9979ac 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -821,6 +821,56 @@ describe('BaseClient', () => { }); }); + describe('recordTokenUsage model assignment', () => { + test('should pass this.model to recordTokenUsage, not the agent ID from responseMessage.model', async () => { + const actualModel = 'claude-opus-4-5'; + const agentId = 'agent_p5Z_IU6EIxBoqn1BoqLBp'; + + TestClient.model = actualModel; + TestClient.options.endpoint = 'agents'; + TestClient.options.agent = { id: agentId }; + + TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50); + TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined); + TestClient.buildMessages.mockReturnValue({ + prompt: [], + tokenCountMap: { res: 50 }, + }); + + await TestClient.sendMessage('Hello', {}); + + expect(TestClient.recordTokenUsage).toHaveBeenCalledWith( + expect.objectContaining({ + model: actualModel, + }), + ); + + const callArgs = TestClient.recordTokenUsage.mock.calls[0][0]; + expect(callArgs.model).not.toBe(agentId); + }); + + test('should pass this.model even when this.model differs from modelOptions.model', async () => { + const instanceModel = 'gpt-4o'; + TestClient.model = instanceModel; + TestClient.modelOptions = { model: 'gpt-4o-mini' }; + + TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50); + TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined); + TestClient.buildMessages.mockReturnValue({ + prompt: [], + tokenCountMap: { res: 50 }, + }); + + await TestClient.sendMessage('Hello', {}); + + expect(TestClient.recordTokenUsage).toHaveBeenCalledWith( + expect.objectContaining({ + model: instanceModel, + }), + ); + }); + }); + describe('getMessagesWithinTokenLimit with instructions', () => { test('should always include instructions when present', async () => { TestClient.maxContextTokens = 50; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index fd2e42511d..e71270ef85 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -306,6 +306,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { } } catch (err) { logger.error(`[initializeClient] Error processing agent ${agentId}:`, err); + skippedAgentIds.add(agentId); } } @@ -315,7 +316,12 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { if (checkAgentInit(agentId)) { continue; } - await processAgent(agentId); + try { + await processAgent(agentId); + } catch (err) { + logger.error(`[initializeClient] Error processing chain agent ${agentId}:`, err); + skippedAgentIds.add(agentId); + } } const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}'); collectEdges(chain); From cde5079886b15d67f37f6ad4bbefbb7197ce818a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 28 Feb 2026 15:01:51 -0500 Subject: [PATCH 033/110] =?UTF-8?q?=F0=9F=8E=AF=20fix:=20Use=20Agents=20En?= =?UTF-8?q?dpoint=20Config=20for=20Agent=20Panel=20File=20Upload=20Validat?= =?UTF-8?q?ion=20(#11992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Use correct endpoint for file validation in agent panel uploads Agent panel file uploads (FileSearch, FileContext, Code/Files) were validating against the active conversation's endpoint config instead of the agents endpoint config. This caused incorrect file size limits when the active chat used a different endpoint. Add endpointOverride option to useFileHandling so callers can specify the correct endpoint for validation independent of the active conversation. * fix: Use agents endpoint config for agent panel file upload validation Agent panel file uploads (FileSearch, FileContext, Code/Files) validated against the active conversation's endpoint config instead of the agents endpoint config. This caused wrong file size limits when the active chat used a different endpoint. Adds endpointOverride to useFileHandling so callers can specify the correct endpoint for both validation and upload routing, independent of the active conversation. * test: Add unit tests for useFileHandling hook to validate endpoint overrides Introduced comprehensive tests for the useFileHandling hook, ensuring correct behavior when using endpoint overrides for file validation and upload routing. The tests cover various scenarios, including fallback to conversation endpoints and proper handling of agent-specific configurations, enhancing the reliability of file handling in the application. --- .../SidePanel/Agents/Code/Files.tsx | 1 + .../SidePanel/Agents/FileContext.tsx | 2 + .../SidePanel/Agents/FileSearch.tsx | 2 + .../Files/__tests__/useFileHandling.test.ts | 285 ++++++++++++++++++ client/src/hooks/Files/useFileHandling.ts | 15 +- .../hooks/Files/useSharePointFileHandling.ts | 2 + 6 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 client/src/hooks/Files/__tests__/useFileHandling.test.ts diff --git a/client/src/components/SidePanel/Agents/Code/Files.tsx b/client/src/components/SidePanel/Agents/Code/Files.tsx index 3ef7da9ca6..88fe710334 100644 --- a/client/src/components/SidePanel/Agents/Code/Files.tsx +++ b/client/src/components/SidePanel/Agents/Code/Files.tsx @@ -35,6 +35,7 @@ export default function Files({ const { abortUpload, handleFileChange } = useFileHandling({ fileSetter: setFiles, additionalMetadata: { agent_id, tool_resource }, + endpointOverride: EModelEndpoint.agents, }); useLazyEffect( diff --git a/client/src/components/SidePanel/Agents/FileContext.tsx b/client/src/components/SidePanel/Agents/FileContext.tsx index d437e8457f..bad2c9bdee 100644 --- a/client/src/components/SidePanel/Agents/FileContext.tsx +++ b/client/src/components/SidePanel/Agents/FileContext.tsx @@ -47,10 +47,12 @@ export default function FileContext({ const { handleFileChange } = useFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.context }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); useLazyEffect( diff --git a/client/src/components/SidePanel/Agents/FileSearch.tsx b/client/src/components/SidePanel/Agents/FileSearch.tsx index a82fc8bdfb..6b3e813ef1 100644 --- a/client/src/components/SidePanel/Agents/FileSearch.tsx +++ b/client/src/components/SidePanel/Agents/FileSearch.tsx @@ -44,11 +44,13 @@ export default function FileSearch({ const { handleFileChange } = useFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({ additionalMetadata: { agent_id, tool_resource: EToolResources.file_search }, + endpointOverride: EModelEndpoint.agents, fileSetter: setFiles, }); diff --git a/client/src/hooks/Files/__tests__/useFileHandling.test.ts b/client/src/hooks/Files/__tests__/useFileHandling.test.ts new file mode 100644 index 0000000000..297b0bd94d --- /dev/null +++ b/client/src/hooks/Files/__tests__/useFileHandling.test.ts @@ -0,0 +1,285 @@ +import { renderHook, act } from '@testing-library/react'; +import { Constants, EModelEndpoint, getEndpointFileConfig } from 'librechat-data-provider'; + +beforeAll(() => { + global.URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + global.URL.revokeObjectURL = jest.fn(); +}); + +const mockShowToast = jest.fn(); +const mockSetFilesLoading = jest.fn(); +const mockMutate = jest.fn(); + +let mockConversation: Record = {}; + +jest.mock('~/Providers/ChatContext', () => ({ + useChatContext: jest.fn(() => ({ + files: new Map(), + setFiles: jest.fn(), + setFilesLoading: mockSetFilesLoading, + conversation: mockConversation, + })), +})); + +jest.mock('@librechat/client', () => ({ + useToastContext: jest.fn(() => ({ + showToast: mockShowToast, + })), +})); + +jest.mock('recoil', () => ({ + ...jest.requireActual('recoil'), + useSetRecoilState: jest.fn(() => jest.fn()), +})); + +jest.mock('~/store', () => ({ + ephemeralAgentByConvoId: jest.fn(() => ({ key: 'mock' })), +})); + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: jest.fn(() => ({ + getQueryData: jest.fn(), + refetchQueries: jest.fn(), + })), +})); + +jest.mock('~/data-provider', () => ({ + useGetFileConfig: jest.fn(() => ({ data: null })), + useUploadFileMutation: jest.fn((_opts: Record) => ({ + mutate: mockMutate, + })), +})); + +jest.mock('~/hooks/useLocalize', () => { + const fn = jest.fn((key: string) => key); + fn.TranslationKeys = {}; + return { __esModule: true, default: fn, TranslationKeys: {} }; +}); + +jest.mock('../useDelayedUploadToast', () => ({ + useDelayedUploadToast: jest.fn(() => ({ + startUploadTimer: jest.fn(), + clearUploadTimer: jest.fn(), + })), +})); + +jest.mock('~/utils/heicConverter', () => ({ + processFileForUpload: jest.fn(async (file: File) => file), +})); + +jest.mock('../useClientResize', () => ({ + __esModule: true, + default: jest.fn(() => ({ + resizeImageIfNeeded: jest.fn(async (file: File) => ({ file, resized: false })), + })), +})); + +jest.mock('../useUpdateFiles', () => ({ + __esModule: true, + default: jest.fn(() => ({ + addFile: jest.fn(), + replaceFile: jest.fn(), + updateFileById: jest.fn(), + deleteFileById: jest.fn(), + })), +})); + +jest.mock('~/utils', () => ({ + logger: { log: jest.fn() }, + validateFiles: jest.fn(() => true), +})); + +const mockValidateFiles = jest.requireMock('~/utils').validateFiles; + +describe('useFileHandling', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockConversation = {}; + }); + + const loadHook = async () => (await import('../useFileHandling')).default; + + describe('endpointOverride', () => { + it('uses conversation endpoint when no override is provided', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling()); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockValidateFiles).toHaveBeenCalledTimes(1); + const validateCall = mockValidateFiles.mock.calls[0][0]; + const configResult = getEndpointFileConfig({ + endpoint: 'openAI', + endpointType: 'custom', + fileConfig: null, + }); + expect(validateCall.endpointFileConfig).toEqual(configResult); + }); + + it('uses endpointOverride for validation instead of conversation endpoint', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ endpointOverride: EModelEndpoint.agents }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockValidateFiles).toHaveBeenCalledTimes(1); + const validateCall = mockValidateFiles.mock.calls[0][0]; + const agentsConfig = getEndpointFileConfig({ + endpoint: EModelEndpoint.agents, + endpointType: EModelEndpoint.agents, + fileConfig: null, + }); + expect(validateCall.endpointFileConfig).toEqual(agentsConfig); + }); + + it('falls back to conversation endpoint when endpointOverride is undefined', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'anthropic', + endpointType: undefined, + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling({ endpointOverride: undefined })); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockValidateFiles).toHaveBeenCalledTimes(1); + const validateCall = mockValidateFiles.mock.calls[0][0]; + const anthropicConfig = getEndpointFileConfig({ + endpoint: 'anthropic', + endpointType: undefined, + fileConfig: null, + }); + expect(validateCall.endpointFileConfig).toEqual(anthropicConfig); + }); + + it('sends correct endpoint in upload form data when override is set', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ + endpointOverride: EModelEndpoint.agents, + additionalMetadata: { agent_id: 'agent-123' }, + }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe(EModelEndpoint.agents); + expect(formData.get('endpointType')).toBe(EModelEndpoint.agents); + }); + + it('does not enter assistants upload path when override is agents', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'assistants', + endpointType: 'assistants', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ + endpointOverride: EModelEndpoint.agents, + additionalMetadata: { agent_id: 'agent-123' }, + }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe(EModelEndpoint.agents); + expect(formData.get('message_file')).toBeNull(); + expect(formData.get('version')).toBeNull(); + expect(formData.get('model')).toBeNull(); + expect(formData.get('assistant_id')).toBeNull(); + }); + + it('enters assistants path without override when conversation is assistants', async () => { + mockConversation = { + conversationId: 'convo-1', + endpoint: 'assistants', + endpointType: 'assistants', + assistant_id: 'asst-456', + model: 'gpt-4', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling()); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe('assistants'); + expect(formData.get('message_file')).toBe('true'); + }); + + it('falls back to "default" when no conversation endpoint and no override', async () => { + mockConversation = { + conversationId: Constants.NEW_CONVO, + endpoint: null, + endpointType: undefined, + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling()); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('endpoint')).toBe('default'); + }); + }); +}); diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 4c65b80765..2d37dfd654 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -13,7 +13,7 @@ import { defaultAssistantsVersion, } from 'librechat-data-provider'; import debounce from 'lodash/debounce'; -import type { TEndpointsConfig, TError } from 'librechat-data-provider'; +import type { EModelEndpoint, TEndpointsConfig, TError } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; import { useGetFileConfig, useUploadFileMutation } from '~/data-provider'; import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; @@ -29,6 +29,8 @@ type UseFileHandling = { fileSetter?: FileSetter; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; + /** Overrides both `endpoint` and `endpointType` for validation and upload routing */ + endpointOverride?: EModelEndpoint; }; const useFileHandling = (params?: UseFileHandling) => { @@ -50,8 +52,15 @@ const useFileHandling = (params?: UseFileHandling) => { const agent_id = params?.additionalMetadata?.agent_id ?? ''; const assistant_id = params?.additionalMetadata?.assistant_id ?? ''; - const endpointType = useMemo(() => conversation?.endpointType, [conversation?.endpointType]); - const endpoint = useMemo(() => conversation?.endpoint ?? 'default', [conversation?.endpoint]); + const endpointOverride = params?.endpointOverride; + const endpointType = useMemo( + () => endpointOverride ?? conversation?.endpointType, + [endpointOverride, conversation?.endpointType], + ); + const endpoint = useMemo( + () => endpointOverride ?? conversation?.endpoint ?? 'default', + [endpointOverride, conversation?.endpoint], + ); const { data: fileConfig = null } = useGetFileConfig({ select: (data) => mergeFileConfig(data), diff --git a/client/src/hooks/Files/useSharePointFileHandling.ts b/client/src/hooks/Files/useSharePointFileHandling.ts index 11fc0915b7..82ff7b555b 100644 --- a/client/src/hooks/Files/useSharePointFileHandling.ts +++ b/client/src/hooks/Files/useSharePointFileHandling.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import useFileHandling from './useFileHandling'; import useSharePointDownload from './useSharePointDownload'; +import type { EModelEndpoint } from 'librechat-data-provider'; import type { SharePointFile } from '~/data-provider/Files/sharepoint'; interface UseSharePointFileHandlingProps { @@ -8,6 +9,7 @@ interface UseSharePointFileHandlingProps { toolResource?: string; fileFilter?: (file: File) => boolean; additionalMetadata?: Record; + endpointOverride?: EModelEndpoint; } interface UseSharePointFileHandlingReturn { From e6b324b2591e1b5472e94d7ad8cf729c236f174b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 28 Feb 2026 15:02:09 -0500 Subject: [PATCH 034/110] =?UTF-8?q?=F0=9F=A7=A0=20feat:=20Add=20`reasoning?= =?UTF-8?q?=5Feffort`=20configuration=20for=20Bedrock=20models=20(#11991)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧠 feat: Add reasoning_effort configuration for Bedrock models - Introduced a new `reasoning_effort` setting in the Bedrock configuration, allowing users to specify the reasoning level for supported models. - Updated the input parser to map `reasoning_effort` to `reasoning_config` for Moonshot and ZAI models, ensuring proper handling of reasoning levels. - Enhanced tests to validate the mapping of `reasoning_effort` to `reasoning_config` and to ensure correct behavior for various model types, including Anthropic models. - Updated translation files to include descriptions for the new configuration option. * chore: Update translation keys for Bedrock reasoning configuration - Renamed translation key from `com_endpoint_bedrock_reasoning_config` to `com_endpoint_bedrock_reasoning_effort` for consistency with the new configuration setting. - Updated the parameter settings to reflect the change in the description key, ensuring accurate mapping in the application. * 🧪 test: Enhance bedrockInputParser tests for reasoning_config handling - Added tests to ensure that stale `reasoning_config` is stripped when switching models from Moonshot to Meta and ZAI to DeepSeek. - Included additional tests to verify that `reasoning_effort` values of "none", "minimal", and "xhigh" do not forward to `reasoning_config` for Moonshot and ZAI models. - Improved coverage for the bedrockInputParser functionality to ensure correct behavior across various model configurations. * feat: Introduce Bedrock reasoning configuration and update input parser - Added a new `BedrockReasoningConfig` enum to define reasoning levels: low, medium, and high. - Updated the `bedrockInputParser` to utilize the new reasoning configuration, ensuring proper handling of `reasoning_effort` values. - Enhanced logic to validate `reasoning_effort` against the defined configuration values before assigning to `reasoning_config`. - Improved code clarity with additional comments and refactored conditions for better readability. --- client/src/locales/en/translation.json | 1 + .../src/endpoints/bedrock/initialize.spec.ts | 62 +++++++ packages/data-provider/specs/bedrock.spec.ts | 170 ++++++++++++++++++ packages/data-provider/src/bedrock.ts | 35 +++- .../data-provider/src/parameterSettings.ts | 61 ++++++- packages/data-provider/src/schemas.ts | 6 + 6 files changed, 326 insertions(+), 9 deletions(-) diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index e0dad68431..fd20176632 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -236,6 +236,7 @@ "com_endpoint_assistant": "Assistant", "com_endpoint_assistant_model": "Assistant Model", "com_endpoint_assistant_placeholder": "Please select an Assistant from the right-hand Side Panel", + "com_endpoint_bedrock_reasoning_effort": "Controls the reasoning level for supported Bedrock models (e.g. Kimi K2.5, GLM). Higher levels produce more thorough reasoning at the cost of increased latency and tokens.", "com_endpoint_config_click_here": "Click Here", "com_endpoint_config_google_api_info": "To get your Generative Language API key (for Gemini),", "com_endpoint_config_google_api_key": "Google API Key", diff --git a/packages/api/src/endpoints/bedrock/initialize.spec.ts b/packages/api/src/endpoints/bedrock/initialize.spec.ts index 2b83c55937..158650017e 100644 --- a/packages/api/src/endpoints/bedrock/initialize.spec.ts +++ b/packages/api/src/endpoints/bedrock/initialize.spec.ts @@ -722,4 +722,66 @@ describe('initializeBedrock', () => { expect(amrf.effort).toBeUndefined(); }); }); + + describe('Bedrock reasoning_effort for Moonshot/ZAI models', () => { + it('should map reasoning_effort to reasoning_config for Moonshot Kimi K2.5', async () => { + const params = createMockParams({ + model_parameters: { + model: 'moonshotai.kimi-k2.5', + reasoning_effort: 'high', + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + const amrf = result.llmConfig.additionalModelRequestFields as Record; + + expect(amrf.reasoning_config).toBe('high'); + expect(amrf.reasoning_effort).toBeUndefined(); + expect(amrf.thinking).toBeUndefined(); + expect(amrf.anthropic_beta).toBeUndefined(); + }); + + it('should map reasoning_effort to reasoning_config for ZAI GLM', async () => { + const params = createMockParams({ + model_parameters: { + model: 'zai.glm-4.7', + reasoning_effort: 'medium', + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + const amrf = result.llmConfig.additionalModelRequestFields as Record; + + expect(amrf.reasoning_config).toBe('medium'); + expect(amrf.reasoning_effort).toBeUndefined(); + }); + + it('should not include reasoning_config when reasoning_effort is unset', async () => { + const params = createMockParams({ + model_parameters: { + model: 'moonshotai.kimi-k2.5', + reasoning_effort: '', + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + + expect(result.llmConfig.additionalModelRequestFields).toBeUndefined(); + }); + + it('should not map reasoning_effort to reasoning_config for Anthropic models', async () => { + const params = createMockParams({ + model_parameters: { + model: 'anthropic.claude-opus-4-6-v1', + reasoning_effort: 'high', + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + const amrf = result.llmConfig.additionalModelRequestFields as Record; + + expect(amrf.reasoning_config).toBeUndefined(); + expect(amrf.thinking).toEqual({ type: 'adaptive' }); + }); + }); }); diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index 55bd0a2e08..1398b17b25 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -688,5 +688,175 @@ describe('bedrockInputParser', () => { expect(amrf.anthropic_beta).toBeDefined(); expect(Array.isArray(amrf.anthropic_beta)).toBe(true); }); + + test('should strip stale reasoning_config when switching to Anthropic model', () => { + const staleConversationData = { + model: 'anthropic.claude-sonnet-4-6', + additionalModelRequestFields: { + reasoning_config: 'high', + }, + }; + const result = bedrockInputParser.parse(staleConversationData) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBeUndefined(); + }); + + test('should strip stale reasoning_config when switching from Moonshot to Meta model', () => { + const staleData = { + model: 'meta.llama-3-1-70b', + additionalModelRequestFields: { + reasoning_config: 'high', + }, + }; + const result = bedrockInputParser.parse(staleData) as Record; + const amrf = result.additionalModelRequestFields as Record | undefined; + expect(amrf?.reasoning_config).toBeUndefined(); + }); + + test('should strip stale reasoning_config when switching from ZAI to DeepSeek model', () => { + const staleData = { + model: 'deepseek.deepseek-r1', + additionalModelRequestFields: { + reasoning_config: 'medium', + }, + }; + const result = bedrockInputParser.parse(staleData) as Record; + const amrf = result.additionalModelRequestFields as Record | undefined; + expect(amrf?.reasoning_config).toBeUndefined(); + }); + }); + + describe('Bedrock reasoning_effort → reasoning_config for Moonshot/ZAI models', () => { + test('should map reasoning_effort to reasoning_config for moonshotai.kimi-k2.5', () => { + const input = { + model: 'moonshotai.kimi-k2.5', + reasoning_effort: 'high', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBe('high'); + expect(amrf.reasoning_effort).toBeUndefined(); + }); + + test('should map reasoning_effort to reasoning_config for moonshot.kimi-k2.5', () => { + const input = { + model: 'moonshot.kimi-k2.5', + reasoning_effort: 'medium', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBe('medium'); + }); + + test('should map reasoning_effort to reasoning_config for zai.glm-4.7', () => { + const input = { + model: 'zai.glm-4.7', + reasoning_effort: 'high', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBe('high'); + }); + + test('should map reasoning_effort "low" to reasoning_config for Moonshot model', () => { + const input = { + model: 'moonshotai.kimi-k2.5', + reasoning_effort: 'low', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBe('low'); + }); + + test('should not include reasoning_config when reasoning_effort is unset (empty string)', () => { + const input = { + model: 'moonshotai.kimi-k2.5', + reasoning_effort: '', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record | undefined; + expect(amrf?.reasoning_config).toBeUndefined(); + }); + + test('should not include reasoning_config when reasoning_effort is not provided', () => { + const input = { + model: 'moonshotai.kimi-k2.5', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record | undefined; + expect(amrf?.reasoning_config).toBeUndefined(); + }); + + test('should not forward reasoning_effort "none" to reasoning_config', () => { + const result = bedrockInputParser.parse({ + model: 'moonshotai.kimi-k2.5', + reasoning_effort: 'none', + }) as Record; + const amrf = result.additionalModelRequestFields as Record | undefined; + expect(amrf?.reasoning_config).toBeUndefined(); + }); + + test('should not forward reasoning_effort "minimal" to reasoning_config', () => { + const result = bedrockInputParser.parse({ + model: 'moonshotai.kimi-k2.5', + reasoning_effort: 'minimal', + }) as Record; + const amrf = result.additionalModelRequestFields as Record | undefined; + expect(amrf?.reasoning_config).toBeUndefined(); + }); + + test('should not forward reasoning_effort "xhigh" to reasoning_config', () => { + const result = bedrockInputParser.parse({ + model: 'zai.glm-4.7', + reasoning_effort: 'xhigh', + }) as Record; + const amrf = result.additionalModelRequestFields as Record | undefined; + expect(amrf?.reasoning_config).toBeUndefined(); + }); + + test('should not add reasoning_config to Anthropic models', () => { + const input = { + model: 'anthropic.claude-sonnet-4-6', + reasoning_effort: 'high', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBeUndefined(); + expect(amrf.reasoning_effort).toBeUndefined(); + }); + + test('should not add thinking or anthropic_beta to Moonshot models with reasoning_effort', () => { + const input = { + model: 'moonshotai.kimi-k2.5', + reasoning_effort: 'high', + }; + const result = bedrockInputParser.parse(input) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.thinking).toBeUndefined(); + expect(amrf.thinkingBudget).toBeUndefined(); + expect(amrf.anthropic_beta).toBeUndefined(); + }); + + test('should pass reasoning_config through bedrockOutputParser', () => { + const parsed = bedrockInputParser.parse({ + model: 'moonshotai.kimi-k2.5', + reasoning_effort: 'high', + }) as Record; + const output = bedrockOutputParser(parsed); + const amrf = output.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBe('high'); + }); + + test('should strip stale reasoning_config from additionalModelRequestFields for Anthropic models', () => { + const staleData = { + model: 'anthropic.claude-opus-4-6-v1', + additionalModelRequestFields: { + reasoning_config: 'high', + }, + }; + const result = bedrockInputParser.parse(staleData) as Record; + const amrf = result.additionalModelRequestFields as Record; + expect(amrf.reasoning_config).toBeUndefined(); + }); }); }); diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index 54f5c8b23f..bdf5e19605 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -4,6 +4,8 @@ import * as s from './schemas'; const DEFAULT_ENABLED_MAX_TOKENS = 8192; const DEFAULT_THINKING_BUDGET = 2000; +const bedrockReasoningConfigValues = new Set(Object.values(s.BedrockReasoningConfig)); + type ThinkingConfig = { type: 'enabled'; budget_tokens: number } | { type: 'adaptive' }; type AnthropicReasoning = { @@ -134,6 +136,7 @@ export const bedrockInputSchema = s.tConversationSchema thinking: true, thinkingBudget: true, effort: true, + reasoning_effort: true, promptCache: true, /* Catch-all fields */ topK: true, @@ -178,6 +181,7 @@ export const bedrockInputParser = s.tConversationSchema thinking: true, thinkingBudget: true, effort: true, + reasoning_effort: true, promptCache: true, /* Catch-all fields */ topK: true, @@ -256,6 +260,9 @@ export const bedrockInputParser = s.tConversationSchema delete additionalFields.effort; } + /** Anthropic uses 'effort' via output_config, not reasoning_config */ + delete additionalFields.reasoning_effort; + if ((typedData.model as string).includes('anthropic.')) { const betaHeaders = getBedrockAnthropicBetaHeaders(typedData.model as string); if (betaHeaders.length > 0) { @@ -268,23 +275,37 @@ export const bedrockInputParser = s.tConversationSchema delete additionalFields.effort; delete additionalFields.output_config; delete additionalFields.anthropic_beta; + + const reasoningEffort = additionalFields.reasoning_effort; + delete additionalFields.reasoning_effort; + if ( + typeof reasoningEffort === 'string' && + bedrockReasoningConfigValues.has(reasoningEffort) + ) { + additionalFields.reasoning_config = reasoningEffort; + } } const isAnthropicModel = typeof typedData.model === 'string' && typedData.model.includes('anthropic.'); - /** Strip stale anthropic_beta from previously-persisted additionalModelRequestFields */ + /** Strip stale fields from previously-persisted additionalModelRequestFields */ if ( - !isAnthropicModel && typeof typedData.additionalModelRequestFields === 'object' && typedData.additionalModelRequestFields != null ) { const amrf = typedData.additionalModelRequestFields as Record; - delete amrf.anthropic_beta; - delete amrf.thinking; - delete amrf.thinkingBudget; - delete amrf.effort; - delete amrf.output_config; + if (!isAnthropicModel) { + delete amrf.anthropic_beta; + delete amrf.thinking; + delete amrf.thinkingBudget; + delete amrf.effort; + delete amrf.output_config; + delete amrf.reasoning_config; + } else { + delete amrf.reasoning_config; + delete amrf.reasoning_effort; + } } /** Default promptCache for claude and nova models, if not defined */ diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 0796efe773..229f970c7d 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -530,6 +530,30 @@ const bedrock: Record = { showDefault: false, columnSpan: 2, }, + reasoning_effort: { + key: 'reasoning_effort', + label: 'com_endpoint_reasoning_effort', + labelCode: true, + description: 'com_endpoint_bedrock_reasoning_effort', + descriptionCode: true, + type: 'enum', + default: ReasoningEffort.unset, + component: 'slider', + options: [ + ReasoningEffort.unset, + ReasoningEffort.low, + ReasoningEffort.medium, + ReasoningEffort.high, + ], + enumMappings: { + [ReasoningEffort.unset]: 'com_ui_off', + [ReasoningEffort.low]: 'com_ui_low', + [ReasoningEffort.medium]: 'com_ui_medium', + [ReasoningEffort.high]: 'com_ui_high', + }, + optionType: 'model', + columnSpan: 4, + }, }; const mistral: Record = { @@ -905,6 +929,34 @@ const bedrockGeneralCol2: SettingsConfiguration = [ librechat.fileTokenLimit, ]; +const bedrockZAI: SettingsConfiguration = [ + librechat.modelLabel, + librechat.promptPrefix, + librechat.maxContextTokens, + meta.temperature, + meta.topP, + librechat.resendFiles, + bedrock.region, + bedrock.reasoning_effort, + librechat.fileTokenLimit, +]; + +const bedrockZAICol1: SettingsConfiguration = [ + baseDefinitions.model as SettingDefinition, + librechat.modelLabel, + librechat.promptPrefix, +]; + +const bedrockZAICol2: SettingsConfiguration = [ + librechat.maxContextTokens, + meta.temperature, + meta.topP, + librechat.resendFiles, + bedrock.region, + bedrock.reasoning_effort, + librechat.fileTokenLimit, +]; + const bedrockMoonshot: SettingsConfiguration = [ librechat.modelLabel, bedrock.system, @@ -917,6 +969,7 @@ const bedrockMoonshot: SettingsConfiguration = [ baseDefinitions.stop, librechat.resendFiles, bedrock.region, + bedrock.reasoning_effort, librechat.fileTokenLimit, ]; @@ -936,6 +989,7 @@ const bedrockMoonshotCol2: SettingsConfiguration = [ bedrock.topP, librechat.resendFiles, bedrock.region, + bedrock.reasoning_effort, librechat.fileTokenLimit, ]; @@ -954,7 +1008,7 @@ export const paramSettings: Record = [`${EModelEndpoint.bedrock}-${BedrockProviders.Moonshot}`]: bedrockMoonshot, [`${EModelEndpoint.bedrock}-${BedrockProviders.MoonshotAI}`]: bedrockMoonshot, [`${EModelEndpoint.bedrock}-${BedrockProviders.OpenAI}`]: bedrockGeneral, - [`${EModelEndpoint.bedrock}-${BedrockProviders.ZAI}`]: bedrockGeneral, + [`${EModelEndpoint.bedrock}-${BedrockProviders.ZAI}`]: bedrockZAI, [EModelEndpoint.google]: googleConfig, }; @@ -1008,7 +1062,10 @@ export const presetSettings: Record< col2: bedrockMoonshotCol2, }, [`${EModelEndpoint.bedrock}-${BedrockProviders.OpenAI}`]: bedrockGeneralColumns, - [`${EModelEndpoint.bedrock}-${BedrockProviders.ZAI}`]: bedrockGeneralColumns, + [`${EModelEndpoint.bedrock}-${BedrockProviders.ZAI}`]: { + col1: bedrockZAICol1, + col2: bedrockZAICol2, + }, [EModelEndpoint.google]: { col1: googleCol1, col2: googleCol2, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 90d5362273..02096cb0cf 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -185,6 +185,12 @@ export enum AnthropicEffort { max = 'max', } +export enum BedrockReasoningConfig { + low = 'low', + medium = 'medium', + high = 'high', +} + export enum ReasoningSummary { none = '', auto = 'auto', From 826b494578554b16b7edff33a35b60c498a7dbc6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 28 Feb 2026 16:54:07 -0500 Subject: [PATCH 035/110] =?UTF-8?q?=F0=9F=94=80=20feat:=20update=20OpenRou?= =?UTF-8?q?ter=20with=20new=20Reasoning=20config=20(#11993)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Update OpenRouter reasoning handling in LLM configuration - Modified the OpenRouter configuration to use a unified `reasoning` object instead of separate `reasoning_effort` and `include_reasoning` properties. - Updated tests to ensure that `reasoning_summary` is excluded from the reasoning object and that the configuration behaves correctly based on the presence of reasoning parameters. - Enhanced test coverage for OpenRouter-specific configurations, ensuring proper handling of various reasoning effort levels. * refactor: Improve OpenRouter reasoning handling in LLM configuration - Updated the handling of the `reasoning` object in the OpenRouter configuration to clarify the relationship between `reasoning_effort` and `include_reasoning`. - Enhanced comments to explain the behavior of the `reasoning` object and its compatibility with legacy parameters. - Ensured that the configuration correctly falls back to legacy behavior when no explicit reasoning effort is provided. * test: Enhance OpenRouter LLM configuration tests - Added a new test to verify the combination of web search plugins and reasoning object for OpenRouter configurations. - Updated existing tests to ensure proper handling of reasoning effort levels and fallback behavior when reasoning_effort is unset. - Improved test coverage for OpenRouter-specific configurations, ensuring accurate validation of reasoning parameters. * chore: Update @librechat/agents dependency to version 3.1.53 - Bumped the version of @librechat/agents in package-lock.json and related package.json files to ensure compatibility with the latest features and fixes. - Updated integrity hashes to reflect the new version. --- api/package.json | 2 +- package-lock.json | 10 +-- packages/api/package.json | 2 +- .../api/src/endpoints/openai/config.spec.ts | 11 +-- packages/api/src/endpoints/openai/llm.spec.ts | 84 ++++++++++++++++++- packages/api/src/endpoints/openai/llm.ts | 18 +++- 6 files changed, 110 insertions(+), 17 deletions(-) diff --git a/api/package.json b/api/package.json index 1447087b38..f9c9601a37 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.52", + "@librechat/agents": "^3.1.53", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 1ad97628a9..c03ef33c8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.52", + "@librechat/agents": "^3.1.53", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11836,9 +11836,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.52", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.52.tgz", - "integrity": "sha512-Bg35zp+vEDZ0AEJQPZ+ukWb/UqBrsLcr3YQWRQpuvpftEgfQz0fHM5Wrxn6l5P7PvaD1ViolxoG44nggjCt7Hw==", + "version": "3.1.53", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.53.tgz", + "integrity": "sha512-jK9JHIhQYgr+Ha2FhknEYQmS6Ft3/TGdYIlL6L6EtIq20SIA59r1DvQx/x9sd3wHoHkk6AZumMgqAUTTCaWBIA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -43797,7 +43797,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.52", + "@librechat/agents": "^3.1.53", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index 903e15947b..3ceaeb7a12 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -90,7 +90,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.52", + "@librechat/agents": "^3.1.53", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/endpoints/openai/config.spec.ts b/packages/api/src/endpoints/openai/config.spec.ts index 93864649f9..2bbe123e63 100644 --- a/packages/api/src/endpoints/openai/config.spec.ts +++ b/packages/api/src/endpoints/openai/config.spec.ts @@ -861,7 +861,7 @@ describe('getOpenAIConfig', () => { expect(result.provider).toBe('openrouter'); }); - it('should handle OpenRouter with reasoning params', () => { + it('should handle OpenRouter with reasoning params (no summary)', () => { const modelOptions = { reasoning_effort: ReasoningEffort.high, reasoning_summary: ReasoningSummary.detailed, @@ -872,10 +872,11 @@ describe('getOpenAIConfig', () => { modelOptions, }); + // OpenRouter reasoning object should only include effort, not summary expect(result.llmConfig.reasoning).toEqual({ effort: ReasoningEffort.high, - summary: ReasoningSummary.detailed, }); + expect(result.llmConfig.include_reasoning).toBeUndefined(); expect(result.provider).toBe('openrouter'); }); @@ -1205,8 +1206,9 @@ describe('getOpenAIConfig', () => { model: 'gpt-4-turbo', temperature: 0.8, streaming: false, - include_reasoning: true, // OpenRouter specific + reasoning: { effort: ReasoningEffort.high }, // OpenRouter reasoning object }); + expect(result.llmConfig.include_reasoning).toBeUndefined(); // Should NOT have useResponsesApi for OpenRouter expect(result.llmConfig.useResponsesApi).toBeUndefined(); expect(result.llmConfig.maxTokens).toBe(2000); @@ -1480,13 +1482,12 @@ describe('getOpenAIConfig', () => { user: 'openrouter-user', temperature: 0.7, maxTokens: 4000, - include_reasoning: true, // OpenRouter specific reasoning: { effort: ReasoningEffort.high, - summary: ReasoningSummary.detailed, }, apiKey: apiKey, }); + expect(result.llmConfig.include_reasoning).toBeUndefined(); expect(result.llmConfig.modelKwargs).toMatchObject({ top_k: 50, repetition_penalty: 1.1, diff --git a/packages/api/src/endpoints/openai/llm.spec.ts b/packages/api/src/endpoints/openai/llm.spec.ts index 8e92332e24..3c10179737 100644 --- a/packages/api/src/endpoints/openai/llm.spec.ts +++ b/packages/api/src/endpoints/openai/llm.spec.ts @@ -381,6 +381,23 @@ describe('getOpenAILLMConfig', () => { expect(result.llmConfig).toHaveProperty('include_reasoning', true); }); + it('should combine web search plugins and reasoning object for OpenRouter', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'anthropic/claude-3-sonnet', + reasoning_effort: ReasoningEffort.high, + web_search: true, + }, + }); + + expect(result.llmConfig).toHaveProperty('reasoning', { effort: ReasoningEffort.high }); + expect(result.llmConfig).not.toHaveProperty('include_reasoning'); + expect(result.llmConfig.modelKwargs).toHaveProperty('plugins', [{ id: 'web' }]); + }); + it('should disable web search via dropParams', () => { const result = getOpenAILLMConfig({ apiKey: 'test-api-key', @@ -575,7 +592,7 @@ describe('getOpenAILLMConfig', () => { }); describe('OpenRouter Configuration', () => { - it('should include include_reasoning for OpenRouter', () => { + it('should include include_reasoning for OpenRouter when no reasoning_effort set', () => { const result = getOpenAILLMConfig({ apiKey: 'test-api-key', streaming: true, @@ -586,6 +603,71 @@ describe('getOpenAILLMConfig', () => { }); expect(result.llmConfig).toHaveProperty('include_reasoning', true); + expect(result.llmConfig).not.toHaveProperty('reasoning'); + }); + + it('should use reasoning object for OpenRouter when reasoning_effort is set', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'anthropic/claude-3-sonnet', + reasoning_effort: ReasoningEffort.high, + }, + }); + + expect(result.llmConfig).toHaveProperty('reasoning', { effort: ReasoningEffort.high }); + expect(result.llmConfig).not.toHaveProperty('include_reasoning'); + expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); + }); + + it('should exclude reasoning_summary from OpenRouter reasoning object', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'anthropic/claude-3-sonnet', + reasoning_effort: ReasoningEffort.high, + reasoning_summary: ReasoningSummary.detailed, + }, + }); + + expect(result.llmConfig).toHaveProperty('reasoning', { effort: ReasoningEffort.high }); + }); + + it.each([ReasoningEffort.xhigh, ReasoningEffort.minimal, ReasoningEffort.none])( + 'should support OpenRouter effort level: %s', + (effort) => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'openai/o3-mini', + reasoning_effort: effort, + }, + }); + + expect(result.llmConfig).toHaveProperty('reasoning', { effort }); + expect(result.llmConfig).not.toHaveProperty('include_reasoning'); + }, + ); + + it('should fall back to include_reasoning when reasoning_effort is unset (empty string)', () => { + const result = getOpenAILLMConfig({ + apiKey: 'test-api-key', + streaming: true, + useOpenRouter: true, + modelOptions: { + model: 'anthropic/claude-3-sonnet', + reasoning_effort: ReasoningEffort.unset, + }, + }); + + expect(result.llmConfig).toHaveProperty('include_reasoning', true); + expect(result.llmConfig).not.toHaveProperty('reasoning'); }); }); diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index f25971735c..c659645958 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -1,6 +1,7 @@ import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; import type { BindToolsInput } from '@langchain/core/language_models/chat_models'; import type { SettingDefinition } from 'librechat-data-provider'; +import type { OpenRouterReasoning } from '@librechat/agents'; import type { AzureOpenAIInput } from '@langchain/openai'; import type { OpenAI } from 'openai'; import type * as t from '~/types'; @@ -223,10 +224,19 @@ export function getOpenAILLMConfig({ } if (useOpenRouter) { - llmConfig.include_reasoning = true; - } - - if ( + if (hasReasoningParams({ reasoning_effort })) { + /** + * OpenRouter uses a `reasoning` object — `summary` is not supported. + * ChatOpenRouter treats `reasoning` and `include_reasoning` as mutually exclusive: + * `include_reasoning` is legacy compat that maps to `{ enabled: true }` only when + * no `reasoning` object is present, so we intentionally omit it here. + */ + llmConfig.reasoning = { effort: reasoning_effort } as OpenRouterReasoning; + } else { + /** No explicit effort; fall back to legacy `include_reasoning` for reasoning token inclusion */ + llmConfig.include_reasoning = true; + } + } else if ( hasReasoningParams({ reasoning_effort, reasoning_summary }) && (llmConfig.useResponsesApi === true || (endpoint !== EModelEndpoint.openAI && endpoint !== EModelEndpoint.azureOpenAI)) From 723acd830c084307fad6e676903d12203fa28aac Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 28 Feb 2026 16:56:10 -0500 Subject: [PATCH 036/110] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Add=20T?= =?UTF-8?q?hinking=20Level=20Parameter=20for=20Gemini=203+=20Models=20(#11?= =?UTF-8?q?994)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧠 feat: Add Thinking Level Config for Gemini 3 Models - Introduced a new setting for 'thinking level' in the Google configuration, allowing users to control the depth of reasoning for Gemini 3 models. - Updated translation files to include the new 'thinking level' label and description. - Enhanced the Google LLM configuration to support the new 'thinking level' parameter, ensuring compatibility with both Google and Vertex AI providers. - Added necessary schema and type definitions to accommodate the new setting across the data provider and API layers. * test: Google LLM Configuration for Gemini 3 Models - Added tests to validate default thinking configuration for Gemini 3 models, ensuring `thinkingConfig` is set correctly without `thinkingLevel`. - Implemented logic to ignore `thinkingBudget` for Gemini 3+ models, confirming that it does not affect the configuration. - Included a test to verify that `gemini-2.9-flash` is not classified as a Gemini 3+ model, maintaining expected behavior for earlier versions. - Updated existing tests to ensure comprehensive coverage of the new configurations and behaviors. * fix: Update translation for Google LLM thinking settings - Revised descriptions for 'thinking budget' and 'thinking level' in the English translation file to clarify their applicability to different Gemini model versions. - Ensured that the new descriptions accurately reflect the functionality and usage of the settings for Gemini 2.5 and 3 models. * docs: Update comments for Gemini 3+ thinking configuration - Added detailed comments in the Google LLM configuration to clarify the differences between `thinkingLevel` and `thinkingBudget` for Gemini 3+ models. - Explained the necessity of `includeThoughts` in Vertex AI requests and how it interacts with `thinkingConfig` for improved understanding of the configuration logic. * fix: Update comment for Gemini 3 model versioning - Corrected comment in the configuration file to reflect the proper versioning for Gemini models, changing "Gemini 3.0 Models" to "Gemini 3 Models" for clarity and consistency. * fix: Update thinkingLevel schema for Gemini 3 Models - Removed nullable option from the thinkingLevel field in the tConversationSchema to ensure it is always defined when present, aligning with the intended configuration for Gemini 3 models. --- client/src/locales/en/translation.json | 6 +- packages/api/src/endpoints/google/llm.spec.ts | 187 +++++++++++++++++- packages/api/src/endpoints/google/llm.ts | 52 +++-- packages/data-provider/src/config.ts | 3 + .../data-provider/src/parameterSettings.ts | 29 +++ packages/data-provider/src/schemas.ts | 16 ++ packages/data-provider/src/types.ts | 1 + packages/data-schemas/src/schema/defaults.ts | 3 + 8 files changed, 283 insertions(+), 14 deletions(-) diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index fd20176632..8fdbc05544 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -275,8 +275,9 @@ "com_endpoint_google_custom_name_placeholder": "Set a custom name for Google", "com_endpoint_google_maxoutputtokens": "Maximum number of tokens that can be generated in the response. Specify a lower value for shorter responses and a higher value for longer responses. Note: models may stop before reaching this maximum.", "com_endpoint_google_temp": "Higher values = more random, while lower values = more focused and deterministic. We recommend altering this or Top P but not both.", - "com_endpoint_google_thinking": "Enables or disables reasoning. This setting is only supported by certain models (2.5 series). For older models, this setting may have no effect.", - "com_endpoint_google_thinking_budget": "Guides the number of thinking tokens the model uses. The actual amount may exceed or fall below this value depending on the prompt.\n\nThis setting is only supported by certain models (2.5 series). Gemini 2.5 Pro supports 128-32,768 tokens. Gemini 2.5 Flash supports 0-24,576 tokens. Gemini 2.5 Flash Lite supports 512-24,576 tokens.\n\nLeave blank or set to \"-1\" to let the model automatically decide when and how much to think. By default, Gemini 2.5 Flash Lite does not think.", + "com_endpoint_google_thinking": "Enables or disables reasoning. Supported by Gemini 2.5 and 3 series. Note: Gemini 3 Pro cannot fully disable thinking.", + "com_endpoint_google_thinking_budget": "Guides the number of thinking tokens the model uses. The actual amount may exceed or fall below this value depending on the prompt.\n\nThis setting only applies to Gemini 2.5 and older models. For Gemini 3 and later, use the Thinking Level setting instead.\n\nGemini 2.5 Pro supports 128-32,768 tokens. Gemini 2.5 Flash supports 0-24,576 tokens. Gemini 2.5 Flash Lite supports 512-24,576 tokens.\n\nLeave blank or set to \"-1\" to let the model automatically decide when and how much to think. By default, Gemini 2.5 Flash Lite does not think.", + "com_endpoint_google_thinking_level": "Controls the depth of reasoning for Gemini 3 and later models. Has no effect on Gemini 2.5 and older — use Thinking Budget for those.\n\nLeave on Auto to use the model default.", "com_endpoint_google_topk": "Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens in the model's vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature).", "com_endpoint_google_topp": "Top-p changes how the model selects tokens for output. Tokens are selected from most K (see topK parameter) probable to least until the sum of their probabilities equals the top-p value.", "com_endpoint_google_use_search_grounding": "Use Google's search grounding feature to enhance responses with real-time web search results. This enables models to access current information and provide more accurate, up-to-date answers.", @@ -346,6 +347,7 @@ "com_endpoint_temperature": "Temperature", "com_endpoint_thinking": "Thinking", "com_endpoint_thinking_budget": "Thinking Budget", + "com_endpoint_thinking_level": "Thinking Level", "com_endpoint_top_k": "Top K", "com_endpoint_top_p": "Top P", "com_endpoint_use_active_assistant": "Use Active Assistant", diff --git a/packages/api/src/endpoints/google/llm.spec.ts b/packages/api/src/endpoints/google/llm.spec.ts index d9aa1a702a..6e2a8ddb25 100644 --- a/packages/api/src/endpoints/google/llm.spec.ts +++ b/packages/api/src/endpoints/google/llm.spec.ts @@ -1,5 +1,5 @@ import { Providers } from '@librechat/agents'; -import { AuthKeys } from 'librechat-data-provider'; +import { AuthKeys, ThinkingLevel } from 'librechat-data-provider'; import type * as t from '~/types'; import { getGoogleConfig, getSafetySettings, knownGoogleParams } from './llm'; @@ -367,6 +367,191 @@ describe('getGoogleConfig', () => { }); }); + describe('Gemini 3 Thinking Level', () => { + it('should use thinkingLevel for Gemini 3 models with Google provider', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3-pro-preview', + thinking: true, + thinkingLevel: ThinkingLevel.high, + }, + }); + + expect(result.llmConfig).toHaveProperty('thinkingConfig'); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: ThinkingLevel.high, + }); + expect((result.llmConfig as Record).thinkingConfig).not.toHaveProperty( + 'thinkingBudget', + ); + }); + + it('should use thinkingLevel for Gemini 3.1 models', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.1-pro-preview', + thinking: true, + thinkingLevel: ThinkingLevel.medium, + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: ThinkingLevel.medium, + }); + }); + + it('should omit thinkingLevel when unset (empty string) for Gemini 3', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3-flash-preview', + thinking: true, + thinkingLevel: ThinkingLevel.unset, + }, + }); + + expect(result.llmConfig).toHaveProperty('thinkingConfig'); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + }); + expect((result.llmConfig as Record).thinkingConfig).not.toHaveProperty( + 'thinkingLevel', + ); + }); + + it('should not set thinkingConfig when thinking is false for Gemini 3', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3-pro-preview', + thinking: false, + thinkingLevel: ThinkingLevel.high, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('thinkingConfig'); + }); + + it('should use thinkingLevel for Gemini 3 with Vertex AI provider', () => { + const credentials = { + [AuthKeys.GOOGLE_SERVICE_KEY]: { + project_id: 'test-project', + }, + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3-pro-preview', + thinking: true, + thinkingLevel: ThinkingLevel.low, + }, + }); + + expect(result.provider).toBe(Providers.VERTEXAI); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: ThinkingLevel.low, + }); + expect(result.llmConfig).toHaveProperty('includeThoughts', true); + }); + + it('should send thinkingConfig by default for Gemini 3 (no thinking options set)', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3-pro-preview', + }, + }); + + expect(result.llmConfig).toHaveProperty('thinkingConfig'); + const config = (result.llmConfig as Record).thinkingConfig; + expect(config).toMatchObject({ includeThoughts: true }); + expect(config).not.toHaveProperty('thinkingLevel'); + }); + + it('should ignore thinkingBudget for Gemini 3+ models', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3-pro-preview', + thinking: true, + thinkingBudget: 5000, + }, + }); + + const config = (result.llmConfig as Record).thinkingConfig; + expect(config).not.toHaveProperty('thinkingBudget'); + expect(config).toMatchObject({ includeThoughts: true }); + }); + + it('should NOT classify gemini-2.9-flash as Gemini 3+', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.9-flash', + thinking: true, + thinkingBudget: 5000, + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + thinkingBudget: 5000, + includeThoughts: true, + }); + expect((result.llmConfig as Record).thinkingConfig).not.toHaveProperty( + 'thinkingLevel', + ); + }); + + it('should use thinkingBudget (not thinkingLevel) for Gemini 2.5 models', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + thinking: true, + thinkingBudget: 5000, + thinkingLevel: ThinkingLevel.high, + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + thinkingBudget: 5000, + includeThoughts: true, + }); + expect((result.llmConfig as Record).thinkingConfig).not.toHaveProperty( + 'thinkingLevel', + ); + }); + }); + describe('Web Search Functionality', () => { it('should enable web search when web_search is true', () => { const credentials = { diff --git a/packages/api/src/endpoints/google/llm.ts b/packages/api/src/endpoints/google/llm.ts index 289bc0e952..83951f9e0c 100644 --- a/packages/api/src/endpoints/google/llm.ts +++ b/packages/api/src/endpoints/google/llm.ts @@ -150,6 +150,7 @@ export function getGoogleConfig( const { web_search, + thinkingLevel, thinking = googleSettings.thinking.default, thinkingBudget = googleSettings.thinkingBudget.default, ...modelOptions @@ -196,19 +197,48 @@ export function getGoogleConfig( ); } - const shouldEnableThinking = - thinking && thinkingBudget != null && (thinkingBudget > 0 || thinkingBudget === -1); + const modelName = (modelOptions?.model ?? '') as string; - if (shouldEnableThinking && provider === Providers.GOOGLE) { - (llmConfig as GoogleClientOptions).thinkingConfig = { - thinkingBudget: thinking ? thinkingBudget : googleSettings.thinkingBudget.default, - includeThoughts: Boolean(thinking), + /** + * Gemini 3+ uses a qualitative `thinkingLevel` ('minimal'|'low'|'medium'|'high') + * instead of the numeric `thinkingBudget` used by Gemini 2.5 and earlier. + * When `thinking` is enabled (default: true), we always send `thinkingConfig` + * with `includeThoughts: true`. The `thinkingBudget` param is ignored for Gemini 3+. + * + * For Vertex AI, top-level `includeThoughts` is still required because + * `@langchain/google-common`'s `formatGenerationConfig` reads it separately + * from `thinkingConfig` — they serve different purposes in the request pipeline. + */ + const isGemini3Plus = /gemini-([3-9]|\d{2,})/i.test(modelName); + + if (isGemini3Plus && thinking) { + const thinkingConfig: { includeThoughts: boolean; thinkingLevel?: string } = { + includeThoughts: true, }; - } else if (shouldEnableThinking && provider === Providers.VERTEXAI) { - (llmConfig as VertexAIClientOptions).thinkingBudget = thinking - ? thinkingBudget - : googleSettings.thinkingBudget.default; - (llmConfig as VertexAIClientOptions).includeThoughts = Boolean(thinking); + if (thinkingLevel) { + thinkingConfig.thinkingLevel = thinkingLevel as string; + } + if (provider === Providers.GOOGLE) { + (llmConfig as GoogleClientOptions).thinkingConfig = thinkingConfig; + } else if (provider === Providers.VERTEXAI) { + (llmConfig as Record).thinkingConfig = thinkingConfig; + (llmConfig as VertexAIClientOptions).includeThoughts = true; + } + } else if (!isGemini3Plus) { + const shouldEnableThinking = + thinking && thinkingBudget != null && (thinkingBudget > 0 || thinkingBudget === -1); + + if (shouldEnableThinking && provider === Providers.GOOGLE) { + (llmConfig as GoogleClientOptions).thinkingConfig = { + thinkingBudget: thinking ? thinkingBudget : googleSettings.thinkingBudget.default, + includeThoughts: Boolean(thinking), + }; + } else if (shouldEnableThinking && provider === Providers.VERTEXAI) { + (llmConfig as VertexAIClientOptions).thinkingBudget = thinking + ? thinkingBudget + : googleSettings.thinkingBudget.default; + (llmConfig as VertexAIClientOptions).includeThoughts = Boolean(thinking); + } } /* diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 64fc99b0eb..4a51844fb8 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1196,6 +1196,9 @@ export const defaultModels = { // Gemini 3.1 Models 'gemini-3.1-pro-preview', 'gemini-3.1-pro-preview-customtools', + // Gemini 3 Models + 'gemini-3-pro-preview', + 'gemini-3-flash-preview', // Gemini 2.5 Models 'gemini-2.5-pro', 'gemini-2.5-flash', diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 229f970c7d..d0cfdf210f 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -1,6 +1,7 @@ import { Verbosity, ImageDetail, + ThinkingLevel, EModelEndpoint, openAISettings, googleSettings, @@ -672,6 +673,32 @@ const google: Record = { optionType: 'conversation', columnSpan: 2, }, + thinkingLevel: { + key: 'thinkingLevel', + label: 'com_endpoint_thinking_level', + labelCode: true, + description: 'com_endpoint_google_thinking_level', + descriptionCode: true, + type: 'enum', + default: ThinkingLevel.unset, + component: 'slider', + options: [ + ThinkingLevel.unset, + ThinkingLevel.minimal, + ThinkingLevel.low, + ThinkingLevel.medium, + ThinkingLevel.high, + ], + enumMappings: { + [ThinkingLevel.unset]: 'com_ui_auto', + [ThinkingLevel.minimal]: 'com_ui_minimal', + [ThinkingLevel.low]: 'com_ui_low', + [ThinkingLevel.medium]: 'com_ui_medium', + [ThinkingLevel.high]: 'com_ui_high', + }, + optionType: 'conversation', + columnSpan: 4, + }, web_search: { key: 'web_search', label: 'com_endpoint_use_search_grounding', @@ -698,6 +725,7 @@ const googleConfig: SettingsConfiguration = [ librechat.resendFiles, google.thinking, google.thinkingBudget, + google.thinkingLevel, google.web_search, librechat.fileTokenLimit, ]; @@ -717,6 +745,7 @@ const googleCol2: SettingsConfiguration = [ librechat.resendFiles, google.thinking, google.thinkingBudget, + google.thinkingLevel, google.web_search, librechat.fileTokenLimit, ]; diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 02096cb0cf..63a7ed574e 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -205,6 +205,14 @@ export enum Verbosity { high = 'high', } +export enum ThinkingLevel { + unset = '', + minimal = 'minimal', + low = 'low', + medium = 'medium', + high = 'high', +} + export const imageDetailNumeric = { [ImageDetail.low]: 0, [ImageDetail.auto]: 1, @@ -222,6 +230,7 @@ export const eReasoningEffortSchema = z.nativeEnum(ReasoningEffort); export const eAnthropicEffortSchema = z.nativeEnum(AnthropicEffort); export const eReasoningSummarySchema = z.nativeEnum(ReasoningSummary); export const eVerbositySchema = z.nativeEnum(Verbosity); +export const eThinkingLevelSchema = z.nativeEnum(ThinkingLevel); export const defaultAssistantFormValues = { assistant: '', @@ -366,6 +375,9 @@ export const googleSettings = { */ default: -1 as const, }, + thinkingLevel: { + default: ThinkingLevel.unset as const, + }, }; const ANTHROPIC_MAX_OUTPUT = 128000 as const; @@ -722,6 +734,7 @@ export const tConversationSchema = z.object({ system: z.string().optional(), thinking: z.boolean().optional(), thinkingBudget: coerceNumber.optional(), + thinkingLevel: eThinkingLevelSchema.optional(), stream: z.boolean().optional(), /* artifacts */ artifacts: z.string().optional(), @@ -868,6 +881,7 @@ export const tQueryParamsSchema = tConversationSchema promptCache: true, thinking: true, thinkingBudget: true, + thinkingLevel: true, effort: true, /** @endpoints bedrock */ region: true, @@ -943,6 +957,7 @@ export const googleBaseSchema = tConversationSchema.pick({ topK: true, thinking: true, thinkingBudget: true, + thinkingLevel: true, web_search: true, fileTokenLimit: true, iconURL: true, @@ -974,6 +989,7 @@ export const googleGenConfigSchema = z .object({ includeThoughts: z.boolean().optional(), thinkingBudget: coerceNumber.optional(), + thinkingLevel: z.string().optional(), }) .optional(), web_search: z.boolean().optional(), diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index a7782a3bc6..3b04c40f45 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -52,6 +52,7 @@ export type TEndpointOption = Pick< | 'promptCache' | 'thinking' | 'thinkingBudget' + | 'thinkingLevel' | 'effort' // Assistant/Agent fields | 'assistant_id' diff --git a/packages/data-schemas/src/schema/defaults.ts b/packages/data-schemas/src/schema/defaults.ts index 33af668384..9b50bceb1d 100644 --- a/packages/data-schemas/src/schema/defaults.ts +++ b/packages/data-schemas/src/schema/defaults.ts @@ -83,6 +83,9 @@ export const conversationPreset = { thinkingBudget: { type: Number, }, + thinkingLevel: { + type: String, + }, effort: { type: String, }, From 0e5ee379b397c4fdf02c39c7253b7352a9b113fa Mon Sep 17 00:00:00 2001 From: Daniel Lew Date: Sat, 28 Feb 2026 15:58:50 -0600 Subject: [PATCH 037/110] =?UTF-8?q?=F0=9F=91=81=EF=B8=8F=E2=80=8D?= =?UTF-8?q?=F0=9F=97=A8=EF=B8=8F=20fix:=20Replace=20Select=20with=20Menu?= =?UTF-8?q?=20in=20AccountSettings=20for=20Screen=20Reader=20Accuracy=20(#?= =?UTF-8?q?11980)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccountSettings was using Select, but it makes more sense (for a11y) to use Menu. The Select has the wrong role & behavior for the purpose of AccountSettings; the "listbox" role it uses is for selecting values in a form. Menu matches the actual content better for screen readers; the "menu" role is more appropriate for selecting one of a number of links. --- client/src/components/Nav/AccountSettings.tsx | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/client/src/components/Nav/AccountSettings.tsx b/client/src/components/Nav/AccountSettings.tsx index e3f97160eb..cf80f89ca2 100644 --- a/client/src/components/Nav/AccountSettings.tsx +++ b/client/src/components/Nav/AccountSettings.tsx @@ -1,5 +1,5 @@ import { useState, memo, useRef } from 'react'; -import * as Select from '@ariakit/react/select'; +import * as Menu from '@ariakit/react/menu'; import { FileText, LogOut } from 'lucide-react'; import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client'; import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal'; @@ -20,8 +20,8 @@ function AccountSettings() { const accountSettingsButtonRef = useRef(null); return ( - - + {user?.name ?? user?.username ?? localize('com_nav_user')} - - + )} - setShowFiles(true)} - className="select-item text-sm" - > + setShowFiles(true)} className="select-item text-sm"> + {startupConfig?.helpAndFaqURL !== '/' && ( - window.open(startupConfig?.helpAndFaqURL, '_blank')} className="select-item text-sm" > + )} - setShowSettings(true)} - className="select-item text-sm" - > + setShowSettings(true)} className="select-item text-sm"> + - logout()} - value="logout" - className="select-item text-sm" - > + logout()} className="select-item text-sm"> - + + {showFiles && ( )} {showSettings && } - + ); } From e1e204d6cffca9b174ef5511746972b990ef8206 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 1 Mar 2026 12:26:36 -0500 Subject: [PATCH 038/110] =?UTF-8?q?=F0=9F=A7=AE=20refactor:=20Bulk=20Trans?= =?UTF-8?q?actions=20&=20Balance=20Updates=20for=20Token=20Spending=20(#11?= =?UTF-8?q?996)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: transaction handling by integrating pricing and bulk write operations - Updated `recordCollectedUsage` to accept pricing functions and bulk write operations, improving transaction management. - Refactored `AgentClient` and related controllers to utilize the new transaction handling capabilities, ensuring better performance and accuracy in token spending. - Added tests to validate the new functionality, ensuring correct behavior for both standard and bulk transaction paths. - Introduced a new `transactions.ts` file to encapsulate transaction-related logic and types, enhancing code organization and maintainability. * chore: reorganize imports in agents client controller - Moved `getMultiplier` and `getCacheMultiplier` imports to maintain consistency and clarity in the import structure. - Removed duplicate import of `updateBalance` and `bulkInsertTransactions`, streamlining the code for better readability. * refactor: add TransactionData type and CANCEL_RATE constant to data-schemas Establishes a single source of truth for the transaction document shape and the incomplete-context billing rate constant, both consumed by packages/api and api/. * refactor: use proper types in data-schemas transaction methods - Replace `as unknown as { tokenCredits }` with `lean()` - Use `TransactionData[]` instead of `Record[]` for bulkInsertTransactions parameter - Add JSDoc noting insertMany bypasses document middleware - Remove orphan section comment in methods/index.ts * refactor: use shared types in transactions.ts, fix bulk write logic - Import CANCEL_RATE from data-schemas instead of local duplicate - Import TransactionData from data-schemas for PreparedEntry/BulkWriteDeps - Use tilde alias for EndpointTokenConfig import - Pass valueKey through to getMultiplier - Only sum tokenValue for balance-enabled docs in bulkWriteTransactions - Consolidate two loops into single-pass map * refactor: remove duplicate updateBalance from Transaction.js Import updateBalance from ~/models (sourced from data-schemas) instead of maintaining a second copy. Also import CANCEL_RATE from data-schemas and remove the Balance model import (no longer needed directly). * fix: test real spendCollectedUsage instead of IIFE replica Export spendCollectedUsage from abortMiddleware.js and rewrite the test file to import and test the actual function. Previously the tests ran against a hand-written replica that could silently diverge from the real implementation. * test: add transactions.spec.ts and restore regression comments Add 22 direct unit tests for transactions.ts financial logic covering prepareTokenSpend, prepareStructuredTokenSpend, bulkWriteTransactions, CANCEL_RATE paths, NaN guards, disabled transactions, zero tokens, cache multipliers, and balance-enabled filtering. Restore critical regression documentation comments in recordCollectedUsage.spec.js explaining which production bugs the tests guard against. * fix: widen setValues type to include lastRefill The UpdateBalanceParams.setValues type was Partial> which excluded lastRefill — used by createAutoRefillTransaction. Widen to also pick 'lastRefill'. * test: use real MongoDB for bulkWriteTransactions tests Replace mock-based bulkWriteTransactions tests with real DB tests using MongoMemoryServer. Pure function tests (prepareTokenSpend, prepareStructuredTokenSpend) remain mock-based since they don't touch DB. Add end-to-end integration tests that verify the full prepare → bulk write → DB state pipeline with real Transaction and Balance models. * chore: update @librechat/agents dependency to version 3.1.54 in package-lock.json and related package.json files * test: add bulk path parity tests proving identical DB outcomes Three test suites proving the bulk path (prepareTokenSpend/ prepareStructuredTokenSpend + bulkWriteTransactions) produces numerically identical results to the legacy path for all scenarios: - usage.bulk-parity.spec.ts: mirrors all legacy recordCollectedUsage tests; asserts same return values and verifies metadata fields on the insertMany docs match what spendTokens args would carry - transactions.bulk-parity.spec.ts: real-DB tests using actual getMultiplier/getCacheMultiplier pricing functions; asserts exact tokenValue, rate, rawAmount and balance deductions for standard tokens, structured/cache tokens, CANCEL_RATE, premium pricing, multi-entry batches, and edge cases (NaN, zero, disabled) - Transaction.spec.js: adds describe('Bulk path parity') that mirrors 7 key legacy tests via recordCollectedUsage + bulk deps against real MongoDB, asserting same balance deductions and doc counts * refactor: update llmConfig structure to use modelKwargs for reasoning effort Refactor the llmConfig in getOpenAILLMConfig to store reasoning effort within modelKwargs instead of directly on llmConfig. This change ensures consistency in the configuration structure and improves clarity in the handling of reasoning properties in the tests. * test: update performance checks in processAssistantMessage tests Revise the performance assertions in the processAssistantMessage tests to ensure that each message processing time remains under 100ms, addressing potential ReDoS vulnerabilities. This change enhances the reliability of the tests by focusing on maximum processing time rather than relative ratios. * test: fill parity test gaps — model fallback, abort context, structured edge cases - usage.bulk-parity: add undefined model fallback test - transactions.bulk-parity: add abort context test (txns inserted, balance unchanged when balance not passed), fix readTokens type cast - Transaction.spec: add 3 missing mirrors — balance disabled with transactions enabled, structured transactions disabled, structured balance disabled * fix: deduct balance before inserting transactions to prevent orphaned docs Swap the order in bulkWriteTransactions: updateBalance runs before insertMany. If updateBalance fails (after exhausting retries), no transaction documents are written — avoiding the inconsistent state where transactions exist in MongoDB with no corresponding balance deduction. * chore: import order * test: update config.spec.ts for OpenRouter reasoning in modelKwargs Same fix as llm.spec.ts — OpenRouter reasoning is now passed via modelKwargs instead of llmConfig.reasoning directly. --- api/models/Transaction.js | 149 +---- api/models/Transaction.spec.js | 340 ++++++++++- api/package.json | 2 +- .../agents/__tests__/openai.spec.js | 29 +- .../agents/__tests__/responses.unit.spec.js | 39 +- api/server/controllers/agents/client.js | 95 +-- api/server/controllers/agents/openai.js | 8 +- .../agents/recordCollectedUsage.spec.js | 574 ++++-------------- api/server/controllers/agents/responses.js | 15 +- api/server/middleware/abortMiddleware.js | 70 +-- api/server/middleware/abortMiddleware.spec.js | 333 +++------- api/server/utils/import/importers.spec.js | 9 +- package-lock.json | 10 +- packages/api/package.json | 2 +- packages/api/src/agents/index.ts | 1 + .../agents/transactions.bulk-parity.spec.ts | 559 +++++++++++++++++ packages/api/src/agents/transactions.spec.ts | 474 +++++++++++++++ packages/api/src/agents/transactions.ts | 345 +++++++++++ .../api/src/agents/usage.bulk-parity.spec.ts | 533 ++++++++++++++++ packages/api/src/agents/usage.spec.ts | 200 +++++- packages/api/src/agents/usage.ts | 132 ++-- .../api/src/endpoints/openai/config.spec.ts | 11 +- packages/api/src/endpoints/openai/llm.spec.ts | 14 +- packages/api/src/endpoints/openai/llm.ts | 4 +- packages/data-schemas/src/methods/index.ts | 6 +- .../data-schemas/src/methods/transaction.ts | 100 +++ packages/data-schemas/src/types/index.ts | 1 + .../data-schemas/src/types/transaction.ts | 17 + .../data-schemas/src/utils/transactions.ts | 2 + 29 files changed, 3004 insertions(+), 1070 deletions(-) create mode 100644 packages/api/src/agents/transactions.bulk-parity.spec.ts create mode 100644 packages/api/src/agents/transactions.spec.ts create mode 100644 packages/api/src/agents/transactions.ts create mode 100644 packages/api/src/agents/usage.bulk-parity.spec.ts create mode 100644 packages/data-schemas/src/methods/transaction.ts create mode 100644 packages/data-schemas/src/types/transaction.ts diff --git a/api/models/Transaction.js b/api/models/Transaction.js index e553e2bb3b..7f018e1c30 100644 --- a/api/models/Transaction.js +++ b/api/models/Transaction.js @@ -1,140 +1,7 @@ -const { logger } = require('@librechat/data-schemas'); +const { logger, CANCEL_RATE } = require('@librechat/data-schemas'); const { getMultiplier, getCacheMultiplier } = require('./tx'); -const { Transaction, Balance } = require('~/db/models'); - -const cancelRate = 1.15; - -/** - * Updates a user's token balance based on a transaction using optimistic concurrency control - * without schema changes. Compatible with DocumentDB. - * @async - * @function - * @param {Object} params - The function parameters. - * @param {string|mongoose.Types.ObjectId} params.user - The user ID. - * @param {number} params.incrementValue - The value to increment the balance by (can be negative). - * @param {import('mongoose').UpdateQuery['$set']} [params.setValues] - Optional additional fields to set. - * @returns {Promise} Returns the updated balance document (lean). - * @throws {Error} Throws an error if the update fails after multiple retries. - */ -const updateBalance = async ({ user, incrementValue, setValues }) => { - let maxRetries = 10; // Number of times to retry on conflict - let delay = 50; // Initial retry delay in ms - let lastError = null; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - let currentBalanceDoc; - try { - // 1. Read the current document state - currentBalanceDoc = await Balance.findOne({ user }).lean(); - const currentCredits = currentBalanceDoc ? currentBalanceDoc.tokenCredits : 0; - - // 2. Calculate the desired new state - const potentialNewCredits = currentCredits + incrementValue; - const newCredits = Math.max(0, potentialNewCredits); // Ensure balance doesn't go below zero - - // 3. Prepare the update payload - const updatePayload = { - $set: { - tokenCredits: newCredits, - ...(setValues || {}), // Merge other values to set - }, - }; - - // 4. Attempt the conditional update or upsert - let updatedBalance = null; - if (currentBalanceDoc) { - // --- Document Exists: Perform Conditional Update --- - // Try to update only if the tokenCredits match the value we read (currentCredits) - updatedBalance = await Balance.findOneAndUpdate( - { - user: user, - tokenCredits: currentCredits, // Optimistic lock: condition based on the read value - }, - updatePayload, - { - new: true, // Return the modified document - // lean: true, // .lean() is applied after query execution in Mongoose >= 6 - }, - ).lean(); // Use lean() for plain JS object - - if (updatedBalance) { - // Success! The update was applied based on the expected current state. - return updatedBalance; - } - // If updatedBalance is null, it means tokenCredits changed between read and write (conflict). - lastError = new Error(`Concurrency conflict for user ${user} on attempt ${attempt}.`); - // Proceed to retry logic below. - } else { - // --- Document Does Not Exist: Perform Conditional Upsert --- - // Try to insert the document, but only if it still doesn't exist. - // Using tokenCredits: {$exists: false} helps prevent race conditions where - // another process creates the doc between our findOne and findOneAndUpdate. - try { - updatedBalance = await Balance.findOneAndUpdate( - { - user: user, - // Attempt to match only if the document doesn't exist OR was just created - // without tokenCredits (less likely but possible). A simple { user } filter - // might also work, relying on the retry for conflicts. - // Let's use a simpler filter and rely on retry for races. - // tokenCredits: { $exists: false } // This condition might be too strict if doc exists with 0 credits - }, - updatePayload, - { - upsert: true, // Create if doesn't exist - new: true, // Return the created/updated document - // setDefaultsOnInsert: true, // Ensure schema defaults are applied on insert - // lean: true, - }, - ).lean(); - - if (updatedBalance) { - // Upsert succeeded (likely created the document) - return updatedBalance; - } - // If null, potentially a rare race condition during upsert. Retry should handle it. - lastError = new Error( - `Upsert race condition suspected for user ${user} on attempt ${attempt}.`, - ); - } catch (error) { - if (error.code === 11000) { - // E11000 duplicate key error on index - // This means another process created the document *just* before our upsert. - // It's a concurrency conflict during creation. We should retry. - lastError = error; // Store the error - // Proceed to retry logic below. - } else { - // Different error, rethrow - throw error; - } - } - } // End if/else (document exists?) - } catch (error) { - // Catch errors from findOne or unexpected findOneAndUpdate errors - logger.error(`[updateBalance] Error during attempt ${attempt} for user ${user}:`, error); - lastError = error; // Store the error - // Consider stopping retries for non-transient errors, but for now, we retry. - } - - // If we reached here, it means the update failed (conflict or error), wait and retry - if (attempt < maxRetries) { - const jitter = Math.random() * delay * 0.5; // Add jitter to delay - await new Promise((resolve) => setTimeout(resolve, delay + jitter)); - delay = Math.min(delay * 2, 2000); // Exponential backoff with cap - } - } // End for loop (retries) - - // If loop finishes without success, throw the last encountered error or a generic one - logger.error( - `[updateBalance] Failed to update balance for user ${user} after ${maxRetries} attempts.`, - ); - throw ( - lastError || - new Error( - `Failed to update balance for user ${user} after maximum retries due to persistent conflicts.`, - ) - ); -}; +const { Transaction } = require('~/db/models'); +const { updateBalance } = require('~/models'); /** Method to calculate and set the tokenValue for a transaction */ function calculateTokenValue(txn) { @@ -145,8 +12,8 @@ function calculateTokenValue(txn) { txn.rate = multiplier; txn.tokenValue = txn.rawAmount * multiplier; if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') { - txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate); - txn.rate *= cancelRate; + txn.tokenValue = Math.ceil(txn.tokenValue * CANCEL_RATE); + txn.rate *= CANCEL_RATE; } } @@ -321,11 +188,11 @@ function calculateStructuredTokenValue(txn) { } if (txn.context && txn.tokenType === 'completion' && txn.context === 'incomplete') { - txn.tokenValue = Math.ceil(txn.tokenValue * cancelRate); - txn.rate *= cancelRate; + txn.tokenValue = Math.ceil(txn.tokenValue * CANCEL_RATE); + txn.rate *= CANCEL_RATE; if (txn.rateDetail) { txn.rateDetail = Object.fromEntries( - Object.entries(txn.rateDetail).map(([k, v]) => [k, v * cancelRate]), + Object.entries(txn.rateDetail).map(([k, v]) => [k, v * CANCEL_RATE]), ); } } diff --git a/api/models/Transaction.spec.js b/api/models/Transaction.spec.js index 545c7b2755..f363c472e1 100644 --- a/api/models/Transaction.spec.js +++ b/api/models/Transaction.spec.js @@ -1,8 +1,10 @@ const mongoose = require('mongoose'); +const { recordCollectedUsage } = require('@librechat/api'); +const { createMethods } = require('@librechat/data-schemas'); const { MongoMemoryServer } = require('mongodb-memory-server'); -const { spendTokens, spendStructuredTokens } = require('./spendTokens'); const { getMultiplier, getCacheMultiplier, premiumTokenValues, tokenValues } = require('./tx'); const { createTransaction, createStructuredTransaction } = require('./Transaction'); +const { spendTokens, spendStructuredTokens } = require('./spendTokens'); const { Balance, Transaction } = require('~/db/models'); let mongoServer; @@ -985,3 +987,339 @@ describe('Premium Token Pricing Integration Tests', () => { expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); }); }); + +describe('Bulk path parity', () => { + /** + * Each test here mirrors an existing legacy test above, replacing spendTokens/ + * spendStructuredTokens with recordCollectedUsage + bulk deps. + * The balance deduction and transaction document fields must be numerically identical. + */ + let bulkDeps; + let methods; + + beforeEach(() => { + methods = createMethods(mongoose); + bulkDeps = { + spendTokens: () => Promise.resolve(), + spendStructuredTokens: () => Promise.resolve(), + pricing: { getMultiplier, getCacheMultiplier }, + bulkWriteOps: { + insertMany: methods.bulkInsertTransactions, + updateBalance: methods.updateBalance, + }, + }; + }); + + test('balance should decrease when spending tokens via bulk path', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + const promptTokens = 100; + const completionTokens = 50; + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-conversation-id', + model, + context: 'test', + balance: { enabled: true }, + transactions: { enabled: true }, + collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }], + }); + + const updatedBalance = await Balance.findOne({ user: userId }); + const promptMultiplier = getMultiplier({ + model, + tokenType: 'prompt', + inputTokenCount: promptTokens, + }); + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: promptTokens, + }); + const expectedTotalCost = + promptTokens * promptMultiplier + completionTokens * completionMultiplier; + const expectedBalance = initialBalance - expectedTotalCost; + + expect(updatedBalance.tokenCredits).toBeCloseTo(expectedBalance, 0); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(2); + }); + + test('bulk path should not update balance when balance.enabled is false', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-conversation-id', + model, + context: 'test', + balance: { enabled: false }, + transactions: { enabled: true }, + collectedUsage: [{ input_tokens: 100, output_tokens: 50, model }], + }); + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(updatedBalance.tokenCredits).toBe(initialBalance); + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(2); // transactions still recorded + }); + + test('bulk path should not insert when transactions.enabled is false', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-conversation-id', + model: 'gpt-3.5-turbo', + context: 'test', + balance: { enabled: true }, + transactions: { enabled: false }, + collectedUsage: [{ input_tokens: 100, output_tokens: 50, model: 'gpt-3.5-turbo' }], + }); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(0); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('bulk path handles incomplete context for completion tokens — same CANCEL_RATE as legacy', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 17613154.55; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-3-5-sonnet'; + const promptTokens = 10; + const completionTokens = 50; + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-convo', + model, + context: 'incomplete', + balance: { enabled: true }, + transactions: { enabled: true }, + collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }], + }); + + const txns = await Transaction.find({ user: userId }).lean(); + const completionTx = txns.find((t) => t.tokenType === 'completion'); + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: promptTokens, + }); + expect(completionTx.tokenValue).toBeCloseTo(-completionTokens * completionMultiplier * 1.15, 0); + }); + + test('bulk path structured tokens — balance deduction matches legacy spendStructuredTokens', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 17613154.55; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-3-5-sonnet'; + const promptInput = 11; + const promptWrite = 140522; + const promptRead = 0; + const completionTokens = 5; + const totalInput = promptInput + promptWrite + promptRead; + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-convo', + model, + context: 'message', + balance: { enabled: true }, + transactions: { enabled: true }, + collectedUsage: [ + { + input_tokens: promptInput, + output_tokens: completionTokens, + model, + input_token_details: { cache_creation: promptWrite, cache_read: promptRead }, + }, + ], + }); + + const promptMultiplier = getMultiplier({ + model, + tokenType: 'prompt', + inputTokenCount: totalInput, + }); + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: totalInput, + }); + const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier; + const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier; + + const expectedPromptCost = + promptInput * promptMultiplier + promptWrite * writeMultiplier + promptRead * readMultiplier; + const expectedCompletionCost = completionTokens * completionMultiplier; + const expectedTotalCost = expectedPromptCost + expectedCompletionCost; + const expectedBalance = initialBalance - expectedTotalCost; + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(Math.abs(updatedBalance.tokenCredits - expectedBalance)).toBeLessThan(100); + }); + + test('premium pricing above threshold via bulk path — same balance as legacy', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-opus-4-6'; + const promptTokens = 250000; + const completionTokens = 500; + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-premium', + model, + context: 'test', + balance: { enabled: true }, + transactions: { enabled: true }, + collectedUsage: [{ input_tokens: promptTokens, output_tokens: completionTokens, model }], + }); + + const premiumPromptRate = premiumTokenValues[model].prompt; + const premiumCompletionRate = premiumTokenValues[model].completion; + const expectedCost = + promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate; + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + test('real-world multi-entry batch: 5 sequential tool calls — same total deduction as 5 legacy spendTokens calls', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-opus-4-5-20251101'; + const calls = [ + { input_tokens: 31596, output_tokens: 151 }, + { input_tokens: 35368, output_tokens: 150 }, + { input_tokens: 58362, output_tokens: 295 }, + { input_tokens: 112604, output_tokens: 193 }, + { input_tokens: 257440, output_tokens: 2217 }, + ]; + + let expectedTotalCost = 0; + for (const { input_tokens, output_tokens } of calls) { + const pm = getMultiplier({ model, tokenType: 'prompt', inputTokenCount: input_tokens }); + const cm = getMultiplier({ model, tokenType: 'completion', inputTokenCount: input_tokens }); + expectedTotalCost += input_tokens * pm + output_tokens * cm; + } + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-sequential', + model, + context: 'message', + balance: { enabled: true }, + transactions: { enabled: true }, + collectedUsage: calls.map((c) => ({ ...c, model })), + }); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(10); // 5 calls × 2 docs (prompt + completion) + + const updatedBalance = await Balance.findOne({ user: userId }); + expect(updatedBalance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0); + }); + + test('bulk path should save transaction but not update balance when balance disabled, transactions enabled', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-conversation-id', + model: 'gpt-3.5-turbo', + context: 'test', + balance: { enabled: false }, + transactions: { enabled: true }, + collectedUsage: [{ input_tokens: 100, output_tokens: 50, model: 'gpt-3.5-turbo' }], + }); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(2); + expect(txns[0].rawAmount).toBeDefined(); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('bulk path structured tokens should not save when transactions.enabled is false', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-conversation-id', + model: 'claude-3-5-sonnet', + context: 'message', + balance: { enabled: true }, + transactions: { enabled: false }, + collectedUsage: [ + { + input_tokens: 10, + output_tokens: 5, + model: 'claude-3-5-sonnet', + input_token_details: { cache_creation: 100, cache_read: 5 }, + }, + ], + }); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(0); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('bulk path structured tokens should save but not update balance when balance disabled', async () => { + const userId = new mongoose.Types.ObjectId(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + await recordCollectedUsage(bulkDeps, { + user: userId.toString(), + conversationId: 'test-conversation-id', + model: 'claude-3-5-sonnet', + context: 'message', + balance: { enabled: false }, + transactions: { enabled: true }, + collectedUsage: [ + { + input_tokens: 10, + output_tokens: 5, + model: 'claude-3-5-sonnet', + input_token_details: { cache_creation: 100, cache_read: 5 }, + }, + ], + }); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(2); + const promptTx = txns.find((t) => t.tokenType === 'prompt'); + expect(promptTx.inputTokens).toBe(-10); + expect(promptTx.writeTokens).toBe(-100); + expect(promptTx.readTokens).toBe(-5); + const balance = await Balance.findOne({ user: userId }); + expect(balance.tokenCredits).toBe(initialBalance); + }); +}); diff --git a/api/package.json b/api/package.json index f9c9601a37..3e9350ac34 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.53", + "@librechat/agents": "^3.1.54", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 8592c79a2d..835343e798 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -82,6 +82,13 @@ jest.mock('~/models/spendTokens', () => ({ spendStructuredTokens: mockSpendStructuredTokens, })); +const mockGetMultiplier = jest.fn().mockReturnValue(1); +const mockGetCacheMultiplier = jest.fn().mockReturnValue(null); +jest.mock('~/models/tx', () => ({ + getMultiplier: mockGetMultiplier, + getCacheMultiplier: mockGetCacheMultiplier, +})); + jest.mock('~/server/controllers/agents/callbacks', () => ({ createToolEndCallback: jest.fn().mockReturnValue(jest.fn()), })); @@ -103,6 +110,8 @@ jest.mock('~/models/Agent', () => ({ getAgents: jest.fn().mockResolvedValue([]), })); +const mockUpdateBalance = jest.fn().mockResolvedValue({}); +const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); jest.mock('~/models', () => ({ getFiles: jest.fn(), getUserKey: jest.fn(), @@ -112,6 +121,8 @@ jest.mock('~/models', () => ({ getUserCodeFiles: jest.fn(), getToolFilesByIds: jest.fn(), getCodeGeneratedFiles: jest.fn(), + updateBalance: mockUpdateBalance, + bulkInsertTransactions: mockBulkInsertTransactions, })); describe('OpenAIChatCompletionController', () => { @@ -155,7 +166,15 @@ describe('OpenAIChatCompletionController', () => { expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); expect(mockRecordCollectedUsage).toHaveBeenCalledWith( - { spendTokens: mockSpendTokens, spendStructuredTokens: mockSpendStructuredTokens }, + { + spendTokens: mockSpendTokens, + spendStructuredTokens: mockSpendStructuredTokens, + pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier }, + bulkWriteOps: { + insertMany: mockBulkInsertTransactions, + updateBalance: mockUpdateBalance, + }, + }, expect.objectContaining({ user: 'user-123', conversationId: expect.any(String), @@ -182,12 +201,18 @@ describe('OpenAIChatCompletionController', () => { ); }); - it('should pass spendTokens and spendStructuredTokens as dependencies', async () => { + it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => { await OpenAIChatCompletionController(req, res); const [deps] = mockRecordCollectedUsage.mock.calls[0]; expect(deps).toHaveProperty('spendTokens', mockSpendTokens); expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens); + expect(deps).toHaveProperty('pricing'); + expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier); + expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier); + expect(deps).toHaveProperty('bulkWriteOps'); + expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions); + expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance); }); it('should include model from primaryConfig in recordCollectedUsage params', async () => { diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index e16ca394b2..45ec31fc68 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -106,6 +106,13 @@ jest.mock('~/models/spendTokens', () => ({ spendStructuredTokens: mockSpendStructuredTokens, })); +const mockGetMultiplier = jest.fn().mockReturnValue(1); +const mockGetCacheMultiplier = jest.fn().mockReturnValue(null); +jest.mock('~/models/tx', () => ({ + getMultiplier: mockGetMultiplier, + getCacheMultiplier: mockGetCacheMultiplier, +})); + jest.mock('~/server/controllers/agents/callbacks', () => ({ createToolEndCallback: jest.fn().mockReturnValue(jest.fn()), createResponsesToolEndCallback: jest.fn().mockReturnValue(jest.fn()), @@ -131,6 +138,8 @@ jest.mock('~/models/Agent', () => ({ getAgents: jest.fn().mockResolvedValue([]), })); +const mockUpdateBalance = jest.fn().mockResolvedValue({}); +const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); jest.mock('~/models', () => ({ getFiles: jest.fn(), getUserKey: jest.fn(), @@ -141,6 +150,8 @@ jest.mock('~/models', () => ({ getUserCodeFiles: jest.fn(), getToolFilesByIds: jest.fn(), getCodeGeneratedFiles: jest.fn(), + updateBalance: mockUpdateBalance, + bulkInsertTransactions: mockBulkInsertTransactions, })); describe('createResponse controller', () => { @@ -184,7 +195,15 @@ describe('createResponse controller', () => { expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); expect(mockRecordCollectedUsage).toHaveBeenCalledWith( - { spendTokens: mockSpendTokens, spendStructuredTokens: mockSpendStructuredTokens }, + { + spendTokens: mockSpendTokens, + spendStructuredTokens: mockSpendStructuredTokens, + pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier }, + bulkWriteOps: { + insertMany: mockBulkInsertTransactions, + updateBalance: mockUpdateBalance, + }, + }, expect.objectContaining({ user: 'user-123', conversationId: expect.any(String), @@ -209,12 +228,18 @@ describe('createResponse controller', () => { ); }); - it('should pass spendTokens and spendStructuredTokens as dependencies', async () => { + it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => { await createResponse(req, res); const [deps] = mockRecordCollectedUsage.mock.calls[0]; expect(deps).toHaveProperty('spendTokens', mockSpendTokens); expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens); + expect(deps).toHaveProperty('pricing'); + expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier); + expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier); + expect(deps).toHaveProperty('bulkWriteOps'); + expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions); + expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance); }); it('should include model from primaryConfig in recordCollectedUsage params', async () => { @@ -244,7 +269,15 @@ describe('createResponse controller', () => { expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); expect(mockRecordCollectedUsage).toHaveBeenCalledWith( - { spendTokens: mockSpendTokens, spendStructuredTokens: mockSpendStructuredTokens }, + { + spendTokens: mockSpendTokens, + spendStructuredTokens: mockSpendStructuredTokens, + pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier }, + bulkWriteOps: { + insertMany: mockBulkInsertTransactions, + updateBalance: mockUpdateBalance, + }, + }, expect.objectContaining({ user: 'user-123', context: 'message', diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index d69281d49c..5f99a0762b 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -13,11 +13,12 @@ const { createSafeUser, initializeAgent, getBalanceConfig, - getProviderConfig, omitTitleOptions, + getProviderConfig, memoryInstructions, - applyContextToAgent, createTokenCounter, + applyContextToAgent, + recordCollectedUsage, GenerationJobManager, getTransactionsConfig, createMemoryProcessor, @@ -45,6 +46,8 @@ const { } = require('librechat-data-provider'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); +const { updateBalance, bulkInsertTransactions } = require('~/models'); +const { getMultiplier, getCacheMultiplier } = require('~/models/tx'); const { createContextHandlers } = require('~/app/clients/prompts'); const { getConvoFiles } = require('~/models/Conversation'); const BaseClient = require('~/app/clients/BaseClient'); @@ -624,83 +627,29 @@ class AgentClient extends BaseClient { context = 'message', collectedUsage = this.collectedUsage, }) { - if (!collectedUsage || !collectedUsage.length) { - return; - } - // Use first entry's input_tokens as the base input (represents initial user message context) - // Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens) - const firstUsage = collectedUsage[0]; - const input_tokens = - (firstUsage?.input_tokens || 0) + - (Number(firstUsage?.input_token_details?.cache_creation) || - Number(firstUsage?.cache_creation_input_tokens) || - 0) + - (Number(firstUsage?.input_token_details?.cache_read) || - Number(firstUsage?.cache_read_input_tokens) || - 0); - - // Sum output_tokens directly from all entries - works for both sequential and parallel execution - // This avoids the incremental calculation that produced negative values for parallel agents - let total_output_tokens = 0; - - for (const usage of collectedUsage) { - if (!usage) { - continue; - } - - // Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens) - const cache_creation = - Number(usage.input_token_details?.cache_creation) || - Number(usage.cache_creation_input_tokens) || - 0; - const cache_read = - Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0; - - // Accumulate output tokens for the usage summary - total_output_tokens += Number(usage.output_tokens) || 0; - - const txMetadata = { + const result = await recordCollectedUsage( + { + spendTokens, + spendStructuredTokens, + pricing: { getMultiplier, getCacheMultiplier }, + bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance }, + }, + { + user: this.user ?? this.options.req.user?.id, + conversationId: this.conversationId, + collectedUsage, + model: model ?? this.model ?? this.options.agent.model_parameters.model, context, + messageId: this.responseMessageId, balance, transactions, - messageId: this.responseMessageId, - conversationId: this.conversationId, - user: this.user ?? this.options.req.user?.id, endpointTokenConfig: this.options.endpointTokenConfig, - model: usage.model ?? model ?? this.model ?? this.options.agent.model_parameters.model, - }; + }, + ); - if (cache_creation > 0 || cache_read > 0) { - spendStructuredTokens(txMetadata, { - promptTokens: { - input: usage.input_tokens, - write: cache_creation, - read: cache_read, - }, - completionTokens: usage.output_tokens, - }).catch((err) => { - logger.error( - '[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending structured tokens', - err, - ); - }); - continue; - } - spendTokens(txMetadata, { - promptTokens: usage.input_tokens, - completionTokens: usage.output_tokens, - }).catch((err) => { - logger.error( - '[api/server/controllers/agents/client.js #recordCollectedUsage] Error spending tokens', - err, - ); - }); + if (result) { + this.usage = result; } - - this.usage = { - input_tokens, - output_tokens: total_output_tokens, - }; } /** diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index a083bd9291..e8561f15fe 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -25,6 +25,7 @@ const { loadAgentTools, loadToolsForExecution } = require('~/server/services/Too const { createToolEndCallback } = require('~/server/controllers/agents/callbacks'); const { findAccessibleResources } = require('~/server/services/PermissionService'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); +const { getMultiplier, getCacheMultiplier } = require('~/models/tx'); const { getConvoFiles } = require('~/models/Conversation'); const { getAgent, getAgents } = require('~/models/Agent'); const db = require('~/models'); @@ -493,7 +494,12 @@ const OpenAIChatCompletionController = async (req, res) => { const balanceConfig = getBalanceConfig(appConfig); const transactionsConfig = getTransactionsConfig(appConfig); recordCollectedUsage( - { spendTokens, spendStructuredTokens }, + { + spendTokens, + spendStructuredTokens, + pricing: { getMultiplier, getCacheMultiplier }, + bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance }, + }, { user: userId, conversationId, diff --git a/api/server/controllers/agents/recordCollectedUsage.spec.js b/api/server/controllers/agents/recordCollectedUsage.spec.js index 6904f2ed39..21720023ca 100644 --- a/api/server/controllers/agents/recordCollectedUsage.spec.js +++ b/api/server/controllers/agents/recordCollectedUsage.spec.js @@ -2,23 +2,37 @@ * Tests for AgentClient.recordCollectedUsage * * This is a critical function that handles token spending for agent LLM calls. - * It must correctly handle: - * - Sequential execution (single agent with tool calls) - * - Parallel execution (multiple agents with independent inputs) - * - Cache token handling (OpenAI and Anthropic formats) + * The client now delegates to the TS recordCollectedUsage from @librechat/api, + * passing pricing and bulkWriteOps deps. */ const { EModelEndpoint } = require('librechat-data-provider'); -// Mock dependencies before requiring the module const mockSpendTokens = jest.fn().mockResolvedValue(); const mockSpendStructuredTokens = jest.fn().mockResolvedValue(); +const mockGetMultiplier = jest.fn().mockReturnValue(1); +const mockGetCacheMultiplier = jest.fn().mockReturnValue(null); +const mockUpdateBalance = jest.fn().mockResolvedValue({}); +const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); +const mockRecordCollectedUsage = jest + .fn() + .mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); jest.mock('~/models/spendTokens', () => ({ spendTokens: (...args) => mockSpendTokens(...args), spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args), })); +jest.mock('~/models/tx', () => ({ + getMultiplier: mockGetMultiplier, + getCacheMultiplier: mockGetCacheMultiplier, +})); + +jest.mock('~/models', () => ({ + updateBalance: mockUpdateBalance, + bulkInsertTransactions: mockBulkInsertTransactions, +})); + jest.mock('~/config', () => ({ logger: { debug: jest.fn(), @@ -39,6 +53,14 @@ jest.mock('@librechat/agents', () => ({ }), })); +jest.mock('@librechat/api', () => { + const actual = jest.requireActual('@librechat/api'); + return { + ...actual, + recordCollectedUsage: (...args) => mockRecordCollectedUsage(...args), + }; +}); + const AgentClient = require('./client'); describe('AgentClient - recordCollectedUsage', () => { @@ -74,31 +96,66 @@ describe('AgentClient - recordCollectedUsage', () => { }); describe('basic functionality', () => { - it('should return early if collectedUsage is empty', async () => { + it('should delegate to recordCollectedUsage with full deps', async () => { + const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + + await client.recordCollectedUsage({ + collectedUsage, + balance: { enabled: true }, + transactions: { enabled: true }, + }); + + expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); + const [deps, params] = mockRecordCollectedUsage.mock.calls[0]; + + expect(deps).toHaveProperty('spendTokens'); + expect(deps).toHaveProperty('spendStructuredTokens'); + expect(deps).toHaveProperty('pricing'); + expect(deps.pricing).toHaveProperty('getMultiplier'); + expect(deps.pricing).toHaveProperty('getCacheMultiplier'); + expect(deps).toHaveProperty('bulkWriteOps'); + expect(deps.bulkWriteOps).toHaveProperty('insertMany'); + expect(deps.bulkWriteOps).toHaveProperty('updateBalance'); + + expect(params).toEqual( + expect.objectContaining({ + user: 'user-123', + conversationId: 'convo-123', + collectedUsage, + context: 'message', + balance: { enabled: true }, + transactions: { enabled: true }, + }), + ); + }); + + it('should not set this.usage if collectedUsage is empty (returns undefined)', async () => { + mockRecordCollectedUsage.mockResolvedValue(undefined); + await client.recordCollectedUsage({ collectedUsage: [], balance: { enabled: true }, transactions: { enabled: true }, }); - expect(mockSpendTokens).not.toHaveBeenCalled(); - expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); expect(client.usage).toBeUndefined(); }); - it('should return early if collectedUsage is null', async () => { + it('should not set this.usage if collectedUsage is null (returns undefined)', async () => { + mockRecordCollectedUsage.mockResolvedValue(undefined); + await client.recordCollectedUsage({ collectedUsage: null, balance: { enabled: true }, transactions: { enabled: true }, }); - expect(mockSpendTokens).not.toHaveBeenCalled(); expect(client.usage).toBeUndefined(); }); - it('should handle single usage entry correctly', async () => { - const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; + it('should set this.usage from recordCollectedUsage result', async () => { + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 75 }); + const collectedUsage = [{ input_tokens: 200, output_tokens: 75, model: 'gpt-4' }]; await client.recordCollectedUsage({ collectedUsage, @@ -106,521 +163,122 @@ describe('AgentClient - recordCollectedUsage', () => { transactions: { enabled: true }, }); - expect(mockSpendTokens).toHaveBeenCalledTimes(1); - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ - conversationId: 'convo-123', - user: 'user-123', - model: 'gpt-4', - }), - { promptTokens: 100, completionTokens: 50 }, - ); - expect(client.usage.input_tokens).toBe(100); - expect(client.usage.output_tokens).toBe(50); - }); - - it('should skip null entries in collectedUsage', async () => { - const collectedUsage = [ - { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, - null, - { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(mockSpendTokens).toHaveBeenCalledTimes(2); + expect(client.usage).toEqual({ input_tokens: 200, output_tokens: 75 }); }); }); describe('sequential execution (single agent with tool calls)', () => { - it('should calculate tokens correctly for sequential tool calls', async () => { - // Sequential flow: output of call N becomes part of input for call N+1 - // Call 1: input=100, output=50 - // Call 2: input=150 (100+50), output=30 - // Call 3: input=180 (150+30), output=20 + it('should pass all usage entries to recordCollectedUsage', async () => { const collectedUsage = [ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, { input_tokens: 150, output_tokens: 30, model: 'gpt-4' }, { input_tokens: 180, output_tokens: 20, model: 'gpt-4' }, ]; + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 100 }); + await client.recordCollectedUsage({ collectedUsage, balance: { enabled: true }, transactions: { enabled: true }, }); - expect(mockSpendTokens).toHaveBeenCalledTimes(3); - // Total output should be sum of all output_tokens: 50 + 30 + 20 = 100 + expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); + const [, params] = mockRecordCollectedUsage.mock.calls[0]; + expect(params.collectedUsage).toHaveLength(3); expect(client.usage.output_tokens).toBe(100); - expect(client.usage.input_tokens).toBe(100); // First entry's input + expect(client.usage.input_tokens).toBe(100); }); }); describe('parallel execution (multiple agents)', () => { - it('should handle parallel agents with independent input tokens', async () => { - // Parallel agents have INDEPENDENT input tokens (not cumulative) - // Agent A: input=100, output=50 - // Agent B: input=80, output=40 (different context, not 100+50) + it('should pass parallel agent usage to recordCollectedUsage', async () => { const collectedUsage = [ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, { input_tokens: 80, output_tokens: 40, model: 'gpt-4' }, ]; + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 90 }); + await client.recordCollectedUsage({ collectedUsage, balance: { enabled: true }, transactions: { enabled: true }, }); - expect(mockSpendTokens).toHaveBeenCalledTimes(2); - // Expected total output: 50 + 40 = 90 - // output_tokens must be positive and should reflect total output + expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); + expect(client.usage.output_tokens).toBe(90); expect(client.usage.output_tokens).toBeGreaterThan(0); }); - it('should NOT produce negative output_tokens for parallel execution', async () => { - // Critical bug scenario: parallel agents where second agent has LOWER input tokens + /** Bug regression: parallel agents where second agent has LOWER input tokens produced negative output via incremental calculation. */ + it('should NOT produce negative output_tokens', async () => { const collectedUsage = [ { input_tokens: 200, output_tokens: 100, model: 'gpt-4' }, { input_tokens: 50, output_tokens: 30, model: 'gpt-4' }, ]; + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 }); + await client.recordCollectedUsage({ collectedUsage, balance: { enabled: true }, transactions: { enabled: true }, }); - // output_tokens MUST be positive for proper token tracking expect(client.usage.output_tokens).toBeGreaterThan(0); - // Correct value should be 100 + 30 = 130 - }); - - it('should calculate correct total output for parallel agents', async () => { - // Three parallel agents with independent contexts - const collectedUsage = [ - { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, - { input_tokens: 120, output_tokens: 60, model: 'gpt-4-turbo' }, - { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(mockSpendTokens).toHaveBeenCalledTimes(3); - // Total output should be 50 + 60 + 40 = 150 - expect(client.usage.output_tokens).toBe(150); - }); - - it('should handle worst-case parallel scenario without negative tokens', async () => { - // Extreme case: first agent has very high input, subsequent have low - const collectedUsage = [ - { input_tokens: 1000, output_tokens: 500, model: 'gpt-4' }, - { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, - { input_tokens: 50, output_tokens: 25, model: 'gpt-4' }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - // Must be positive, should be 500 + 50 + 25 = 575 - expect(client.usage.output_tokens).toBeGreaterThan(0); - expect(client.usage.output_tokens).toBe(575); + expect(client.usage.output_tokens).toBe(130); }); }); describe('real-world scenarios', () => { - it('should correctly sum output tokens for sequential tool calls with growing context', async () => { - // Real production data: Claude Opus with multiple tool calls - // Context grows as tool results are added, but output_tokens should only count model generations + it('should correctly handle sequential tool calls with growing context', async () => { const collectedUsage = [ - { - input_tokens: 31596, - output_tokens: 151, - total_tokens: 31747, - input_token_details: { cache_read: 0, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 35368, - output_tokens: 150, - total_tokens: 35518, - input_token_details: { cache_read: 0, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 58362, - output_tokens: 295, - total_tokens: 58657, - input_token_details: { cache_read: 0, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 112604, - output_tokens: 193, - total_tokens: 112797, - input_token_details: { cache_read: 0, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 257440, - output_tokens: 2217, - total_tokens: 259657, - input_token_details: { cache_read: 0, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, + { input_tokens: 31596, output_tokens: 151, model: 'claude-opus-4-5-20251101' }, + { input_tokens: 35368, output_tokens: 150, model: 'claude-opus-4-5-20251101' }, + { input_tokens: 58362, output_tokens: 295, model: 'claude-opus-4-5-20251101' }, + { input_tokens: 112604, output_tokens: 193, model: 'claude-opus-4-5-20251101' }, + { input_tokens: 257440, output_tokens: 2217, model: 'claude-opus-4-5-20251101' }, ]; + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 3006 }); + await client.recordCollectedUsage({ collectedUsage, balance: { enabled: true }, transactions: { enabled: true }, }); - // input_tokens should be first entry's input (initial context) expect(client.usage.input_tokens).toBe(31596); - - // output_tokens should be sum of all model outputs: 151 + 150 + 295 + 193 + 2217 = 3006 - // NOT the inflated value from incremental calculation (338,559) expect(client.usage.output_tokens).toBe(3006); - - // Verify spendTokens was called for each entry with correct values - expect(mockSpendTokens).toHaveBeenCalledTimes(5); - expect(mockSpendTokens).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), - { promptTokens: 31596, completionTokens: 151 }, - ); - expect(mockSpendTokens).toHaveBeenNthCalledWith( - 5, - expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), - { promptTokens: 257440, completionTokens: 2217 }, - ); }); - it('should handle single followup message correctly', async () => { - // Real production data: followup to the above conversation - const collectedUsage = [ - { - input_tokens: 263406, - output_tokens: 257, - total_tokens: 263663, - input_token_details: { cache_read: 0, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(client.usage.input_tokens).toBe(263406); - expect(client.usage.output_tokens).toBe(257); - - expect(mockSpendTokens).toHaveBeenCalledTimes(1); - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), - { promptTokens: 263406, completionTokens: 257 }, - ); - }); - - it('should ensure output_tokens > 0 check passes for BaseClient.sendMessage', async () => { - // This verifies the fix for the duplicate token spending bug - // BaseClient.sendMessage checks: if (usage != null && Number(usage[this.outputTokensKey]) > 0) - const collectedUsage = [ - { - input_tokens: 31596, - output_tokens: 151, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 35368, - output_tokens: 150, - model: 'claude-opus-4-5-20251101', - }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - const usage = client.getStreamUsage(); - - // The check that was failing before the fix - expect(usage).not.toBeNull(); - expect(Number(usage.output_tokens)).toBeGreaterThan(0); - - // Verify correct value - expect(usage.output_tokens).toBe(301); // 151 + 150 - }); - - it('should correctly handle cache tokens with multiple tool calls', async () => { - // Real production data: Claude Opus with cache tokens (prompt caching) - // First entry has cache_creation, subsequent entries have cache_read + it('should correctly handle cache tokens', async () => { const collectedUsage = [ { input_tokens: 788, output_tokens: 163, - total_tokens: 951, input_token_details: { cache_read: 0, cache_creation: 30808 }, model: 'claude-opus-4-5-20251101', }, - { - input_tokens: 3802, - output_tokens: 149, - total_tokens: 3951, - input_token_details: { cache_read: 30808, cache_creation: 768 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 26808, - output_tokens: 225, - total_tokens: 27033, - input_token_details: { cache_read: 31576, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 80912, - output_tokens: 204, - total_tokens: 81116, - input_token_details: { cache_read: 31576, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 136454, - output_tokens: 206, - total_tokens: 136660, - input_token_details: { cache_read: 31576, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 146316, - output_tokens: 224, - total_tokens: 146540, - input_token_details: { cache_read: 31576, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 150402, - output_tokens: 1248, - total_tokens: 151650, - input_token_details: { cache_read: 31576, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 156268, - output_tokens: 139, - total_tokens: 156407, - input_token_details: { cache_read: 31576, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, - { - input_tokens: 167126, - output_tokens: 2961, - total_tokens: 170087, - input_token_details: { cache_read: 31576, cache_creation: 0 }, - model: 'claude-opus-4-5-20251101', - }, ]; + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 163 }); + await client.recordCollectedUsage({ collectedUsage, balance: { enabled: true }, transactions: { enabled: true }, }); - // input_tokens = first entry's input + cache_creation + cache_read - // = 788 + 30808 + 0 = 31596 expect(client.usage.input_tokens).toBe(31596); - - // output_tokens = sum of all output_tokens - // = 163 + 149 + 225 + 204 + 206 + 224 + 1248 + 139 + 2961 = 5519 - expect(client.usage.output_tokens).toBe(5519); - - // First 2 entries have cache tokens, should use spendStructuredTokens - // Remaining 7 entries have cache_read but no cache_creation, still structured - expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(9); - expect(mockSpendTokens).toHaveBeenCalledTimes(0); - - // Verify first entry uses structured tokens with cache_creation - expect(mockSpendStructuredTokens).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), - { - promptTokens: { input: 788, write: 30808, read: 0 }, - completionTokens: 163, - }, - ); - - // Verify second entry uses structured tokens with both cache_creation and cache_read - expect(mockSpendStructuredTokens).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ model: 'claude-opus-4-5-20251101' }), - { - promptTokens: { input: 3802, write: 768, read: 30808 }, - completionTokens: 149, - }, - ); - }); - }); - - describe('cache token handling', () => { - it('should handle OpenAI format cache tokens (input_token_details)', async () => { - const collectedUsage = [ - { - input_tokens: 100, - output_tokens: 50, - model: 'gpt-4', - input_token_details: { - cache_creation: 20, - cache_read: 10, - }, - }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); - expect(mockSpendStructuredTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'gpt-4' }), - { - promptTokens: { - input: 100, - write: 20, - read: 10, - }, - completionTokens: 50, - }, - ); - }); - - it('should handle Anthropic format cache tokens (cache_*_input_tokens)', async () => { - const collectedUsage = [ - { - input_tokens: 100, - output_tokens: 50, - model: 'claude-3', - cache_creation_input_tokens: 25, - cache_read_input_tokens: 15, - }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); - expect(mockSpendStructuredTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'claude-3' }), - { - promptTokens: { - input: 100, - write: 25, - read: 15, - }, - completionTokens: 50, - }, - ); - }); - - it('should use spendTokens for entries without cache tokens', async () => { - const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(mockSpendTokens).toHaveBeenCalledTimes(1); - expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); - }); - - it('should handle mixed cache and non-cache entries', async () => { - const collectedUsage = [ - { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, - { - input_tokens: 150, - output_tokens: 30, - model: 'gpt-4', - input_token_details: { cache_creation: 10, cache_read: 5 }, - }, - { input_tokens: 200, output_tokens: 20, model: 'gpt-4' }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(mockSpendTokens).toHaveBeenCalledTimes(2); - expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); - }); - - it('should include cache tokens in total input calculation', async () => { - const collectedUsage = [ - { - input_tokens: 100, - output_tokens: 50, - model: 'gpt-4', - input_token_details: { - cache_creation: 20, - cache_read: 10, - }, - }, - ]; - - await client.recordCollectedUsage({ - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - // Total input should include cache tokens: 100 + 20 + 10 = 130 - expect(client.usage.input_tokens).toBe(130); + expect(client.usage.output_tokens).toBe(163); }); }); describe('model fallback', () => { - it('should use usage.model when available', async () => { - const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4-turbo' }]; - - await client.recordCollectedUsage({ - model: 'fallback-model', - collectedUsage, - balance: { enabled: true }, - transactions: { enabled: true }, - }); - - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'gpt-4-turbo' }), - expect.any(Object), - ); - }); - - it('should fallback to param model when usage.model is missing', async () => { + it('should use param model when available', async () => { + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; await client.recordCollectedUsage({ @@ -630,14 +288,13 @@ describe('AgentClient - recordCollectedUsage', () => { transactions: { enabled: true }, }); - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'param-model' }), - expect.any(Object), - ); + const [, params] = mockRecordCollectedUsage.mock.calls[0]; + expect(params.model).toBe('param-model'); }); it('should fallback to client.model when param model is missing', async () => { client.model = 'client-model'; + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; await client.recordCollectedUsage({ @@ -646,13 +303,12 @@ describe('AgentClient - recordCollectedUsage', () => { transactions: { enabled: true }, }); - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'client-model' }), - expect.any(Object), - ); + const [, params] = mockRecordCollectedUsage.mock.calls[0]; + expect(params.model).toBe('client-model'); }); it('should fallback to agent model_parameters.model as last resort', async () => { + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; await client.recordCollectedUsage({ @@ -661,15 +317,14 @@ describe('AgentClient - recordCollectedUsage', () => { transactions: { enabled: true }, }); - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'gpt-4' }), - expect.any(Object), - ); + const [, params] = mockRecordCollectedUsage.mock.calls[0]; + expect(params.model).toBe('gpt-4'); }); }); describe('getStreamUsage integration', () => { it('should return the usage object set by recordCollectedUsage', async () => { + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; await client.recordCollectedUsage({ @@ -679,10 +334,7 @@ describe('AgentClient - recordCollectedUsage', () => { }); const usage = client.getStreamUsage(); - expect(usage).toEqual({ - input_tokens: 100, - output_tokens: 50, - }); + expect(usage).toEqual({ input_tokens: 100, output_tokens: 50 }); }); it('should return undefined before recordCollectedUsage is called', () => { @@ -690,9 +342,9 @@ describe('AgentClient - recordCollectedUsage', () => { expect(usage).toBeUndefined(); }); + /** Verifies usage passes the check in BaseClient.sendMessage: if (usage != null && Number(usage[this.outputTokensKey]) > 0) */ it('should have output_tokens > 0 for BaseClient.sendMessage check', async () => { - // This test verifies the usage will pass the check in BaseClient.sendMessage: - // if (usage != null && Number(usage[this.outputTokensKey]) > 0) + mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 }); const collectedUsage = [ { input_tokens: 200, output_tokens: 100, model: 'gpt-4' }, { input_tokens: 50, output_tokens: 30, model: 'gpt-4' }, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 8ce15766c7..83e6ad6efd 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -38,6 +38,7 @@ const { loadAgentTools, loadToolsForExecution } = require('~/server/services/Too const { findAccessibleResources } = require('~/server/services/PermissionService'); const { getConvoFiles, saveConvo, getConvo } = require('~/models/Conversation'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); +const { getMultiplier, getCacheMultiplier } = require('~/models/tx'); const { getAgent, getAgents } = require('~/models/Agent'); const db = require('~/models'); @@ -509,7 +510,12 @@ const createResponse = async (req, res) => { const balanceConfig = getBalanceConfig(req.config); const transactionsConfig = getTransactionsConfig(req.config); recordCollectedUsage( - { spendTokens, spendStructuredTokens }, + { + spendTokens, + spendStructuredTokens, + pricing: { getMultiplier, getCacheMultiplier }, + bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance }, + }, { user: userId, conversationId, @@ -658,7 +664,12 @@ const createResponse = async (req, res) => { const balanceConfig = getBalanceConfig(req.config); const transactionsConfig = getTransactionsConfig(req.config); recordCollectedUsage( - { spendTokens, spendStructuredTokens }, + { + spendTokens, + spendStructuredTokens, + pricing: { getMultiplier, getCacheMultiplier }, + bulkWriteOps: { insertMany: db.bulkInsertTransactions, updateBalance: db.updateBalance }, + }, { user: userId, conversationId, diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index acc9299b04..d39b0104a8 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -1,17 +1,19 @@ const { logger } = require('@librechat/data-schemas'); const { - countTokens, isEnabled, sendEvent, + countTokens, GenerationJobManager, + recordCollectedUsage, sanitizeMessageForTransmit, } = require('@librechat/api'); const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider'); +const { saveMessage, getConvo, updateBalance, bulkInsertTransactions } = require('~/models'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); +const { getMultiplier, getCacheMultiplier } = require('~/models/tx'); const clearPendingReq = require('~/cache/clearPendingReq'); const { sendError } = require('~/server/middleware/error'); -const { saveMessage, getConvo } = require('~/models'); const { abortRun } = require('./abortRun'); /** @@ -40,57 +42,22 @@ async function spendCollectedUsage({ return; } - const spendPromises = []; - - for (const usage of collectedUsage) { - if (!usage) { - continue; - } - - // Support both OpenAI format (input_token_details) and Anthropic format (cache_*_input_tokens) - const cache_creation = - Number(usage.input_token_details?.cache_creation) || - Number(usage.cache_creation_input_tokens) || - 0; - const cache_read = - Number(usage.input_token_details?.cache_read) || Number(usage.cache_read_input_tokens) || 0; - - const txMetadata = { + await recordCollectedUsage( + { + spendTokens, + spendStructuredTokens, + pricing: { getMultiplier, getCacheMultiplier }, + bulkWriteOps: { insertMany: bulkInsertTransactions, updateBalance }, + }, + { + user: userId, + conversationId, + collectedUsage, context: 'abort', messageId, - conversationId, - user: userId, - model: usage.model ?? fallbackModel, - }; - - if (cache_creation > 0 || cache_read > 0) { - spendPromises.push( - spendStructuredTokens(txMetadata, { - promptTokens: { - input: usage.input_tokens, - write: cache_creation, - read: cache_read, - }, - completionTokens: usage.output_tokens, - }).catch((err) => { - logger.error('[abortMiddleware] Error spending structured tokens for abort', err); - }), - ); - continue; - } - - spendPromises.push( - spendTokens(txMetadata, { - promptTokens: usage.input_tokens, - completionTokens: usage.output_tokens, - }).catch((err) => { - logger.error('[abortMiddleware] Error spending tokens for abort', err); - }), - ); - } - - // Wait for all token spending to complete - await Promise.all(spendPromises); + model: fallbackModel, + }, + ); // Clear the array to prevent double-spending from the AgentClient finally block. // The collectedUsage array is shared by reference with AgentClient.collectedUsage, @@ -301,4 +268,5 @@ const handleAbortError = async (res, req, error, data) => { module.exports = { handleAbort, handleAbortError, + spendCollectedUsage, }; diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js index 93f2ce558b..795814a928 100644 --- a/api/server/middleware/abortMiddleware.spec.js +++ b/api/server/middleware/abortMiddleware.spec.js @@ -4,16 +4,32 @@ * This tests the token spending logic for abort scenarios, * particularly for parallel agents (addedConvo) where multiple * models need their tokens spent. + * + * spendCollectedUsage delegates to recordCollectedUsage from @librechat/api, + * passing pricing + bulkWriteOps deps, with context: 'abort'. + * After spending, it clears the collectedUsage array to prevent double-spending + * from the AgentClient finally block (which shares the same array reference). */ const mockSpendTokens = jest.fn().mockResolvedValue(); const mockSpendStructuredTokens = jest.fn().mockResolvedValue(); +const mockRecordCollectedUsage = jest + .fn() + .mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); + +const mockGetMultiplier = jest.fn().mockReturnValue(1); +const mockGetCacheMultiplier = jest.fn().mockReturnValue(null); jest.mock('~/models/spendTokens', () => ({ spendTokens: (...args) => mockSpendTokens(...args), spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args), })); +jest.mock('~/models/tx', () => ({ + getMultiplier: mockGetMultiplier, + getCacheMultiplier: mockGetCacheMultiplier, +})); + jest.mock('@librechat/data-schemas', () => ({ logger: { debug: jest.fn(), @@ -30,6 +46,7 @@ jest.mock('@librechat/api', () => ({ GenerationJobManager: { abortJob: jest.fn(), }, + recordCollectedUsage: mockRecordCollectedUsage, sanitizeMessageForTransmit: jest.fn((msg) => msg), })); @@ -49,94 +66,27 @@ jest.mock('~/server/middleware/error', () => ({ sendError: jest.fn(), })); +const mockUpdateBalance = jest.fn().mockResolvedValue({}); +const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); jest.mock('~/models', () => ({ saveMessage: jest.fn().mockResolvedValue(), getConvo: jest.fn().mockResolvedValue({ title: 'Test Chat' }), + updateBalance: mockUpdateBalance, + bulkInsertTransactions: mockBulkInsertTransactions, })); jest.mock('./abortRun', () => ({ abortRun: jest.fn(), })); -// Import the module after mocks are set up -// We need to extract the spendCollectedUsage function for testing -// Since it's not exported, we'll test it through the handleAbort flow +const { spendCollectedUsage } = require('./abortMiddleware'); describe('abortMiddleware - spendCollectedUsage', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('spendCollectedUsage logic', () => { - // Since spendCollectedUsage is not exported, we test the logic directly - // by replicating the function here for unit testing - - const spendCollectedUsage = async ({ - userId, - conversationId, - collectedUsage, - fallbackModel, - }) => { - if (!collectedUsage || collectedUsage.length === 0) { - return; - } - - const spendPromises = []; - - for (const usage of collectedUsage) { - if (!usage) { - continue; - } - - const cache_creation = - Number(usage.input_token_details?.cache_creation) || - Number(usage.cache_creation_input_tokens) || - 0; - const cache_read = - Number(usage.input_token_details?.cache_read) || - Number(usage.cache_read_input_tokens) || - 0; - - const txMetadata = { - context: 'abort', - conversationId, - user: userId, - model: usage.model ?? fallbackModel, - }; - - if (cache_creation > 0 || cache_read > 0) { - spendPromises.push( - mockSpendStructuredTokens(txMetadata, { - promptTokens: { - input: usage.input_tokens, - write: cache_creation, - read: cache_read, - }, - completionTokens: usage.output_tokens, - }).catch(() => { - // Log error but don't throw - }), - ); - continue; - } - - spendPromises.push( - mockSpendTokens(txMetadata, { - promptTokens: usage.input_tokens, - completionTokens: usage.output_tokens, - }).catch(() => { - // Log error but don't throw - }), - ); - } - - // Wait for all token spending to complete - await Promise.all(spendPromises); - - // Clear the array to prevent double-spending - collectedUsage.length = 0; - }; - + describe('spendCollectedUsage delegation', () => { it('should return early if collectedUsage is empty', async () => { await spendCollectedUsage({ userId: 'user-123', @@ -145,8 +95,7 @@ describe('abortMiddleware - spendCollectedUsage', () => { fallbackModel: 'gpt-4', }); - expect(mockSpendTokens).not.toHaveBeenCalled(); - expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(mockRecordCollectedUsage).not.toHaveBeenCalled(); }); it('should return early if collectedUsage is null', async () => { @@ -157,28 +106,10 @@ describe('abortMiddleware - spendCollectedUsage', () => { fallbackModel: 'gpt-4', }); - expect(mockSpendTokens).not.toHaveBeenCalled(); - expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(mockRecordCollectedUsage).not.toHaveBeenCalled(); }); - it('should skip null entries in collectedUsage', async () => { - const collectedUsage = [ - { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, - null, - { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, - ]; - - await spendCollectedUsage({ - userId: 'user-123', - conversationId: 'convo-123', - collectedUsage, - fallbackModel: 'gpt-4', - }); - - expect(mockSpendTokens).toHaveBeenCalledTimes(2); - }); - - it('should spend tokens for single model', async () => { + it('should call recordCollectedUsage with abort context and full deps', async () => { const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }]; await spendCollectedUsage({ @@ -186,21 +117,35 @@ describe('abortMiddleware - spendCollectedUsage', () => { conversationId: 'convo-123', collectedUsage, fallbackModel: 'gpt-4', + messageId: 'msg-123', }); - expect(mockSpendTokens).toHaveBeenCalledTimes(1); - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ - context: 'abort', - conversationId: 'convo-123', + expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); + expect(mockRecordCollectedUsage).toHaveBeenCalledWith( + { + spendTokens: expect.any(Function), + spendStructuredTokens: expect.any(Function), + pricing: { + getMultiplier: mockGetMultiplier, + getCacheMultiplier: mockGetCacheMultiplier, + }, + bulkWriteOps: { + insertMany: mockBulkInsertTransactions, + updateBalance: mockUpdateBalance, + }, + }, + { user: 'user-123', + conversationId: 'convo-123', + collectedUsage, + context: 'abort', + messageId: 'msg-123', model: 'gpt-4', - }), - { promptTokens: 100, completionTokens: 50 }, + }, ); }); - it('should spend tokens for multiple models (parallel agents)', async () => { + it('should pass context abort for multiple models (parallel agents)', async () => { const collectedUsage = [ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, @@ -214,136 +159,17 @@ describe('abortMiddleware - spendCollectedUsage', () => { fallbackModel: 'gpt-4', }); - expect(mockSpendTokens).toHaveBeenCalledTimes(3); - - // Verify each model was called - expect(mockSpendTokens).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ model: 'gpt-4' }), - { promptTokens: 100, completionTokens: 50 }, - ); - expect(mockSpendTokens).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ model: 'claude-3' }), - { promptTokens: 80, completionTokens: 40 }, - ); - expect(mockSpendTokens).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ model: 'gemini-pro' }), - { promptTokens: 120, completionTokens: 60 }, - ); - }); - - it('should use fallbackModel when usage.model is missing', async () => { - const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }]; - - await spendCollectedUsage({ - userId: 'user-123', - conversationId: 'convo-123', - collectedUsage, - fallbackModel: 'fallback-model', - }); - - expect(mockSpendTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'fallback-model' }), + expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); + expect(mockRecordCollectedUsage).toHaveBeenCalledWith( expect.any(Object), + expect.objectContaining({ + context: 'abort', + collectedUsage, + }), ); }); - it('should use spendStructuredTokens for OpenAI format cache tokens', async () => { - const collectedUsage = [ - { - input_tokens: 100, - output_tokens: 50, - model: 'gpt-4', - input_token_details: { - cache_creation: 20, - cache_read: 10, - }, - }, - ]; - - await spendCollectedUsage({ - userId: 'user-123', - conversationId: 'convo-123', - collectedUsage, - fallbackModel: 'gpt-4', - }); - - expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); - expect(mockSpendTokens).not.toHaveBeenCalled(); - expect(mockSpendStructuredTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'gpt-4', context: 'abort' }), - { - promptTokens: { - input: 100, - write: 20, - read: 10, - }, - completionTokens: 50, - }, - ); - }); - - it('should use spendStructuredTokens for Anthropic format cache tokens', async () => { - const collectedUsage = [ - { - input_tokens: 100, - output_tokens: 50, - model: 'claude-3', - cache_creation_input_tokens: 25, - cache_read_input_tokens: 15, - }, - ]; - - await spendCollectedUsage({ - userId: 'user-123', - conversationId: 'convo-123', - collectedUsage, - fallbackModel: 'claude-3', - }); - - expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); - expect(mockSpendTokens).not.toHaveBeenCalled(); - expect(mockSpendStructuredTokens).toHaveBeenCalledWith( - expect.objectContaining({ model: 'claude-3' }), - { - promptTokens: { - input: 100, - write: 25, - read: 15, - }, - completionTokens: 50, - }, - ); - }); - - it('should handle mixed cache and non-cache entries', async () => { - const collectedUsage = [ - { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, - { - input_tokens: 150, - output_tokens: 30, - model: 'claude-3', - cache_creation_input_tokens: 20, - cache_read_input_tokens: 10, - }, - { input_tokens: 200, output_tokens: 20, model: 'gemini-pro' }, - ]; - - await spendCollectedUsage({ - userId: 'user-123', - conversationId: 'convo-123', - collectedUsage, - fallbackModel: 'gpt-4', - }); - - expect(mockSpendTokens).toHaveBeenCalledTimes(2); - expect(mockSpendStructuredTokens).toHaveBeenCalledTimes(1); - }); - it('should handle real-world parallel agent abort scenario', async () => { - // Simulates: Primary agent (gemini) + addedConvo agent (gpt-5) aborted mid-stream const collectedUsage = [ { input_tokens: 31596, output_tokens: 151, model: 'gemini-3-flash-preview' }, { input_tokens: 28000, output_tokens: 120, model: 'gpt-5.2' }, @@ -356,27 +182,24 @@ describe('abortMiddleware - spendCollectedUsage', () => { fallbackModel: 'gemini-3-flash-preview', }); - expect(mockSpendTokens).toHaveBeenCalledTimes(2); - - // Primary model - expect(mockSpendTokens).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ model: 'gemini-3-flash-preview' }), - { promptTokens: 31596, completionTokens: 151 }, - ); - - // Parallel model (addedConvo) - expect(mockSpendTokens).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ model: 'gpt-5.2' }), - { promptTokens: 28000, completionTokens: 120 }, + expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); + expect(mockRecordCollectedUsage).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + user: 'user-123', + conversationId: 'convo-123', + context: 'abort', + model: 'gemini-3-flash-preview', + }), ); }); + /** + * Race condition prevention: after abort middleware spends tokens, + * the collectedUsage array is cleared so AgentClient.recordCollectedUsage() + * (which shares the same array reference) sees an empty array and returns early. + */ it('should clear collectedUsage array after spending to prevent double-spending', async () => { - // This tests the race condition fix: after abort middleware spends tokens, - // the collectedUsage array is cleared so AgentClient.recordCollectedUsage() - // (which shares the same array reference) sees an empty array and returns early. const collectedUsage = [ { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, @@ -391,19 +214,16 @@ describe('abortMiddleware - spendCollectedUsage', () => { fallbackModel: 'gpt-4', }); - expect(mockSpendTokens).toHaveBeenCalledTimes(2); - - // The array should be cleared after spending + expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1); expect(collectedUsage.length).toBe(0); }); - it('should await all token spending operations before clearing array', async () => { - // Ensure we don't clear the array before spending completes - let spendCallCount = 0; - mockSpendTokens.mockImplementation(async () => { - spendCallCount++; - // Simulate async delay + it('should await recordCollectedUsage before clearing array', async () => { + let resolved = false; + mockRecordCollectedUsage.mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); + resolved = true; + return { input_tokens: 100, output_tokens: 50 }; }); const collectedUsage = [ @@ -418,10 +238,7 @@ describe('abortMiddleware - spendCollectedUsage', () => { fallbackModel: 'gpt-4', }); - // Both spend calls should have completed - expect(spendCallCount).toBe(2); - - // Array should be cleared after awaiting + expect(resolved).toBe(true); expect(collectedUsage.length).toBe(0); }); }); diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index a695a31555..2ddfa76658 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -1277,12 +1277,9 @@ describe('processAssistantMessage', () => { results.push(duration); }); - // Check if processing time increases exponentially - // In a ReDoS vulnerability, time would roughly double with each size increase - for (let i = 1; i < results.length; i++) { - const ratio = results[i] / results[i - 1]; - expect(ratio).toBeLessThan(3); // Allow for CI environment variability while still catching ReDoS - console.log(`Size ${sizes[i]} processing time ratio: ${ratio}`); + // Each size should complete well under 100ms; a ReDoS would cause exponential blowup + for (let i = 0; i < results.length; i++) { + expect(results[i]).toBeLessThan(100); } // Also test with the exact payload from the security report diff --git a/package-lock.json b/package-lock.json index c03ef33c8d..2b90bbec3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.53", + "@librechat/agents": "^3.1.54", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11836,9 +11836,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.53", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.53.tgz", - "integrity": "sha512-jK9JHIhQYgr+Ha2FhknEYQmS6Ft3/TGdYIlL6L6EtIq20SIA59r1DvQx/x9sd3wHoHkk6AZumMgqAUTTCaWBIA==", + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.54.tgz", + "integrity": "sha512-OdsE8kDgtIhxs0sR0rG7I5WynbZKAH/j/50OCZEjLdv//jR8Lj6fpL9RCEzRGnu44MjUZgRkSgf2JV3LHsCJiQ==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -43797,7 +43797,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.53", + "@librechat/agents": "^3.1.54", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/package.json b/packages/api/package.json index 3ceaeb7a12..f2529ecea5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -90,7 +90,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.53", + "@librechat/agents": "^3.1.54", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.27.1", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 9d13b3dd8e..47e15b8c28 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -9,6 +9,7 @@ export * from './legacy'; export * from './memory'; export * from './migration'; export * from './openai'; +export * from './transactions'; export * from './usage'; export * from './resources'; export * from './responses'; diff --git a/packages/api/src/agents/transactions.bulk-parity.spec.ts b/packages/api/src/agents/transactions.bulk-parity.spec.ts new file mode 100644 index 0000000000..bf89682d6f --- /dev/null +++ b/packages/api/src/agents/transactions.bulk-parity.spec.ts @@ -0,0 +1,559 @@ +/** + * Real-DB parity tests for the bulk transaction path. + * + * Each test uses the actual getMultiplier/getCacheMultiplier pricing functions + * (the same ones the legacy createTransaction path uses) and runs the bulk path + * against a real MongoMemoryServer instance. + * + * The assertion pattern: compute the expected tokenValue/rate/rawAmount from the + * pricing functions directly, then verify the DB state matches exactly. Since both + * legacy (createTransaction) and bulk (prepareTokenSpend + bulkWriteTransactions) + * call the same pricing functions with the same inputs, their outputs must be + * numerically identical. + */ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { + CANCEL_RATE, + createMethods, + balanceSchema, + transactionSchema, +} from '@librechat/data-schemas'; +import type { PricingFns, TxMetadata } from './transactions'; +import { + prepareStructuredTokenSpend, + bulkWriteTransactions, + prepareTokenSpend, +} from './transactions'; + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn(), info: jest.fn() }, + }; +}); + +// Real pricing functions from api/models/tx.js — same ones the legacy path uses +/* eslint-disable @typescript-eslint/no-require-imports */ +const { + getMultiplier, + getCacheMultiplier, + tokenValues, + premiumTokenValues, +} = require('../../../../api/models/tx.js'); +/* eslint-enable @typescript-eslint/no-require-imports */ + +const pricing: PricingFns = { getMultiplier, getCacheMultiplier }; + +let mongoServer: MongoMemoryServer; +let Transaction: mongoose.Model; +let Balance: mongoose.Model; +let dbMethods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + Transaction = mongoose.models.Transaction || mongoose.model('Transaction', transactionSchema); + Balance = mongoose.models.Balance || mongoose.model('Balance', balanceSchema); + dbMethods = createMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +const dbOps = () => ({ + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, +}); + +function txMeta(user: string, extra: Partial = {}): TxMetadata { + return { + user, + conversationId: 'test-convo', + context: 'test', + balance: { enabled: true }, + transactions: { enabled: true }, + ...extra, + }; +} + +describe('Standard token parity', () => { + test('balance should decrease by promptCost + completionCost — identical to legacy path', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + const promptTokens = 100; + const completionTokens = 50; + + const promptMultiplier = getMultiplier({ + model, + tokenType: 'prompt', + inputTokenCount: promptTokens, + }); + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: promptTokens, + }); + const expectedCost = promptTokens * promptMultiplier + completionTokens * completionMultiplier; + const expectedBalance = initialBalance - expectedCost; + + const entries = prepareTokenSpend( + txMeta(userId, { model }), + { promptTokens, completionTokens }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBeCloseTo(expectedBalance, 0); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + expect(txns).toHaveLength(2); + const promptTx = txns.find((t) => t.tokenType === 'prompt'); + const completionTx = txns.find((t) => t.tokenType === 'completion'); + expect(promptTx!.rawAmount).toBe(-promptTokens); + expect(promptTx!.rate).toBe(promptMultiplier); + expect(promptTx!.tokenValue).toBe(-promptTokens * promptMultiplier); + expect(completionTx!.rawAmount).toBe(-completionTokens); + expect(completionTx!.rate).toBe(completionMultiplier); + expect(completionTx!.tokenValue).toBe(-completionTokens * completionMultiplier); + }); + + test('balance unchanged when balance.enabled is false — identical to legacy path', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const entries = prepareTokenSpend( + txMeta(userId, { model: 'gpt-3.5-turbo', balance: { enabled: false } }), + { promptTokens: 100, completionTokens: 50 }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBe(initialBalance); + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(2); // transactions still inserted + }); + + test('no docs when transactions.enabled is false — identical to legacy path', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const entries = prepareTokenSpend( + txMeta(userId, { model: 'gpt-3.5-turbo', transactions: { enabled: false } }), + { promptTokens: 100, completionTokens: 50 }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(0); + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('abort context — transactions inserted, no balance update when balance not passed', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'gpt-3.5-turbo'; + const entries = prepareTokenSpend( + txMeta(userId, { model, context: 'abort', balance: undefined }), + { promptTokens: 100, completionTokens: 50 }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(2); + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBe(initialBalance); + }); + + test('NaN promptTokens — only completion doc inserted, identical to legacy', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const entries = prepareTokenSpend( + txMeta(userId, { model: 'gpt-3.5-turbo' }), + { promptTokens: NaN, completionTokens: 50 }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + expect(txns).toHaveLength(1); + expect(txns[0].tokenType).toBe('completion'); + }); + + test('zero tokens produce docs with rawAmount=0, tokenValue=0', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + await Balance.create({ user: userId, tokenCredits: 10000 }); + + const entries = prepareTokenSpend( + txMeta(userId, { model: 'gpt-3.5-turbo' }), + { promptTokens: 0, completionTokens: 0 }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + expect(txns).toHaveLength(2); + expect(txns.every((t) => t.rawAmount === 0)).toBe(true); + expect(txns.every((t) => t.tokenValue === 0)).toBe(true); + }); +}); + +describe('CANCEL_RATE parity (incomplete context)', () => { + test('CANCEL_RATE applied to completion token — same tokenValue as legacy', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + await Balance.create({ user: userId, tokenCredits: 10000000 }); + + const model = 'claude-3-5-sonnet'; + const completionTokens = 50; + const promptTokens = 10; + + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: promptTokens, + }); + const expectedCompletionTokenValue = Math.ceil( + -completionTokens * completionMultiplier * CANCEL_RATE, + ); + + const entries = prepareTokenSpend( + txMeta(userId, { model, context: 'incomplete' }), + { promptTokens, completionTokens }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + const completionTx = txns.find((t) => t.tokenType === 'completion'); + expect(completionTx!.tokenValue).toBe(expectedCompletionTokenValue); + expect(completionTx!.rate).toBeCloseTo(completionMultiplier * CANCEL_RATE, 5); + }); + + test('CANCEL_RATE NOT applied to prompt tokens in incomplete context', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + await Balance.create({ user: userId, tokenCredits: 10000000 }); + + const model = 'claude-3-5-sonnet'; + const promptTokens = 100; + + const promptMultiplier = getMultiplier({ + model, + tokenType: 'prompt', + inputTokenCount: promptTokens, + }); + + const entries = prepareTokenSpend( + txMeta(userId, { model, context: 'incomplete' }), + { promptTokens, completionTokens: 0 }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + const promptTx = txns.find((t) => t.tokenType === 'prompt'); + expect(promptTx!.rate).toBe(promptMultiplier); // no CANCEL_RATE + }); +}); + +describe('Structured token parity', () => { + test('balance deduction identical to legacy spendStructuredTokens', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 17613154.55; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-3-5-sonnet'; + const tokenUsage = { + promptTokens: { input: 11, write: 140522, read: 0 }, + completionTokens: 5, + }; + + const promptMultiplier = getMultiplier({ + model, + tokenType: 'prompt', + inputTokenCount: 11 + 140522, + }); + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: 11 + 140522, + }); + const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier; + const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier; + + const expectedPromptCost = + tokenUsage.promptTokens.input * promptMultiplier + + tokenUsage.promptTokens.write * writeMultiplier + + tokenUsage.promptTokens.read * readMultiplier; + const expectedCompletionCost = tokenUsage.completionTokens * completionMultiplier; + const expectedTotalCost = expectedPromptCost + expectedCompletionCost; + const expectedBalance = initialBalance - expectedTotalCost; + + const entries = prepareStructuredTokenSpend(txMeta(userId, { model }), tokenUsage, pricing); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(Math.abs((balance.tokenCredits as number) - expectedBalance)).toBeLessThan(100); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + const promptTx = txns.find((t) => t.tokenType === 'prompt'); + expect(promptTx!.inputTokens).toBe(-11); + expect(promptTx!.writeTokens).toBe(-140522); + expect(Math.abs(Number(promptTx!.readTokens ?? 0))).toBe(0); + }); + + test('structured tokens with both cache_creation and cache_read', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 10000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-3-5-sonnet'; + const tokenUsage = { + promptTokens: { input: 100, write: 50, read: 30 }, + completionTokens: 80, + }; + const totalInput = 180; + + const promptMultiplier = getMultiplier({ + model, + tokenType: 'prompt', + inputTokenCount: totalInput, + }); + const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }) ?? promptMultiplier; + const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }) ?? promptMultiplier; + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: totalInput, + }); + + const expectedPromptCost = 100 * promptMultiplier + 50 * writeMultiplier + 30 * readMultiplier; + const expectedCost = expectedPromptCost + 80 * completionMultiplier; + + const entries = prepareStructuredTokenSpend(txMeta(userId, { model }), tokenUsage, pricing); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + expect(txns).toHaveLength(2); + const promptTx = txns.find((t) => t.tokenType === 'prompt'); + expect(promptTx!.inputTokens).toBe(-100); + expect(promptTx!.writeTokens).toBe(-50); + expect(promptTx!.readTokens).toBe(-30); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect( + Math.abs((balance.tokenCredits as number) - (initialBalance - expectedCost)), + ).toBeLessThan(1); + }); + + test('CANCEL_RATE applied to completion in structured incomplete context', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + await Balance.create({ user: userId, tokenCredits: 17613154.55 }); + + const model = 'claude-3-5-sonnet'; + const tokenUsage = { + promptTokens: { input: 10, write: 100, read: 5 }, + completionTokens: 50, + }; + + const completionMultiplier = getMultiplier({ + model, + tokenType: 'completion', + inputTokenCount: 115, + }); + const expectedCompletionTokenValue = Math.ceil(-50 * completionMultiplier * CANCEL_RATE); + + const entries = prepareStructuredTokenSpend( + txMeta(userId, { model, context: 'incomplete' }), + tokenUsage, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const txns = (await Transaction.find({ user: userId }).lean()) as Record[]; + const completionTx = txns.find((t) => t.tokenType === 'completion'); + expect(completionTx!.tokenValue).toBeCloseTo(expectedCompletionTokenValue, 0); + }); +}); + +describe('Premium pricing parity', () => { + test('standard pricing below threshold — identical to legacy', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-opus-4-6'; + const promptTokens = 100000; + const completionTokens = 500; + + const standardPromptRate = (tokenValues as Record>)[model] + .prompt; + const standardCompletionRate = (tokenValues as Record>)[model] + .completion; + const expectedCost = + promptTokens * standardPromptRate + completionTokens * standardCompletionRate; + + const entries = prepareTokenSpend( + txMeta(userId, { model }), + { promptTokens, completionTokens }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + test('premium pricing above threshold — identical to legacy', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-opus-4-6'; + const promptTokens = 250000; + const completionTokens = 500; + + const premiumPromptRate = (premiumTokenValues as Record>)[model] + .prompt; + const premiumCompletionRate = (premiumTokenValues as Record>)[ + model + ].completion; + const expectedCost = + promptTokens * premiumPromptRate + completionTokens * premiumCompletionRate; + + const entries = prepareTokenSpend( + txMeta(userId, { model }), + { promptTokens, completionTokens }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); + + test('standard pricing at exactly the threshold — identical to legacy', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-opus-4-6'; + const promptTokens = (premiumTokenValues as Record>)[model] + .threshold; + const completionTokens = 500; + + const standardPromptRate = (tokenValues as Record>)[model] + .prompt; + const standardCompletionRate = (tokenValues as Record>)[model] + .completion; + const expectedCost = + promptTokens * standardPromptRate + completionTokens * standardCompletionRate; + + const entries = prepareTokenSpend( + txMeta(userId, { model }), + { promptTokens, completionTokens }, + pricing, + ); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedCost, 0); + }); +}); + +describe('Multi-entry batch parity', () => { + test('real-world sequential tool calls — total balance deduction identical to N individual legacy calls', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-opus-4-5-20251101'; + const calls = [ + { promptTokens: 31596, completionTokens: 151 }, + { promptTokens: 35368, completionTokens: 150 }, + { promptTokens: 58362, completionTokens: 295 }, + { promptTokens: 112604, completionTokens: 193 }, + { promptTokens: 257440, completionTokens: 2217 }, + ]; + + let expectedTotalCost = 0; + const allEntries = []; + for (const { promptTokens, completionTokens } of calls) { + const pm = getMultiplier({ model, tokenType: 'prompt', inputTokenCount: promptTokens }); + const cm = getMultiplier({ model, tokenType: 'completion', inputTokenCount: promptTokens }); + expectedTotalCost += promptTokens * pm + completionTokens * cm; + const entries = prepareTokenSpend( + txMeta(userId, { model }), + { promptTokens, completionTokens }, + pricing, + ); + allEntries.push(...entries); + } + + await bulkWriteTransactions({ user: userId, docs: allEntries }, dbOps()); + + const txns = await Transaction.find({ user: userId }).lean(); + expect(txns).toHaveLength(10); // 5 calls × 2 docs + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0); + }); + + test('structured premium above threshold — batch vs individual produce same balance deduction', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const initialBalance = 100000000; + await Balance.create({ user: userId, tokenCredits: initialBalance }); + + const model = 'claude-opus-4-6'; + const tokenUsage = { + promptTokens: { input: 200000, write: 10000, read: 5000 }, + completionTokens: 1000, + }; + const totalInput = 215000; + + const premiumPromptRate = (premiumTokenValues as Record>)[model] + .prompt; + const premiumCompletionRate = (premiumTokenValues as Record>)[ + model + ].completion; + const writeMultiplier = getCacheMultiplier({ model, cacheType: 'write' }); + const readMultiplier = getCacheMultiplier({ model, cacheType: 'read' }); + + const expectedPromptCost = + tokenUsage.promptTokens.input * premiumPromptRate + + tokenUsage.promptTokens.write * writeMultiplier + + tokenUsage.promptTokens.read * readMultiplier; + const expectedCompletionCost = tokenUsage.completionTokens * premiumCompletionRate; + const expectedTotalCost = expectedPromptCost + expectedCompletionCost; + + expect(totalInput).toBeGreaterThan( + (premiumTokenValues as Record>)[model].threshold, + ); + + const entries = prepareStructuredTokenSpend(txMeta(userId, { model }), tokenUsage, pricing); + await bulkWriteTransactions({ user: userId, docs: entries }, dbOps()); + + const balance = (await Balance.findOne({ user: userId }).lean()) as Record; + expect(balance.tokenCredits).toBeCloseTo(initialBalance - expectedTotalCost, 0); + }); +}); diff --git a/packages/api/src/agents/transactions.spec.ts b/packages/api/src/agents/transactions.spec.ts new file mode 100644 index 0000000000..99fb7cdd85 --- /dev/null +++ b/packages/api/src/agents/transactions.spec.ts @@ -0,0 +1,474 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { + CANCEL_RATE, + createMethods, + balanceSchema, + transactionSchema, +} from '@librechat/data-schemas'; +import type { PricingFns, TxMetadata, PreparedEntry } from './transactions'; +import { + prepareStructuredTokenSpend, + bulkWriteTransactions, + prepareTokenSpend, +} from './transactions'; + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, + }; +}); + +let mongoServer: MongoMemoryServer; +let Transaction: mongoose.Model; +let Balance: mongoose.Model; +let dbMethods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + Transaction = mongoose.models.Transaction || mongoose.model('Transaction', transactionSchema); + Balance = mongoose.models.Balance || mongoose.model('Balance', balanceSchema); + dbMethods = createMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +const testUserId = new mongoose.Types.ObjectId().toString(); + +const baseTxData: TxMetadata = { + user: testUserId, + context: 'message', + conversationId: 'convo-123', + model: 'gpt-4', + messageId: 'msg-123', + balance: { enabled: true }, + transactions: { enabled: true }, +}; + +const mockPricing: PricingFns = { + getMultiplier: jest.fn().mockReturnValue(2), + getCacheMultiplier: jest.fn().mockReturnValue(null), +}; + +describe('prepareTokenSpend', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should prepare prompt + completion entries', () => { + const entries = prepareTokenSpend( + baseTxData, + { promptTokens: 100, completionTokens: 50 }, + mockPricing, + ); + expect(entries).toHaveLength(2); + expect(entries[0].doc.tokenType).toBe('prompt'); + expect(entries[1].doc.tokenType).toBe('completion'); + }); + + it('should return empty array when transactions disabled', () => { + const txData = { ...baseTxData, transactions: { enabled: false } }; + const entries = prepareTokenSpend( + txData, + { promptTokens: 100, completionTokens: 50 }, + mockPricing, + ); + expect(entries).toHaveLength(0); + }); + + it('should filter out NaN rawAmount entries', () => { + const entries = prepareTokenSpend( + baseTxData, + { promptTokens: NaN, completionTokens: 50 }, + mockPricing, + ); + expect(entries).toHaveLength(1); + expect(entries[0].doc.tokenType).toBe('completion'); + }); + + it('should handle promptTokens only', () => { + const entries = prepareTokenSpend(baseTxData, { promptTokens: 100 }, mockPricing); + expect(entries).toHaveLength(1); + expect(entries[0].doc.tokenType).toBe('prompt'); + }); + + it('should handle completionTokens only', () => { + const entries = prepareTokenSpend(baseTxData, { completionTokens: 50 }, mockPricing); + expect(entries).toHaveLength(1); + expect(entries[0].doc.tokenType).toBe('completion'); + }); + + it('should handle zero tokens', () => { + const entries = prepareTokenSpend( + baseTxData, + { promptTokens: 0, completionTokens: 0 }, + mockPricing, + ); + expect(entries).toHaveLength(2); + expect(entries[0].doc.rawAmount).toBe(0); + expect(entries[1].doc.rawAmount).toBe(0); + }); + + it('should calculate tokenValue using pricing multiplier', () => { + (mockPricing.getMultiplier as jest.Mock).mockReturnValue(3); + const entries = prepareTokenSpend( + baseTxData, + { promptTokens: 100, completionTokens: 50 }, + mockPricing, + ); + expect(entries[0].doc.rate).toBe(3); + expect(entries[0].doc.tokenValue).toBe(-100 * 3); + expect(entries[1].doc.rate).toBe(3); + expect(entries[1].doc.tokenValue).toBe(-50 * 3); + }); + + it('should pass valueKey to getMultiplier', () => { + prepareTokenSpend(baseTxData, { promptTokens: 100 }, mockPricing); + expect(mockPricing.getMultiplier).toHaveBeenCalledWith( + expect.objectContaining({ tokenType: 'prompt', model: 'gpt-4' }), + ); + }); + + it('should carry balance config on each entry', () => { + const entries = prepareTokenSpend( + baseTxData, + { promptTokens: 100, completionTokens: 50 }, + mockPricing, + ); + for (const entry of entries) { + expect(entry.balance).toEqual({ enabled: true }); + } + }); +}); + +describe('prepareTokenSpend — CANCEL_RATE', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockPricing.getMultiplier as jest.Mock).mockReturnValue(2); + }); + + it('should apply CANCEL_RATE to completion tokens with incomplete context', () => { + const txData: TxMetadata = { ...baseTxData, context: 'incomplete' }; + const entries = prepareTokenSpend( + txData, + { promptTokens: 100, completionTokens: 50 }, + mockPricing, + ); + const completion = entries.find((e) => e.doc.tokenType === 'completion'); + expect(completion).toBeDefined(); + expect(completion!.doc.rate).toBe(2 * CANCEL_RATE); + expect(completion!.doc.tokenValue).toBe(Math.ceil(-50 * 2 * CANCEL_RATE)); + }); + + it('should NOT apply CANCEL_RATE to prompt tokens with incomplete context', () => { + const txData: TxMetadata = { ...baseTxData, context: 'incomplete' }; + const entries = prepareTokenSpend( + txData, + { promptTokens: 100, completionTokens: 50 }, + mockPricing, + ); + const prompt = entries.find((e) => e.doc.tokenType === 'prompt'); + expect(prompt!.doc.rate).toBe(2); + }); + + it('should NOT apply CANCEL_RATE for abort context', () => { + const txData: TxMetadata = { ...baseTxData, context: 'abort' }; + const entries = prepareTokenSpend(txData, { completionTokens: 50 }, mockPricing); + expect(entries[0].doc.rate).toBe(2); + }); +}); + +describe('prepareStructuredTokenSpend', () => { + beforeEach(() => { + jest.clearAllMocks(); + (mockPricing.getMultiplier as jest.Mock).mockReturnValue(2); + (mockPricing.getCacheMultiplier as jest.Mock).mockReturnValue(null); + }); + + it('should prepare prompt + completion for structured tokens', () => { + const entries = prepareStructuredTokenSpend( + baseTxData, + { promptTokens: { input: 100, write: 50, read: 30 }, completionTokens: 80 }, + mockPricing, + ); + expect(entries).toHaveLength(2); + expect(entries[0].doc.tokenType).toBe('prompt'); + expect(entries[0].doc.inputTokens).toBe(-100); + expect(entries[0].doc.writeTokens).toBe(-50); + expect(entries[0].doc.readTokens).toBe(-30); + expect(entries[1].doc.tokenType).toBe('completion'); + }); + + it('should use cache multipliers when available', () => { + (mockPricing.getCacheMultiplier as jest.Mock).mockImplementation(({ cacheType }) => { + if (cacheType === 'write') { + return 5; + } + if (cacheType === 'read') { + return 0.5; + } + return null; + }); + + const entries = prepareStructuredTokenSpend( + baseTxData, + { promptTokens: { input: 100, write: 50, read: 30 }, completionTokens: 0 }, + mockPricing, + ); + const prompt = entries.find((e) => e.doc.tokenType === 'prompt'); + expect(prompt).toBeDefined(); + expect(prompt!.doc.rateDetail).toEqual({ input: 2, write: 5, read: 0.5 }); + }); + + it('should return empty when transactions disabled', () => { + const txData = { ...baseTxData, transactions: { enabled: false } }; + const entries = prepareStructuredTokenSpend( + txData, + { promptTokens: { input: 100 }, completionTokens: 50 }, + mockPricing, + ); + expect(entries).toHaveLength(0); + }); + + it('should handle zero totalPromptTokens (fallback rate)', () => { + const entries = prepareStructuredTokenSpend( + baseTxData, + { promptTokens: { input: 0, write: 0, read: 0 }, completionTokens: 50 }, + mockPricing, + ); + const prompt = entries.find((e) => e.doc.tokenType === 'prompt'); + expect(prompt).toBeDefined(); + expect(prompt!.doc.rate).toBe(2); + }); +}); + +describe('bulkWriteTransactions (real DB)', () => { + it('should return early for empty docs without DB writes', async () => { + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs: [] }, dbOps); + const txCount = await Transaction.countDocuments(); + expect(txCount).toBe(0); + }); + + it('should insert transaction documents into MongoDB', async () => { + const docs: PreparedEntry[] = [ + { + doc: { + user: testUserId, + conversationId: 'c1', + tokenType: 'prompt', + tokenValue: -200, + rate: 2, + rawAmount: -100, + }, + tokenValue: -200, + balance: { enabled: true }, + }, + { + doc: { + user: testUserId, + conversationId: 'c1', + tokenType: 'completion', + tokenValue: -100, + rate: 2, + rawAmount: -50, + }, + tokenValue: -100, + balance: { enabled: true }, + }, + ]; + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs }, dbOps); + + const saved = await Transaction.find({ user: testUserId }).lean(); + expect(saved).toHaveLength(2); + expect(saved.map((t: Record) => t.tokenType).sort()).toEqual([ + 'completion', + 'prompt', + ]); + }); + + it('should create balance document and update credits', async () => { + const docs: PreparedEntry[] = [ + { + doc: { user: testUserId, conversationId: 'c1', tokenType: 'prompt', tokenValue: -300 }, + tokenValue: -300, + balance: { enabled: true }, + }, + ]; + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs }, dbOps); + + const bal = (await Balance.findOne({ user: testUserId }).lean()) as Record< + string, + unknown + > | null; + expect(bal).toBeDefined(); + expect(bal!.tokenCredits).toBe(0); + }); + + it('should NOT update balance when no docs have balance enabled', async () => { + const docs: PreparedEntry[] = [ + { + doc: { user: testUserId, conversationId: 'c1', tokenType: 'prompt', tokenValue: -100 }, + tokenValue: -100, + balance: { enabled: false }, + }, + ]; + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs }, dbOps); + + const txCount = await Transaction.countDocuments({ user: testUserId }); + expect(txCount).toBe(1); + const bal = await Balance.findOne({ user: testUserId }).lean(); + expect(bal).toBeNull(); + }); + + it('should only sum tokenValue from balance-enabled docs', async () => { + await Balance.create({ user: testUserId, tokenCredits: 1000 }); + + const docs: PreparedEntry[] = [ + { + doc: { user: testUserId, conversationId: 'c1', tokenType: 'prompt', tokenValue: -100 }, + tokenValue: -100, + balance: { enabled: true }, + }, + { + doc: { user: testUserId, conversationId: 'c1', tokenType: 'completion', tokenValue: -50 }, + tokenValue: -50, + balance: { enabled: false }, + }, + ]; + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs }, dbOps); + + const bal = (await Balance.findOne({ user: testUserId }).lean()) as Record< + string, + unknown + > | null; + expect(bal!.tokenCredits).toBe(900); + }); + + it('should handle null balance gracefully', async () => { + const docs: PreparedEntry[] = [ + { + doc: { user: testUserId, conversationId: 'c1', tokenType: 'prompt', tokenValue: -100 }, + tokenValue: -100, + balance: null, + }, + ]; + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs }, dbOps); + + const txCount = await Transaction.countDocuments({ user: testUserId }); + expect(txCount).toBe(1); + const bal = await Balance.findOne({ user: testUserId }).lean(); + expect(bal).toBeNull(); + }); +}); + +describe('end-to-end: prepare → bulk write → verify', () => { + it('should prepare, write, and correctly update balance for standard tokens', async () => { + await Balance.create({ user: testUserId, tokenCredits: 10000 }); + (mockPricing.getMultiplier as jest.Mock).mockReturnValue(2); + + const entries = prepareTokenSpend( + baseTxData, + { promptTokens: 100, completionTokens: 50 }, + mockPricing, + ); + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs: entries }, dbOps); + + const txns = (await Transaction.find({ user: testUserId }).lean()) as Record[]; + expect(txns).toHaveLength(2); + + const prompt = txns.find((t) => t.tokenType === 'prompt'); + const completion = txns.find((t) => t.tokenType === 'completion'); + expect(prompt!.tokenValue).toBe(-200); + expect(prompt!.rate).toBe(2); + expect(completion!.tokenValue).toBe(-100); + expect(completion!.rate).toBe(2); + + const bal = (await Balance.findOne({ user: testUserId }).lean()) as Record< + string, + unknown + > | null; + expect(bal!.tokenCredits).toBe(10000 + -200 + -100); + }); + + it('should prepare and write structured tokens with cache pricing', async () => { + await Balance.create({ user: testUserId, tokenCredits: 5000 }); + (mockPricing.getMultiplier as jest.Mock).mockReturnValue(1); + (mockPricing.getCacheMultiplier as jest.Mock).mockImplementation(({ cacheType }) => { + if (cacheType === 'write') { + return 3; + } + if (cacheType === 'read') { + return 0.1; + } + return null; + }); + + const entries = prepareStructuredTokenSpend( + baseTxData, + { promptTokens: { input: 100, write: 50, read: 200 }, completionTokens: 80 }, + mockPricing, + ); + const dbOps = { + insertMany: dbMethods.bulkInsertTransactions, + updateBalance: dbMethods.updateBalance, + }; + await bulkWriteTransactions({ user: testUserId, docs: entries }, dbOps); + + const txns = (await Transaction.find({ user: testUserId }).lean()) as Record[]; + expect(txns).toHaveLength(2); + + const prompt = txns.find((t) => t.tokenType === 'prompt'); + expect(prompt!.inputTokens).toBe(-100); + expect(prompt!.writeTokens).toBe(-50); + expect(prompt!.readTokens).toBe(-200); + + const bal = (await Balance.findOne({ user: testUserId }).lean()) as Record< + string, + unknown + > | null; + expect(bal!.tokenCredits).toBeLessThan(5000); + }); +}); diff --git a/packages/api/src/agents/transactions.ts b/packages/api/src/agents/transactions.ts new file mode 100644 index 0000000000..b746392b44 --- /dev/null +++ b/packages/api/src/agents/transactions.ts @@ -0,0 +1,345 @@ +import { CANCEL_RATE } from '@librechat/data-schemas'; +import type { TCustomConfig, TTransactionsConfig } from 'librechat-data-provider'; +import type { TransactionData } from '@librechat/data-schemas'; +import type { EndpointTokenConfig } from '~/types/tokens'; + +interface GetMultiplierParams { + valueKey?: string; + tokenType?: string; + model?: string; + endpointTokenConfig?: EndpointTokenConfig; + inputTokenCount?: number; +} + +interface GetCacheMultiplierParams { + cacheType: 'write' | 'read'; + model?: string; + endpointTokenConfig?: EndpointTokenConfig; +} + +export interface PricingFns { + getMultiplier: (params: GetMultiplierParams) => number; + getCacheMultiplier: (params: GetCacheMultiplierParams) => number | null; +} + +interface BaseTxData { + user: string; + model?: string; + context: string; + messageId?: string; + conversationId: string; + endpointTokenConfig?: EndpointTokenConfig; + balance?: Partial | null; + transactions?: Partial; +} + +interface StandardTxData extends BaseTxData { + tokenType: string; + rawAmount: number; + inputTokenCount?: number; + valueKey?: string; +} + +interface StructuredTxData extends BaseTxData { + tokenType: string; + inputTokens?: number; + writeTokens?: number; + readTokens?: number; + inputTokenCount?: number; + rawAmount?: number; +} + +export interface PreparedEntry { + doc: TransactionData; + tokenValue: number; + balance?: Partial | null; +} + +export interface TokenUsage { + promptTokens?: number; + completionTokens?: number; +} + +export interface StructuredPromptTokens { + input?: number; + write?: number; + read?: number; +} + +export interface StructuredTokenUsage { + promptTokens?: StructuredPromptTokens; + completionTokens?: number; +} + +export interface TxMetadata { + user: string; + model?: string; + context: string; + messageId?: string; + conversationId: string; + balance?: Partial | null; + transactions?: Partial; + endpointTokenConfig?: EndpointTokenConfig; +} + +export interface BulkWriteDeps { + insertMany: (docs: TransactionData[]) => Promise; + updateBalance: (params: { user: string; incrementValue: number }) => Promise; +} + +function calculateTokenValue( + txData: StandardTxData, + pricing: PricingFns, +): { tokenValue: number; rate: number } { + const { tokenType, model, endpointTokenConfig, inputTokenCount, rawAmount, valueKey } = txData; + const multiplier = Math.abs( + pricing.getMultiplier({ valueKey, tokenType, model, endpointTokenConfig, inputTokenCount }), + ); + let rate = multiplier; + let tokenValue = rawAmount * multiplier; + if (txData.context === 'incomplete' && tokenType === 'completion') { + tokenValue = Math.ceil(tokenValue * CANCEL_RATE); + rate *= CANCEL_RATE; + } + return { tokenValue, rate }; +} + +function calculateStructuredTokenValue( + txData: StructuredTxData, + pricing: PricingFns, +): { tokenValue: number; rate: number; rawAmount: number; rateDetail?: Record } { + const { tokenType, model, endpointTokenConfig, inputTokenCount } = txData; + + if (!tokenType) { + return { tokenValue: txData.rawAmount ?? 0, rate: 0, rawAmount: txData.rawAmount ?? 0 }; + } + + if (tokenType === 'prompt') { + const inputMultiplier = pricing.getMultiplier({ + tokenType: 'prompt', + model, + endpointTokenConfig, + inputTokenCount, + }); + const writeMultiplier = + pricing.getCacheMultiplier({ cacheType: 'write', model, endpointTokenConfig }) ?? + inputMultiplier; + const readMultiplier = + pricing.getCacheMultiplier({ cacheType: 'read', model, endpointTokenConfig }) ?? + inputMultiplier; + + const inputAbs = Math.abs(txData.inputTokens ?? 0); + const writeAbs = Math.abs(txData.writeTokens ?? 0); + const readAbs = Math.abs(txData.readTokens ?? 0); + const totalPromptTokens = inputAbs + writeAbs + readAbs; + + const rate = + totalPromptTokens > 0 + ? (Math.abs(inputMultiplier * (txData.inputTokens ?? 0)) + + Math.abs(writeMultiplier * (txData.writeTokens ?? 0)) + + Math.abs(readMultiplier * (txData.readTokens ?? 0))) / + totalPromptTokens + : Math.abs(inputMultiplier); + + const tokenValue = -( + inputAbs * inputMultiplier + + writeAbs * writeMultiplier + + readAbs * readMultiplier + ); + + return { + tokenValue, + rate, + rawAmount: -totalPromptTokens, + rateDetail: { input: inputMultiplier, write: writeMultiplier, read: readMultiplier }, + }; + } + + const multiplier = pricing.getMultiplier({ + tokenType, + model, + endpointTokenConfig, + inputTokenCount, + }); + const rawAmount = -Math.abs(txData.rawAmount ?? 0); + let rate = Math.abs(multiplier); + let tokenValue = rawAmount * multiplier; + + if (txData.context === 'incomplete' && tokenType === 'completion') { + tokenValue = Math.ceil(tokenValue * CANCEL_RATE); + rate *= CANCEL_RATE; + } + + return { tokenValue, rate, rawAmount }; +} + +function prepareStandardTx( + _txData: StandardTxData & { + balance?: Partial | null; + transactions?: Partial; + }, + pricing: PricingFns, +): PreparedEntry | null { + const { balance, transactions, ...txData } = _txData; + if (txData.rawAmount != null && isNaN(txData.rawAmount)) { + return null; + } + if (transactions?.enabled === false) { + return null; + } + + const { tokenValue, rate } = calculateTokenValue(txData, pricing); + return { + doc: { ...txData, tokenValue, rate }, + tokenValue, + balance, + }; +} + +function prepareStructuredTx( + _txData: StructuredTxData & { + balance?: Partial | null; + transactions?: Partial; + }, + pricing: PricingFns, +): PreparedEntry | null { + const { balance, transactions, ...txData } = _txData; + if (transactions?.enabled === false) { + return null; + } + + const { tokenValue, rate, rawAmount, rateDetail } = calculateStructuredTokenValue( + txData, + pricing, + ); + return { + doc: { + ...txData, + tokenValue, + rate, + rawAmount, + ...(rateDetail && { rateDetail }), + }, + tokenValue, + balance, + }; +} + +export function prepareTokenSpend( + txData: TxMetadata, + tokenUsage: TokenUsage, + pricing: PricingFns, +): PreparedEntry[] { + const { promptTokens, completionTokens } = tokenUsage; + const results: PreparedEntry[] = []; + const normalizedPromptTokens = Math.max(promptTokens ?? 0, 0); + + if (promptTokens !== undefined) { + const entry = prepareStandardTx( + { + ...txData, + tokenType: 'prompt', + rawAmount: promptTokens === 0 ? 0 : -normalizedPromptTokens, + inputTokenCount: normalizedPromptTokens, + }, + pricing, + ); + if (entry) { + results.push(entry); + } + } + + if (completionTokens !== undefined) { + const entry = prepareStandardTx( + { + ...txData, + tokenType: 'completion', + rawAmount: completionTokens === 0 ? 0 : -Math.max(completionTokens, 0), + inputTokenCount: normalizedPromptTokens, + }, + pricing, + ); + if (entry) { + results.push(entry); + } + } + + return results; +} + +export function prepareStructuredTokenSpend( + txData: TxMetadata, + tokenUsage: StructuredTokenUsage, + pricing: PricingFns, +): PreparedEntry[] { + const { promptTokens, completionTokens } = tokenUsage; + const results: PreparedEntry[] = []; + + if (promptTokens) { + const input = Math.max(promptTokens.input ?? 0, 0); + const write = Math.max(promptTokens.write ?? 0, 0); + const read = Math.max(promptTokens.read ?? 0, 0); + const totalInputTokens = input + write + read; + const entry = prepareStructuredTx( + { + ...txData, + tokenType: 'prompt', + inputTokens: -input, + writeTokens: -write, + readTokens: -read, + inputTokenCount: totalInputTokens, + }, + pricing, + ); + if (entry) { + results.push(entry); + } + } + + if (completionTokens) { + const totalInputTokens = promptTokens + ? Math.max(promptTokens.input ?? 0, 0) + + Math.max(promptTokens.write ?? 0, 0) + + Math.max(promptTokens.read ?? 0, 0) + : undefined; + const entry = prepareStandardTx( + { + ...txData, + tokenType: 'completion', + rawAmount: -Math.max(completionTokens, 0), + inputTokenCount: totalInputTokens, + }, + pricing, + ); + if (entry) { + results.push(entry); + } + } + + return results; +} + +export async function bulkWriteTransactions( + { user, docs }: { user: string; docs: PreparedEntry[] }, + dbOps: BulkWriteDeps, +): Promise { + if (!docs.length) { + return; + } + + let totalTokenValue = 0; + let balanceEnabled = false; + const plainDocs = docs.map(({ doc, tokenValue, balance }) => { + if (balance?.enabled) { + balanceEnabled = true; + totalTokenValue += tokenValue; + } + return doc; + }); + + if (balanceEnabled) { + await dbOps.updateBalance({ user, incrementValue: totalTokenValue }); + } + + await dbOps.insertMany(plainDocs); +} diff --git a/packages/api/src/agents/usage.bulk-parity.spec.ts b/packages/api/src/agents/usage.bulk-parity.spec.ts new file mode 100644 index 0000000000..79dd50b2e3 --- /dev/null +++ b/packages/api/src/agents/usage.bulk-parity.spec.ts @@ -0,0 +1,533 @@ +/** + * Bulk path parity tests for recordCollectedUsage. + * + * Every test here mirrors a corresponding legacy-path test in usage.spec.ts. + * The return values (input_tokens, output_tokens) must be identical between paths. + * The docs written to insertMany must carry the same metadata as the args that + * would have been passed to spendTokens/spendStructuredTokens. + */ +import type { UsageMetadata } from '../stream/interfaces/IJobStore'; +import type { RecordUsageDeps, RecordUsageParams } from './usage'; +import type { BulkWriteDeps, PricingFns } from './transactions'; +import { recordCollectedUsage } from './usage'; + +describe('recordCollectedUsage — bulk path parity', () => { + let mockSpendTokens: jest.Mock; + let mockSpendStructuredTokens: jest.Mock; + let mockInsertMany: jest.Mock; + let mockUpdateBalance: jest.Mock; + let mockPricing: PricingFns; + let mockBulkWriteOps: BulkWriteDeps; + let deps: RecordUsageDeps; + + const baseParams: Omit = { + user: 'user-123', + conversationId: 'convo-123', + model: 'gpt-4', + context: 'message', + balance: { enabled: true }, + transactions: { enabled: true }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSpendTokens = jest.fn().mockResolvedValue(undefined); + mockSpendStructuredTokens = jest.fn().mockResolvedValue(undefined); + mockInsertMany = jest.fn().mockResolvedValue(undefined); + mockUpdateBalance = jest.fn().mockResolvedValue({}); + mockPricing = { + getMultiplier: jest.fn().mockReturnValue(1), + getCacheMultiplier: jest.fn().mockReturnValue(null), + }; + mockBulkWriteOps = { + insertMany: mockInsertMany, + updateBalance: mockUpdateBalance, + }; + deps = { + spendTokens: mockSpendTokens, + spendStructuredTokens: mockSpendStructuredTokens, + pricing: mockPricing, + bulkWriteOps: mockBulkWriteOps, + }; + }); + + describe('basic functionality', () => { + it('should return undefined if collectedUsage is empty', async () => { + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage: [] }); + expect(result).toBeUndefined(); + expect(mockInsertMany).not.toHaveBeenCalled(); + expect(mockSpendTokens).not.toHaveBeenCalled(); + }); + + it('should return undefined if collectedUsage is null-ish', async () => { + const result = await recordCollectedUsage(deps, { + ...baseParams, + collectedUsage: null as unknown as UsageMetadata[], + }); + expect(result).toBeUndefined(); + expect(mockInsertMany).not.toHaveBeenCalled(); + }); + + it('should handle single usage entry — same return value as legacy path', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result).toEqual({ input_tokens: 100, output_tokens: 50 }); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockInsertMany).toHaveBeenCalledTimes(1); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs).toHaveLength(2); + const promptDoc = docs.find((d: { tokenType: string }) => d.tokenType === 'prompt'); + const completionDoc = docs.find((d: { tokenType: string }) => d.tokenType === 'completion'); + expect(promptDoc.user).toBe('user-123'); + expect(promptDoc.conversationId).toBe('convo-123'); + expect(promptDoc.model).toBe('gpt-4'); + expect(promptDoc.context).toBe('message'); + expect(promptDoc.rawAmount).toBe(-100); + expect(completionDoc.rawAmount).toBe(-50); + }); + + it('should skip null entries — same return value as legacy path', async () => { + const collectedUsage = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + null, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + ] as UsageMetadata[]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result).toEqual({ input_tokens: 100, output_tokens: 110 }); + expect(mockInsertMany).toHaveBeenCalledTimes(1); + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs).toHaveLength(4); // 2 non-null entries × 2 docs each + }); + }); + + describe('sequential execution (tool calls)', () => { + it('should calculate tokens correctly for sequential tool calls — same totals as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 150, output_tokens: 30, model: 'gpt-4' }, + { input_tokens: 180, output_tokens: 20, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.output_tokens).toBe(100); // 50 + 30 + 20 + expect(result?.input_tokens).toBe(100); // first entry's input + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs).toHaveLength(6); // 3 entries × 2 docs + expect(mockSpendTokens).not.toHaveBeenCalled(); + }); + }); + + describe('parallel execution (multiple agents)', () => { + it('should handle parallel agents — same output_tokens total as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 80, output_tokens: 40, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.output_tokens).toBe(90); // 50 + 40 + expect(result?.output_tokens).toBeGreaterThan(0); + expect(mockInsertMany).toHaveBeenCalledTimes(1); + }); + + /** Bug regression: parallel agents where second agent has LOWER input tokens produced negative output via incremental calculation. */ + it('should NOT produce negative output_tokens — same positive result as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 200, output_tokens: 100, model: 'gpt-4' }, + { input_tokens: 50, output_tokens: 30, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.output_tokens).toBeGreaterThan(0); + expect(result?.output_tokens).toBe(130); // 100 + 30 + }); + + it('should calculate correct total output for 3 parallel agents', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 120, output_tokens: 60, model: 'gpt-4-turbo' }, + { input_tokens: 80, output_tokens: 40, model: 'claude-3' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.output_tokens).toBe(150); // 50 + 60 + 40 + expect(mockInsertMany).toHaveBeenCalledTimes(1); + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs).toHaveLength(6); + expect(mockSpendTokens).not.toHaveBeenCalled(); + }); + }); + + describe('cache token handling - OpenAI format', () => { + it('should route cache entries to structured path — same input_tokens as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'gpt-4', + input_token_details: { cache_creation: 20, cache_read: 10 }, + }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.input_tokens).toBe(130); // 100 + 20 + 10 + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(mockSpendTokens).not.toHaveBeenCalled(); + + const docs = mockInsertMany.mock.calls[0][0]; + const promptDoc = docs.find((d: { tokenType: string }) => d.tokenType === 'prompt'); + expect(promptDoc.inputTokens).toBe(-100); + expect(promptDoc.writeTokens).toBe(-20); + expect(promptDoc.readTokens).toBe(-10); + expect(promptDoc.model).toBe('gpt-4'); + }); + }); + + describe('cache token handling - Anthropic format', () => { + it('should route Anthropic cache entries to structured path — same input_tokens as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'claude-3', + cache_creation_input_tokens: 25, + cache_read_input_tokens: 15, + }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.input_tokens).toBe(140); // 100 + 25 + 15 + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + + const docs = mockInsertMany.mock.calls[0][0]; + const promptDoc = docs.find((d: { tokenType: string }) => d.tokenType === 'prompt'); + expect(promptDoc.inputTokens).toBe(-100); + expect(promptDoc.writeTokens).toBe(-25); + expect(promptDoc.readTokens).toBe(-15); + expect(promptDoc.model).toBe('claude-3'); + }); + }); + + describe('mixed cache and non-cache entries', () => { + it('should handle mixed entries — same output_tokens as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { + input_tokens: 150, + output_tokens: 30, + model: 'gpt-4', + input_token_details: { cache_creation: 10, cache_read: 5 }, + }, + { input_tokens: 200, output_tokens: 20, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.output_tokens).toBe(100); // 50 + 30 + 20 + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs).toHaveLength(6); // 3 entries × 2 docs each + }); + }); + + describe('model fallback', () => { + it('should use usage.model when available — model lands in doc', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4-turbo' }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + model: 'fallback-model', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs[0].model).toBe('gpt-4-turbo'); + }); + + it('should fallback to param model when usage.model is missing — model lands in doc', async () => { + const collectedUsage: UsageMetadata[] = [{ input_tokens: 100, output_tokens: 50 }]; + + await recordCollectedUsage(deps, { + ...baseParams, + model: 'param-model', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs[0].model).toBe('param-model'); + }); + + it('should fallback to undefined model when both usage.model and param model are missing', async () => { + const collectedUsage: UsageMetadata[] = [{ input_tokens: 100, output_tokens: 50 }]; + + await recordCollectedUsage(deps, { + ...baseParams, + model: undefined, + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs[0].model).toBeUndefined(); + }); + }); + + describe('real-world scenarios', () => { + it('should correctly sum output tokens for sequential tool calls with growing context', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 31596, output_tokens: 151, model: 'claude-opus' }, + { input_tokens: 35368, output_tokens: 150, model: 'claude-opus' }, + { input_tokens: 58362, output_tokens: 295, model: 'claude-opus' }, + { input_tokens: 112604, output_tokens: 193, model: 'claude-opus' }, + { input_tokens: 257440, output_tokens: 2217, model: 'claude-opus' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.input_tokens).toBe(31596); + expect(result?.output_tokens).toBe(3006); // 151+150+295+193+2217 + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs).toHaveLength(10); // 5 entries × 2 docs + expect(mockSpendTokens).not.toHaveBeenCalled(); + }); + + it('should handle cache tokens with multiple tool calls — same totals as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 788, + output_tokens: 163, + model: 'claude-opus', + input_token_details: { cache_read: 0, cache_creation: 30808 }, + }, + { + input_tokens: 3802, + output_tokens: 149, + model: 'claude-opus', + input_token_details: { cache_read: 30808, cache_creation: 768 }, + }, + { + input_tokens: 26808, + output_tokens: 225, + model: 'claude-opus', + input_token_details: { cache_read: 31576, cache_creation: 0 }, + }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result?.input_tokens).toBe(31596); // 788 + 30808 + 0 + expect(result?.output_tokens).toBe(537); // 163 + 149 + 225 + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(mockSpendTokens).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should catch bulk write errors — still returns correct result', async () => { + mockInsertMany.mockRejectedValue(new Error('DB error')); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(deps, { ...baseParams, collectedUsage }); + + expect(result).toEqual({ input_tokens: 100, output_tokens: 50 }); + }); + }); + + describe('transaction metadata — doc fields match what legacy would pass to spendTokens', () => { + it('should pass all metadata fields to docs', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + const endpointTokenConfig = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } }; + + await recordCollectedUsage(deps, { + ...baseParams, + messageId: 'msg-123', + endpointTokenConfig, + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + for (const doc of docs) { + expect(doc.user).toBe('user-123'); + expect(doc.conversationId).toBe('convo-123'); + expect(doc.model).toBe('gpt-4'); + expect(doc.context).toBe('message'); + expect(doc.messageId).toBe('msg-123'); + } + }); + + it('should use default context "message" when not provided', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + user: 'user-123', + conversationId: 'convo-123', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs[0].context).toBe('message'); + }); + + it('should allow custom context like "title"', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + context: 'title', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs[0].context).toBe('title'); + }); + }); + + describe('messageId propagation — messageId on every doc', () => { + it('should propagate messageId to all docs', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 10, output_tokens: 5, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + messageId: 'msg-1', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + for (const doc of docs) { + expect(doc.messageId).toBe('msg-1'); + } + }); + + it('should propagate messageId to structured cache docs', async () => { + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'claude-3', + cache_creation_input_tokens: 25, + cache_read_input_tokens: 15, + }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + messageId: 'msg-cache-1', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + for (const doc of docs) { + expect(doc.messageId).toBe('msg-cache-1'); + } + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + }); + + it('should pass undefined messageId when not provided', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 10, output_tokens: 5, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + user: 'user-123', + conversationId: 'convo-123', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + expect(docs[0].messageId).toBeUndefined(); + }); + + it('should propagate messageId across all entries in a multi-entry batch', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + { + input_tokens: 150, + output_tokens: 30, + model: 'gpt-4', + input_token_details: { cache_creation: 10, cache_read: 5 }, + }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + messageId: 'msg-multi', + collectedUsage, + }); + + const docs = mockInsertMany.mock.calls[0][0]; + for (const doc of docs) { + expect(doc.messageId).toBe('msg-multi'); + } + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + }); + }); + + describe('balance behavior parity', () => { + it('should not call updateBalance when balance is disabled — same as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + balance: { enabled: false }, + collectedUsage, + }); + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockUpdateBalance).not.toHaveBeenCalled(); + }); + + it('should not insert docs when transactions are disabled — same as legacy', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(deps, { + ...baseParams, + transactions: { enabled: false }, + collectedUsage, + }); + + expect(mockInsertMany).not.toHaveBeenCalled(); + expect(mockUpdateBalance).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/agents/usage.spec.ts b/packages/api/src/agents/usage.spec.ts index 1937af5011..d0b065b8ff 100644 --- a/packages/api/src/agents/usage.spec.ts +++ b/packages/api/src/agents/usage.spec.ts @@ -1,6 +1,7 @@ -import { recordCollectedUsage } from './usage'; -import type { RecordUsageDeps, RecordUsageParams } from './usage'; import type { UsageMetadata } from '../stream/interfaces/IJobStore'; +import type { RecordUsageDeps, RecordUsageParams } from './usage'; +import type { BulkWriteDeps, PricingFns } from './transactions'; +import { recordCollectedUsage } from './usage'; describe('recordCollectedUsage', () => { let mockSpendTokens: jest.Mock; @@ -522,4 +523,199 @@ describe('recordCollectedUsage', () => { ); }); }); + + describe('bulk write path', () => { + let mockInsertMany: jest.Mock; + let mockUpdateBalance: jest.Mock; + let mockPricing: PricingFns; + let mockBulkWriteOps: BulkWriteDeps; + let bulkDeps: RecordUsageDeps; + + beforeEach(() => { + mockInsertMany = jest.fn().mockResolvedValue(undefined); + mockUpdateBalance = jest.fn().mockResolvedValue({}); + mockPricing = { + getMultiplier: jest.fn().mockReturnValue(1), + getCacheMultiplier: jest.fn().mockReturnValue(null), + }; + mockBulkWriteOps = { + insertMany: mockInsertMany, + updateBalance: mockUpdateBalance, + }; + bulkDeps = { + spendTokens: mockSpendTokens, + spendStructuredTokens: mockSpendStructuredTokens, + pricing: mockPricing, + bulkWriteOps: mockBulkWriteOps, + }; + }); + + it('should use bulk path when pricing and bulkWriteOps are provided', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(bulkDeps, { + ...baseParams, + collectedUsage, + }); + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(result).toEqual({ input_tokens: 100, output_tokens: 50 }); + }); + + it('should batch all entries into a single insertMany call', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + { input_tokens: 300, output_tokens: 70, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(bulkDeps, { + ...baseParams, + collectedUsage, + }); + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + const insertedDocs = mockInsertMany.mock.calls[0][0]; + expect(insertedDocs.length).toBe(6); // 2 per entry (prompt + completion) + }); + + it('should call updateBalance once when balance is enabled', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { input_tokens: 200, output_tokens: 60, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(bulkDeps, { + ...baseParams, + balance: { enabled: true }, + collectedUsage, + }); + + expect(mockUpdateBalance).toHaveBeenCalledTimes(1); + expect(mockUpdateBalance).toHaveBeenCalledWith( + expect.objectContaining({ + user: 'user-123', + incrementValue: expect.any(Number), + }), + ); + }); + + it('should not call updateBalance when balance is disabled', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(bulkDeps, { + ...baseParams, + balance: { enabled: false }, + collectedUsage, + }); + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockUpdateBalance).not.toHaveBeenCalled(); + }); + + it('should handle cache tokens via bulk path', async () => { + const collectedUsage: UsageMetadata[] = [ + { + input_tokens: 100, + output_tokens: 50, + model: 'gpt-4', + input_token_details: { cache_creation: 20, cache_read: 10 }, + }, + ]; + + const result = await recordCollectedUsage(bulkDeps, { + ...baseParams, + collectedUsage, + }); + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('should handle mixed cache and non-cache entries in bulk', async () => { + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + { + input_tokens: 150, + output_tokens: 30, + model: 'gpt-4', + input_token_details: { cache_creation: 10, cache_read: 5 }, + }, + ]; + + const result = await recordCollectedUsage(bulkDeps, { + ...baseParams, + collectedUsage, + }); + + expect(mockInsertMany).toHaveBeenCalledTimes(1); + expect(mockSpendTokens).not.toHaveBeenCalled(); + expect(mockSpendStructuredTokens).not.toHaveBeenCalled(); + expect(result?.output_tokens).toBe(80); + }); + + it('should fall back to legacy path when pricing is missing', async () => { + const legacyDeps: RecordUsageDeps = { + spendTokens: mockSpendTokens, + spendStructuredTokens: mockSpendStructuredTokens, + bulkWriteOps: mockBulkWriteOps, + // no pricing + }; + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(legacyDeps, { + ...baseParams, + collectedUsage, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(1); + expect(mockInsertMany).not.toHaveBeenCalled(); + }); + + it('should fall back to legacy path when bulkWriteOps is missing', async () => { + const legacyDeps: RecordUsageDeps = { + spendTokens: mockSpendTokens, + spendStructuredTokens: mockSpendStructuredTokens, + pricing: mockPricing, + // no bulkWriteOps + }; + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + await recordCollectedUsage(legacyDeps, { + ...baseParams, + collectedUsage, + }); + + expect(mockSpendTokens).toHaveBeenCalledTimes(1); + expect(mockInsertMany).not.toHaveBeenCalled(); + }); + + it('should handle errors in bulk write gracefully', async () => { + mockInsertMany.mockRejectedValue(new Error('DB error')); + + const collectedUsage: UsageMetadata[] = [ + { input_tokens: 100, output_tokens: 50, model: 'gpt-4' }, + ]; + + const result = await recordCollectedUsage(bulkDeps, { + ...baseParams, + collectedUsage, + }); + + expect(result).toEqual({ input_tokens: 100, output_tokens: 50 }); + }); + }); }); diff --git a/packages/api/src/agents/usage.ts b/packages/api/src/agents/usage.ts index 351452d698..c092702730 100644 --- a/packages/api/src/agents/usage.ts +++ b/packages/api/src/agents/usage.ts @@ -1,34 +1,20 @@ import { logger } from '@librechat/data-schemas'; import type { TCustomConfig, TTransactionsConfig } from 'librechat-data-provider'; -import type { UsageMetadata } from '../stream/interfaces/IJobStore'; -import type { EndpointTokenConfig } from '../types/tokens'; - -interface TokenUsage { - promptTokens?: number; - completionTokens?: number; -} - -interface StructuredPromptTokens { - input?: number; - write?: number; - read?: number; -} - -interface StructuredTokenUsage { - promptTokens?: StructuredPromptTokens; - completionTokens?: number; -} - -interface TxMetadata { - user: string; - model?: string; - context: string; - messageId?: string; - conversationId: string; - balance?: Partial | null; - transactions?: Partial; - endpointTokenConfig?: EndpointTokenConfig; -} +import type { + StructuredTokenUsage, + BulkWriteDeps, + PreparedEntry, + TxMetadata, + TokenUsage, + PricingFns, +} from './transactions'; +import type { UsageMetadata } from '~/stream/interfaces/IJobStore'; +import type { EndpointTokenConfig } from '~/types/tokens'; +import { + prepareStructuredTokenSpend, + bulkWriteTransactions, + prepareTokenSpend, +} from './transactions'; type SpendTokensFn = (txData: TxMetadata, tokenUsage: TokenUsage) => Promise; type SpendStructuredTokensFn = ( @@ -39,6 +25,8 @@ type SpendStructuredTokensFn = ( export interface RecordUsageDeps { spendTokens: SpendTokensFn; spendStructuredTokens: SpendStructuredTokensFn; + pricing?: PricingFns; + bulkWriteOps?: BulkWriteDeps; } export interface RecordUsageParams { @@ -61,6 +49,9 @@ export interface RecordUsageResult { /** * Records token usage for collected LLM calls and spends tokens against balance. * This handles both sequential execution (tool calls) and parallel execution (multiple agents). + * + * When `pricing` and `bulkWriteOps` deps are provided, prepares all transaction documents + * in-memory first, then writes them in a single `insertMany` + one `updateBalance` call. */ export async function recordCollectedUsage( deps: RecordUsageDeps, @@ -78,8 +69,6 @@ export async function recordCollectedUsage( context = 'message', } = params; - const { spendTokens, spendStructuredTokens } = deps; - if (!collectedUsage || !collectedUsage.length) { return; } @@ -96,6 +85,11 @@ export async function recordCollectedUsage( let total_output_tokens = 0; + const { pricing, bulkWriteOps } = deps; + const useBulk = pricing && bulkWriteOps; + + const allDocs: PreparedEntry[] = []; + for (const usage of collectedUsage) { if (!usage) { continue; @@ -121,26 +115,68 @@ export async function recordCollectedUsage( model: usage.model ?? model, }; - if (cache_creation > 0 || cache_read > 0) { - spendStructuredTokens(txMetadata, { - promptTokens: { - input: usage.input_tokens, - write: cache_creation, - read: cache_read, - }, - completionTokens: usage.output_tokens, - }).catch((err) => { - logger.error('[packages/api #recordCollectedUsage] Error spending structured tokens', err); - }); + if (useBulk) { + const entries = + cache_creation > 0 || cache_read > 0 + ? prepareStructuredTokenSpend( + txMetadata, + { + promptTokens: { + input: usage.input_tokens, + write: cache_creation, + read: cache_read, + }, + completionTokens: usage.output_tokens, + }, + pricing, + ) + : prepareTokenSpend( + txMetadata, + { + promptTokens: usage.input_tokens, + completionTokens: usage.output_tokens, + }, + pricing, + ); + allDocs.push(...entries); continue; } - spendTokens(txMetadata, { - promptTokens: usage.input_tokens, - completionTokens: usage.output_tokens, - }).catch((err) => { - logger.error('[packages/api #recordCollectedUsage] Error spending tokens', err); - }); + if (cache_creation > 0 || cache_read > 0) { + deps + .spendStructuredTokens(txMetadata, { + promptTokens: { + input: usage.input_tokens, + write: cache_creation, + read: cache_read, + }, + completionTokens: usage.output_tokens, + }) + .catch((err) => { + logger.error( + '[packages/api #recordCollectedUsage] Error spending structured tokens', + err, + ); + }); + continue; + } + + deps + .spendTokens(txMetadata, { + promptTokens: usage.input_tokens, + completionTokens: usage.output_tokens, + }) + .catch((err) => { + logger.error('[packages/api #recordCollectedUsage] Error spending tokens', err); + }); + } + + if (useBulk && allDocs.length > 0) { + try { + await bulkWriteTransactions({ user, docs: allDocs }, bulkWriteOps); + } catch (err) { + logger.error('[packages/api #recordCollectedUsage] Error in bulk write', err); + } } return { diff --git a/packages/api/src/endpoints/openai/config.spec.ts b/packages/api/src/endpoints/openai/config.spec.ts index 2bbe123e63..58b471aa66 100644 --- a/packages/api/src/endpoints/openai/config.spec.ts +++ b/packages/api/src/endpoints/openai/config.spec.ts @@ -872,9 +872,8 @@ describe('getOpenAIConfig', () => { modelOptions, }); - // OpenRouter reasoning object should only include effort, not summary - expect(result.llmConfig.reasoning).toEqual({ - effort: ReasoningEffort.high, + expect(result.llmConfig.modelKwargs).toMatchObject({ + reasoning: { effort: ReasoningEffort.high }, }); expect(result.llmConfig.include_reasoning).toBeUndefined(); expect(result.provider).toBe('openrouter'); @@ -1206,13 +1205,13 @@ describe('getOpenAIConfig', () => { model: 'gpt-4-turbo', temperature: 0.8, streaming: false, - reasoning: { effort: ReasoningEffort.high }, // OpenRouter reasoning object }); expect(result.llmConfig.include_reasoning).toBeUndefined(); // Should NOT have useResponsesApi for OpenRouter expect(result.llmConfig.useResponsesApi).toBeUndefined(); expect(result.llmConfig.maxTokens).toBe(2000); expect(result.llmConfig.modelKwargs).toEqual({ + reasoning: { effort: ReasoningEffort.high }, verbosity: Verbosity.medium, customParam: 'custom-value', plugins: [{ id: 'web' }], // OpenRouter web search format @@ -1482,13 +1481,11 @@ describe('getOpenAIConfig', () => { user: 'openrouter-user', temperature: 0.7, maxTokens: 4000, - reasoning: { - effort: ReasoningEffort.high, - }, apiKey: apiKey, }); expect(result.llmConfig.include_reasoning).toBeUndefined(); expect(result.llmConfig.modelKwargs).toMatchObject({ + reasoning: { effort: ReasoningEffort.high }, top_k: 50, repetition_penalty: 1.1, }); diff --git a/packages/api/src/endpoints/openai/llm.spec.ts b/packages/api/src/endpoints/openai/llm.spec.ts index 3c10179737..a78cc4b87d 100644 --- a/packages/api/src/endpoints/openai/llm.spec.ts +++ b/packages/api/src/endpoints/openai/llm.spec.ts @@ -393,7 +393,9 @@ describe('getOpenAILLMConfig', () => { }, }); - expect(result.llmConfig).toHaveProperty('reasoning', { effort: ReasoningEffort.high }); + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { + effort: ReasoningEffort.high, + }); expect(result.llmConfig).not.toHaveProperty('include_reasoning'); expect(result.llmConfig.modelKwargs).toHaveProperty('plugins', [{ id: 'web' }]); }); @@ -617,7 +619,9 @@ describe('getOpenAILLMConfig', () => { }, }); - expect(result.llmConfig).toHaveProperty('reasoning', { effort: ReasoningEffort.high }); + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { + effort: ReasoningEffort.high, + }); expect(result.llmConfig).not.toHaveProperty('include_reasoning'); expect(result.llmConfig).not.toHaveProperty('reasoning_effort'); }); @@ -634,7 +638,9 @@ describe('getOpenAILLMConfig', () => { }, }); - expect(result.llmConfig).toHaveProperty('reasoning', { effort: ReasoningEffort.high }); + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { + effort: ReasoningEffort.high, + }); }); it.each([ReasoningEffort.xhigh, ReasoningEffort.minimal, ReasoningEffort.none])( @@ -650,7 +656,7 @@ describe('getOpenAILLMConfig', () => { }, }); - expect(result.llmConfig).toHaveProperty('reasoning', { effort }); + expect(result.llmConfig.modelKwargs).toHaveProperty('reasoning', { effort }); expect(result.llmConfig).not.toHaveProperty('include_reasoning'); }, ); diff --git a/packages/api/src/endpoints/openai/llm.ts b/packages/api/src/endpoints/openai/llm.ts index c659645958..a89f6fce44 100644 --- a/packages/api/src/endpoints/openai/llm.ts +++ b/packages/api/src/endpoints/openai/llm.ts @@ -1,7 +1,6 @@ import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; import type { BindToolsInput } from '@langchain/core/language_models/chat_models'; import type { SettingDefinition } from 'librechat-data-provider'; -import type { OpenRouterReasoning } from '@librechat/agents'; import type { AzureOpenAIInput } from '@langchain/openai'; import type { OpenAI } from 'openai'; import type * as t from '~/types'; @@ -231,7 +230,8 @@ export function getOpenAILLMConfig({ * `include_reasoning` is legacy compat that maps to `{ enabled: true }` only when * no `reasoning` object is present, so we intentionally omit it here. */ - llmConfig.reasoning = { effort: reasoning_effort } as OpenRouterReasoning; + modelKwargs.reasoning = { effort: reasoning_effort }; + hasModelKwargs = true; } else { /** No explicit effort; fall back to legacy `include_reasoning` for reasoning token inclusion */ llmConfig.include_reasoning = true; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 2f20b67fec..07e7cefc24 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -21,6 +21,7 @@ import { createAccessRoleMethods, type AccessRoleMethods } from './accessRole'; import { createUserGroupMethods, type UserGroupMethods } from './userGroup'; import { createAclEntryMethods, type AclEntryMethods } from './aclEntry'; import { createShareMethods, type ShareMethods } from './share'; +import { createTransactionMethods, type TransactionMethods } from './transaction'; export type AllMethods = UserMethods & SessionMethods & @@ -36,7 +37,8 @@ export type AllMethods = UserMethods & AclEntryMethods & ShareMethods & AccessRoleMethods & - PluginAuthMethods; + PluginAuthMethods & + TransactionMethods; /** * Creates all database methods for all collections @@ -59,6 +61,7 @@ export function createMethods(mongoose: typeof import('mongoose')): AllMethods { ...createAclEntryMethods(mongoose), ...createShareMethods(mongoose), ...createPluginAuthMethods(mongoose), + ...createTransactionMethods(mongoose), }; } @@ -78,4 +81,5 @@ export type { ShareMethods, AccessRoleMethods, PluginAuthMethods, + TransactionMethods, }; diff --git a/packages/data-schemas/src/methods/transaction.ts b/packages/data-schemas/src/methods/transaction.ts new file mode 100644 index 0000000000..d521b9e85e --- /dev/null +++ b/packages/data-schemas/src/methods/transaction.ts @@ -0,0 +1,100 @@ +import type { IBalance, TransactionData } from '~/types'; +import logger from '~/config/winston'; + +interface UpdateBalanceParams { + user: string; + incrementValue: number; + setValues?: Partial>; +} + +export function createTransactionMethods(mongoose: typeof import('mongoose')) { + async function updateBalance({ user, incrementValue, setValues }: UpdateBalanceParams) { + const maxRetries = 10; + let delay = 50; + let lastError: Error | null = null; + const Balance = mongoose.models.Balance; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const currentBalanceDoc = await Balance.findOne({ user }).lean(); + const currentCredits = currentBalanceDoc?.tokenCredits ?? 0; + const newCredits = Math.max(0, currentCredits + incrementValue); + + const updatePayload = { + $set: { + tokenCredits: newCredits, + ...(setValues ?? {}), + }, + }; + + if (currentBalanceDoc) { + const updatedBalance = await Balance.findOneAndUpdate( + { user, tokenCredits: currentCredits }, + updatePayload, + { new: true }, + ).lean(); + + if (updatedBalance) { + return updatedBalance; + } + lastError = new Error(`Concurrency conflict for user ${user} on attempt ${attempt}.`); + } else { + try { + const updatedBalance = await Balance.findOneAndUpdate({ user }, updatePayload, { + upsert: true, + new: true, + }).lean(); + + if (updatedBalance) { + return updatedBalance; + } + lastError = new Error( + `Upsert race condition suspected for user ${user} on attempt ${attempt}.`, + ); + } catch (error: unknown) { + if ( + error instanceof Error && + 'code' in error && + (error as { code: number }).code === 11000 + ) { + lastError = error; + } else { + throw error; + } + } + } + } catch (error) { + logger.error(`[updateBalance] Error during attempt ${attempt} for user ${user}:`, error); + lastError = error instanceof Error ? error : new Error(String(error)); + } + + if (attempt < maxRetries) { + const jitter = Math.random() * delay * 0.5; + await new Promise((resolve) => setTimeout(resolve, delay + jitter)); + delay = Math.min(delay * 2, 2000); + } + } + + logger.error( + `[updateBalance] Failed to update balance for user ${user} after ${maxRetries} attempts.`, + ); + throw ( + lastError ?? + new Error( + `Failed to update balance for user ${user} after maximum retries due to persistent conflicts.`, + ) + ); + } + + /** Bypasses document middleware; all computed fields must be pre-calculated before calling. */ + async function bulkInsertTransactions(docs: TransactionData[]): Promise { + const Transaction = mongoose.models.Transaction; + if (docs.length) { + await Transaction.insertMany(docs); + } + } + + return { updateBalance, bulkInsertTransactions }; +} + +export type TransactionMethods = ReturnType; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 38f9f22b50..d467d99d21 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -8,6 +8,7 @@ export * from './convo'; export * from './session'; export * from './balance'; export * from './banner'; +export * from './transaction'; export * from './message'; export * from './agent'; export * from './agentApiKey'; diff --git a/packages/data-schemas/src/types/transaction.ts b/packages/data-schemas/src/types/transaction.ts new file mode 100644 index 0000000000..978d7fd62b --- /dev/null +++ b/packages/data-schemas/src/types/transaction.ts @@ -0,0 +1,17 @@ +export interface TransactionData { + user: string; + conversationId: string; + tokenType: string; + model?: string; + context?: string; + valueKey?: string; + rate?: number; + rawAmount?: number; + tokenValue?: number; + inputTokens?: number; + writeTokens?: number; + readTokens?: number; + messageId?: string; + inputTokenCount?: number; + rateDetail?: Record; +} diff --git a/packages/data-schemas/src/utils/transactions.ts b/packages/data-schemas/src/utils/transactions.ts index 09bbb040c1..26f1f77e7e 100644 --- a/packages/data-schemas/src/utils/transactions.ts +++ b/packages/data-schemas/src/utils/transactions.ts @@ -1,5 +1,7 @@ import logger from '~/config/winston'; +export const CANCEL_RATE = 1.15; + /** * Checks if the connected MongoDB deployment supports transactions * This requires a MongoDB replica set configuration From ce1338285c922c0579a8fe6115050809295bdfe7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 1 Mar 2026 12:51:31 -0500 Subject: [PATCH 039/110] =?UTF-8?q?=F0=9F=93=A6=20chore:=20update=20multer?= =?UTF-8?q?=20dependency=20to=20v2.1.0=20(#12000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 52 +++++++++++++++++++---------------------------- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/api/package.json b/api/package.json index 3e9350ac34..301314bbc1 100644 --- a/api/package.json +++ b/api/package.json @@ -87,7 +87,7 @@ "mime": "^3.0.0", "module-alias": "^2.2.3", "mongoose": "^8.12.1", - "multer": "^2.0.2", + "multer": "^2.1.0", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", "nodemailer": "^7.0.11", diff --git a/package-lock.json b/package-lock.json index 2b90bbec3e..740e044b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,7 +102,7 @@ "mime": "^3.0.0", "module-alias": "^2.2.3", "mongoose": "^8.12.1", - "multer": "^2.0.2", + "multer": "^2.1.0", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", "nodemailer": "^7.0.11", @@ -279,36 +279,6 @@ "url": "https://github.com/sponsors/panva" } }, - "api/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "api/node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" - }, - "engines": { - "node": ">= 10.16.0" - } - }, "api/node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -34123,6 +34093,25 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -43608,6 +43597,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, "engines": { "node": ">=0.4" } From 5be90706b0dbed4d084efdf3c2a49e706578567e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 1 Mar 2026 16:44:57 -0500 Subject: [PATCH 040/110] =?UTF-8?q?=E2=9C=82=EF=B8=8F=20fix:=20Unicode-Saf?= =?UTF-8?q?e=20Title=20Truncation=20and=20Shared=20View=20Layout=20Polish?= =?UTF-8?q?=20(#12003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: title sanitization with max length truncation and update ShareView for better text display - Added functionality to `sanitizeTitle` to truncate titles exceeding 200 characters with an ellipsis, ensuring consistent title length. - Updated `ShareView` component to apply a line clamp on the title, improving text display and preventing overflow in the UI. * refactor: Update layout and styling in MessagesView and ShareView components - Removed unnecessary padding in MessagesView to streamline the layout. - Increased bottom padding in the message container for better spacing. - Enhanced ShareView footer positioning and styling for improved visibility. - Adjusted section and div classes in ShareView for better responsiveness and visual consistency. * fix: Correct title fallback and enhance sanitization logic in sanitizeTitle - Updated the fallback title in sanitizeTitle to use DEFAULT_TITLE_FALLBACK instead of a hardcoded string. - Improved title truncation logic to ensure proper handling of maximum length and whitespace, including edge cases for emoji and whitespace-only titles. - Added tests to validate the new sanitization behavior, ensuring consistent and expected results across various input scenarios. --- client/src/components/Share/MessagesView.tsx | 6 +- client/src/components/Share/ShareView.tsx | 16 +++--- packages/api/src/utils/sanitizeTitle.spec.ts | 59 ++++++++++++++++++-- packages/api/src/utils/sanitizeTitle.ts | 35 +++++++----- 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/client/src/components/Share/MessagesView.tsx b/client/src/components/Share/MessagesView.tsx index 9fa4276024..0b2e7b1c2d 100644 --- a/client/src/components/Share/MessagesView.tsx +++ b/client/src/components/Share/MessagesView.tsx @@ -13,7 +13,7 @@ export default function MessagesView({ const localize = useLocalize(); const [currentEditId, setCurrentEditId] = useState(-1); return ( -
+
-
- {(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? ( +
+ {(_messagesTree && _messagesTree.length === 0) || _messagesTree === null ? (
{localize('com_ui_nothing_found')}
diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index 99ab7f35eb..00a0d36398 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -123,14 +123,14 @@ function SharedView() { } const footer = ( -
-