diff --git a/api/package.json b/api/package.json index f31ddfe5ca..f26022d8d3 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.37", + "@librechat/agents": "^3.1.38", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index b7e7f56552..3f0bfcfc87 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -18,9 +18,9 @@ const { getEndpointFileConfig, } = require('librechat-data-provider'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); +const { createFile, getFiles, updateFile, claimCodeFile } = require('~/models'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); -const { createFile, getFiles, updateFile } = require('~/models'); const { determineFileType } = require('~/server/utils'); /** @@ -56,50 +56,6 @@ const createDownloadFallback = ({ }; }; -/** - * Find an existing code-generated file by filename in the conversation. - * Used to update existing files instead of creating duplicates. - * - * ## Deduplication Strategy - * - * Files are deduplicated by `(conversationId, filename)` - NOT including `messageId`. - * This is an intentional design decision to handle iterative code development patterns: - * - * **Rationale:** - * - When users iteratively refine code (e.g., "regenerate that chart with red bars"), - * the same logical file (e.g., "chart.png") is produced multiple times - * - Without deduplication, each iteration would create a new file, leading to storage bloat - * - The latest version is what matters for re-upload to the code environment - * - * **Implications:** - * - Different messages producing files with the same name will update the same file record - * - The `messageId` field tracks which message last updated the file - * - The `usage` counter tracks how many times the file has been generated - * - * **Future Considerations:** - * - If file versioning is needed, consider adding a `versions` array or separate version collection - * - The current approach prioritizes storage efficiency over history preservation - * - * @param {string} filename - The filename to search for. - * @param {string} conversationId - The conversation ID. - * @returns {Promise} The existing file or null. - */ -const findExistingCodeFile = async (filename, conversationId) => { - if (!filename || !conversationId) { - return null; - } - const files = await getFiles( - { - filename, - conversationId, - context: FileContext.execute_code, - }, - { createdAt: -1 }, - { text: 0 }, - ); - return files?.[0] ?? null; -}; - /** * Process code execution output files - downloads and saves both images and non-image files. * All files are saved to local storage with fileIdentifier metadata for code env re-upload. @@ -170,12 +126,19 @@ const processCodeOutput = async ({ const fileIdentifier = `${session_id}/${id}`; /** - * Check for existing file with same filename in this conversation. - * If found, we'll update it instead of creating a duplicate. + * Atomically claim a file_id for this (filename, conversationId, context) tuple. + * Uses $setOnInsert so concurrent calls for the same filename converge on + * a single record instead of creating duplicates (TOCTOU race fix). */ - const existingFile = await findExistingCodeFile(name, conversationId); - const file_id = existingFile?.file_id ?? v4(); - const isUpdate = !!existingFile; + const newFileId = v4(); + const claimed = await claimCodeFile({ + filename: name, + conversationId, + file_id: newFileId, + user: req.user.id, + }); + const file_id = claimed.file_id; + const isUpdate = file_id !== newFileId; if (isUpdate) { logger.debug( @@ -184,27 +147,29 @@ const processCodeOutput = async ({ } if (isImage) { + const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1; const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`); + const filepath = usage > 1 ? `${_file.filepath}?v=${Date.now()}` : _file.filepath; const file = { ..._file, + filepath, file_id, messageId, - usage: isUpdate ? (existingFile.usage ?? 0) + 1 : 1, + usage, filename: name, conversationId, user: req.user.id, type: `image/${appConfig.imageOutputType}`, - createdAt: isUpdate ? existingFile.createdAt : formattedDate, + createdAt: isUpdate ? claimed.createdAt : formattedDate, updatedAt: formattedDate, source: appConfig.fileStrategy, context: FileContext.execute_code, metadata: { fileIdentifier }, }; - createFile(file, true); + await createFile(file, true); return Object.assign(file, { messageId, toolCallId }); } - // For non-image files, save to configured storage strategy const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy); if (!saveBuffer) { logger.warn( @@ -221,7 +186,6 @@ const processCodeOutput = async ({ }); } - // Determine MIME type from buffer or extension const detectedType = await determineFileType(buffer, true); const mimeType = detectedType?.mime || inferMimeType(name, '') || 'application/octet-stream'; @@ -258,11 +222,11 @@ const processCodeOutput = async ({ metadata: { fileIdentifier }, source: appConfig.fileStrategy, context: FileContext.execute_code, - usage: isUpdate ? (existingFile.usage ?? 0) + 1 : 1, - createdAt: isUpdate ? existingFile.createdAt : formattedDate, + usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1, + createdAt: isUpdate ? claimed.createdAt : formattedDate, }; - createFile(file, true); + await createFile(file, true); return Object.assign(file, { messageId, toolCallId }); } catch (error) { logAxiosError({ diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 7e15888876..f01a623f90 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -61,10 +61,12 @@ jest.mock('@librechat/api', () => ({ })); // Mock models +const mockClaimCodeFile = jest.fn(); jest.mock('~/models', () => ({ - createFile: jest.fn(), + createFile: jest.fn().mockResolvedValue({}), getFiles: jest.fn(), updateFile: jest.fn(), + claimCodeFile: (...args) => mockClaimCodeFile(...args), })); // Mock permissions (must be before process.js import) @@ -119,7 +121,11 @@ describe('Code Process', () => { beforeEach(() => { jest.clearAllMocks(); - // Default mock implementations + // Default mock: atomic claim returns a new file record (no existing file) + mockClaimCodeFile.mockResolvedValue({ + file_id: 'mock-uuid-1234', + user: 'user-123', + }); getFiles.mockResolvedValue(null); createFile.mockResolvedValue({}); getStrategyFunctions.mockReturnValue({ @@ -128,67 +134,46 @@ describe('Code Process', () => { determineFileType.mockResolvedValue({ mime: 'text/plain' }); }); - describe('findExistingCodeFile (via processCodeOutput)', () => { - it('should find existing file by filename and conversationId', async () => { - const existingFile = { + describe('atomic file claim (via processCodeOutput)', () => { + it('should reuse file_id from existing record via atomic claim', async () => { + mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-file-id', filename: 'test-file.txt', usage: 2, createdAt: '2024-01-01T00:00:00.000Z', - }; - getFiles.mockResolvedValue([existingFile]); + }); const smallBuffer = Buffer.alloc(100); axios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); - // Verify getFiles was called with correct deduplication query - expect(getFiles).toHaveBeenCalledWith( - { - filename: 'test-file.txt', - conversationId: 'conv-123', - context: FileContext.execute_code, - }, - { createdAt: -1 }, - { text: 0 }, - ); + expect(mockClaimCodeFile).toHaveBeenCalledWith({ + filename: 'test-file.txt', + conversationId: 'conv-123', + file_id: 'mock-uuid-1234', + user: 'user-123', + }); - // Verify the existing file_id was reused expect(result.file_id).toBe('existing-file-id'); - // Verify usage was incremented expect(result.usage).toBe(3); - // Verify original createdAt was preserved expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z'); }); it('should create new file when no existing file found', async () => { - getFiles.mockResolvedValue(null); + mockClaimCodeFile.mockResolvedValue({ + file_id: 'mock-uuid-1234', + user: 'user-123', + }); const smallBuffer = Buffer.alloc(100); axios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); - // Should use the mocked uuid expect(result.file_id).toBe('mock-uuid-1234'); - // Should have usage of 1 for new file expect(result.usage).toBe(1); }); - - it('should return null for invalid inputs (empty filename)', async () => { - const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); - - // The function handles this internally - with empty name - // findExistingCodeFile returns null early for empty filename (guard clause) - const result = await processCodeOutput({ ...baseParams, name: '' }); - - // getFiles should NOT be called due to early return in findExistingCodeFile - expect(getFiles).not.toHaveBeenCalled(); - // A new file_id should be generated since no existing file was found - expect(result.file_id).toBe('mock-uuid-1234'); - }); }); describe('processCodeOutput', () => { @@ -203,7 +188,6 @@ describe('Code Process', () => { bytes: 400, }; convertImage.mockResolvedValue(convertedFile); - getFiles.mockResolvedValue(null); const result = await processCodeOutput(imageParams); @@ -218,23 +202,29 @@ describe('Code Process', () => { expect(result.filename).toBe('chart.png'); }); - it('should update existing image file and increment usage', async () => { + it('should update existing image file with cache-busted filepath', async () => { const imageParams = { ...baseParams, name: 'chart.png' }; - const existingFile = { + mockClaimCodeFile.mockResolvedValue({ file_id: 'existing-img-id', usage: 1, createdAt: '2024-01-01T00:00:00.000Z', - }; - getFiles.mockResolvedValue([existingFile]); + }); const imageBuffer = Buffer.alloc(500); axios.mockResolvedValue({ data: imageBuffer }); - convertImage.mockResolvedValue({ filepath: '/uploads/img.webp' }); + convertImage.mockResolvedValue({ filepath: '/images/user-123/existing-img-id.webp' }); const result = await processCodeOutput(imageParams); + expect(convertImage).toHaveBeenCalledWith( + mockReq, + imageBuffer, + 'high', + 'existing-img-id.png', + ); expect(result.file_id).toBe('existing-img-id'); expect(result.usage).toBe(2); + expect(result.filepath).toMatch(/^\/images\/user-123\/existing-img-id\.webp\?v=\d+$/); expect(logger.debug).toHaveBeenCalledWith( expect.stringContaining('Updating existing file'), ); @@ -335,7 +325,6 @@ describe('Code Process', () => { describe('usage counter increment', () => { it('should set usage to 1 for new files', async () => { - getFiles.mockResolvedValue(null); const smallBuffer = Buffer.alloc(100); axios.mockResolvedValue({ data: smallBuffer }); @@ -345,8 +334,11 @@ describe('Code Process', () => { }); it('should increment usage for existing files', async () => { - const existingFile = { file_id: 'existing-id', usage: 5, createdAt: '2024-01-01' }; - getFiles.mockResolvedValue([existingFile]); + mockClaimCodeFile.mockResolvedValue({ + file_id: 'existing-id', + usage: 5, + createdAt: '2024-01-01', + }); const smallBuffer = Buffer.alloc(100); axios.mockResolvedValue({ data: smallBuffer }); @@ -356,14 +348,15 @@ describe('Code Process', () => { }); it('should handle existing file with undefined usage', async () => { - const existingFile = { file_id: 'existing-id', createdAt: '2024-01-01' }; - getFiles.mockResolvedValue([existingFile]); + mockClaimCodeFile.mockResolvedValue({ + file_id: 'existing-id', + createdAt: '2024-01-01', + }); const smallBuffer = Buffer.alloc(100); axios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); - // (undefined ?? 0) + 1 = 1 expect(result.usage).toBe(1); }); }); diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 16a94fb1cd..c3b48cb107 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -111,7 +111,7 @@ export default function useStepHandler({ const updatedContent = [...(message.content || [])] as Array< Partial | undefined >; - if (!updatedContent[index]) { + if (!updatedContent[index] && contentType !== ContentTypes.TOOL_CALL) { updatedContent[index] = { type: contentPart.type as AllContentTypes }; } diff --git a/package-lock.json b/package-lock.json index ba61959041..c89cf1a9dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.37", + "@librechat/agents": "^3.1.38", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11207,9 +11207,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.37", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.37.tgz", - "integrity": "sha512-179dSddx8uQcJFLu5LMhZQckIQHoV3kmkJj+py6uewGNlf9gsmG6M8JYi6i65Y4X73u05KRKUtO9U+n3Z85dOw==", + "version": "3.1.38", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.38.tgz", + "integrity": "sha512-s8WkS2bXkTWsPGKsQKlUFWUVijMAIQvpv4LZLbNj/rZui0R+82vY/CVnkK3jeUueNMo6GS7GG9Fj01FZmhXslw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -42102,7 +42102,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.37", + "@librechat/agents": "^3.1.38", "@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 18bb9ec5a4..0dd1bfc005 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.37", + "@librechat/agents": "^3.1.38", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.26.0", "@smithy/node-http-handler": "^4.4.5", diff --git a/packages/api/src/endpoints/bedrock/initialize.spec.ts b/packages/api/src/endpoints/bedrock/initialize.spec.ts index 285cd58fee..2b83c55937 100644 --- a/packages/api/src/endpoints/bedrock/initialize.spec.ts +++ b/packages/api/src/endpoints/bedrock/initialize.spec.ts @@ -615,7 +615,7 @@ describe('initializeBedrock', () => { }); describe('Opus 4.6 Adaptive Thinking', () => { - it('should configure adaptive thinking with default maxTokens for Opus 4.6', async () => { + it('should configure adaptive thinking with no default maxTokens for Opus 4.6', async () => { const params = createMockParams({ model_parameters: { model: 'anthropic.claude-opus-4-6-v1', @@ -626,7 +626,7 @@ describe('initializeBedrock', () => { const amrf = result.llmConfig.additionalModelRequestFields as Record; expect(amrf.thinking).toEqual({ type: 'adaptive' }); - expect(result.llmConfig.maxTokens).toBe(16000); + expect(result.llmConfig.maxTokens).toBeUndefined(); expect(amrf.anthropic_beta).toEqual( expect.arrayContaining(['output-128k-2025-02-19', 'context-1m-2025-08-07']), ); diff --git a/packages/data-provider/specs/bedrock.spec.ts b/packages/data-provider/specs/bedrock.spec.ts index f331055107..ead41b47fa 100644 --- a/packages/data-provider/specs/bedrock.spec.ts +++ b/packages/data-provider/specs/bedrock.spec.ts @@ -455,14 +455,14 @@ describe('bedrockInputParser', () => { }); describe('bedrockOutputParser with configureThinking', () => { - test('should preserve adaptive thinking config and set default maxTokens', () => { + test('should preserve adaptive thinking config without setting default maxTokens', () => { const parsed = bedrockInputParser.parse({ model: 'anthropic.claude-opus-4-6-v1', }) as Record; const output = bedrockOutputParser(parsed as Record); const amrf = output.additionalModelRequestFields as Record; expect(amrf.thinking).toEqual({ type: 'adaptive' }); - expect(output.maxTokens).toBe(16000); + expect(output.maxTokens).toBeUndefined(); expect(output.maxOutputTokens).toBeUndefined(); }); @@ -475,6 +475,16 @@ describe('bedrockInputParser', () => { expect(output.maxTokens).toBe(32000); }); + test('should use maxOutputTokens as maxTokens for adaptive model when maxTokens is not set', () => { + const parsed = bedrockInputParser.parse({ + model: 'anthropic.claude-opus-4-6-v1', + maxOutputTokens: 24000, + }) as Record; + const output = bedrockOutputParser(parsed as Record); + expect(output.maxTokens).toBe(24000); + expect(output.maxOutputTokens).toBeUndefined(); + }); + test('should convert thinking=true to enabled config for non-adaptive models', () => { const parsed = bedrockInputParser.parse({ model: 'anthropic.claude-sonnet-4-5-20250929-v1:0', @@ -496,14 +506,14 @@ describe('bedrockInputParser', () => { expect(amrf.output_config).toEqual({ effort: 'low' }); }); - test('should use adaptive default maxTokens (16000) over maxOutputTokens for adaptive models', () => { + test('should not set maxTokens for adaptive models when neither maxTokens nor maxOutputTokens are provided', () => { const parsed = bedrockInputParser.parse({ model: 'anthropic.claude-opus-4-6-v1', }) as Record; parsed.maxOutputTokens = undefined; (parsed as Record).maxTokens = undefined; const output = bedrockOutputParser(parsed as Record); - expect(output.maxTokens).toBe(16000); + expect(output.maxTokens).toBeUndefined(); }); test('should use enabled default maxTokens (8192) for non-adaptive thinking models', () => { diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts index 8f2d41dc38..a037245fc0 100644 --- a/packages/data-provider/src/bedrock.ts +++ b/packages/data-provider/src/bedrock.ts @@ -2,7 +2,6 @@ import { z } from 'zod'; import * as s from './schemas'; const DEFAULT_ENABLED_MAX_TOKENS = 8192; -const DEFAULT_ADAPTIVE_MAX_TOKENS = 16000; const DEFAULT_THINKING_BUDGET = 2000; type ThinkingConfig = { type: 'enabled'; budget_tokens: number } | { type: 'adaptive' }; @@ -340,8 +339,9 @@ function configureThinking(data: AnthropicInput): AnthropicInput { thinking != null && (thinking as { type: string }).type === 'adaptive' ) { - updatedData.maxTokens = - updatedData.maxTokens ?? updatedData.maxOutputTokens ?? DEFAULT_ADAPTIVE_MAX_TOKENS; + if (updatedData.maxTokens == null && updatedData.maxOutputTokens != null) { + updatedData.maxTokens = updatedData.maxOutputTokens; + } delete updatedData.maxOutputTokens; delete updatedData.additionalModelRequestFields!.thinkingBudget; } diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index b2c24a47a0..98254390b9 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -60,6 +60,7 @@ export const fullMimeTypesList = [ 'application/vnd.coffeescript', 'application/xml', 'application/zip', + 'application/x-parquet', 'image/svg', 'image/svg+xml', // Video formats @@ -114,6 +115,7 @@ export const codeInterpreterMimeTypesList = [ 'application/typescript', 'application/xml', 'application/zip', + 'application/x-parquet', ...excelFileTypes, ]; @@ -144,7 +146,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|vnd\.coffeescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/; + /^(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))$/; export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/; @@ -202,6 +204,7 @@ export const codeTypeMapping: { [key: string]: string } = { log: 'text/plain', // .log - Log file csv: 'text/csv', // .csv - Comma-separated values tsv: 'text/tab-separated-values', // .tsv - Tab-separated values + parquet: 'application/x-parquet', // .parquet - Apache Parquet columnar storage json: 'application/json', // .json - JSON file xml: 'application/xml', // .xml - XML file html: 'text/html', // .html - HTML file diff --git a/packages/data-schemas/src/methods/file.ts b/packages/data-schemas/src/methods/file.ts index 751f23f5c0..3d7db88c3f 100644 --- a/packages/data-schemas/src/methods/file.ts +++ b/packages/data-schemas/src/methods/file.ts @@ -171,6 +171,35 @@ export function createFileMethods(mongoose: typeof import('mongoose')) { } } + /** + * Atomically claims a file_id for a code-execution output by compound key. + * Uses $setOnInsert so concurrent calls for the same (filename, conversationId) + * converge on a single record instead of creating duplicates. + */ + async function claimCodeFile(data: { + filename: string; + conversationId: string; + file_id: string; + user: string; + }): Promise { + const File = mongoose.models.File as Model; + const result = await File.findOneAndUpdate( + { + filename: data.filename, + conversationId: data.conversationId, + context: FileContext.execute_code, + }, + { $setOnInsert: { file_id: data.file_id, user: data.user } }, + { upsert: true, new: true }, + ).lean(); + if (!result) { + throw new Error( + `[claimCodeFile] Failed to claim file "${data.filename}" for conversation ${data.conversationId}`, + ); + } + return result as IMongoFile; + } + /** * Creates a new file with a TTL of 1 hour. * @param data - The file data to be created, must contain file_id @@ -344,6 +373,7 @@ export function createFileMethods(mongoose: typeof import('mongoose')) { getToolFilesByIds, getCodeGeneratedFiles, getUserCodeFiles, + claimCodeFile, createFile, updateFile, updateFileUsage, diff --git a/packages/data-schemas/src/schema/file.ts b/packages/data-schemas/src/schema/file.ts index d39672f1ea..c5e3b3c4e3 100644 --- a/packages/data-schemas/src/schema/file.ts +++ b/packages/data-schemas/src/schema/file.ts @@ -1,5 +1,5 @@ import mongoose, { Schema } from 'mongoose'; -import { FileSources } from 'librechat-data-provider'; +import { FileContext, FileSources } from 'librechat-data-provider'; import type { IMongoFile } from '~/types'; const file: Schema = new Schema( @@ -85,5 +85,9 @@ const file: Schema = new Schema( ); file.index({ createdAt: 1, updatedAt: 1 }); +file.index( + { filename: 1, conversationId: 1, context: 1 }, + { unique: true, partialFilterExpression: { context: FileContext.execute_code } }, +); export default file;