mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-19 08:58:09 +01:00
* 🐛 fix: Normalize `output_text` blocks in Responses API input conversion
Treat `output_text` content blocks the same as `input_text` when
converting Responses API input to internal message format. Previously,
assistant messages containing `output_text` blocks fell through to the
default handler, producing `{ type: 'output_text' }` without a `text`
field, which caused downstream provider adapters (e.g. Bedrock) to fail
with "Unsupported content block type: output_text".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: Remove ChatModelStreamHandler from OpenAI and Responses controllers
Eliminated the ChatModelStreamHandler from both OpenAIChatCompletionController and createResponse functions to streamline event handling. This change simplifies the code by relying on existing handlers for message deltas and reasoning deltas, enhancing maintainability and reducing complexity in the agent's event processing logic.
* feat: Enhance input conversion in Responses API
Updated the `convertInputToMessages` function to handle additional content types, including `input_file` and `refusal` blocks, ensuring they are converted to appropriate message formats. Implemented null filtering for content arrays and default values for missing fields, improving robustness. Added comprehensive unit tests to validate these changes and ensure correct behavior across various input scenarios.
* fix: Forward upstream provider status codes in error responses
Updated error handling in OpenAIChatCompletionController and createResponse functions to forward upstream provider status codes (e.g., Anthropic 400s) instead of masking them as 500. This change improves error reporting by providing more accurate status codes and error types, enhancing the clarity of error responses for clients.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
8.8 KiB
JavaScript
329 lines
8.8 KiB
JavaScript
const { Tools } = require('librechat-data-provider');
|
|
|
|
// Mock all dependencies before requiring the module
|
|
jest.mock('nanoid', () => ({
|
|
nanoid: jest.fn(() => 'mock-id'),
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
sendEvent: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
error: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
jest.mock('@librechat/agents', () => ({
|
|
...jest.requireActual('@librechat/agents'),
|
|
getMessageId: jest.fn(),
|
|
ToolEndHandler: jest.fn(),
|
|
handleToolCalls: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Citations', () => ({
|
|
processFileCitations: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
|
processCodeOutput: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
|
loadAuthValues: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/process', () => ({
|
|
saveBase64Image: jest.fn(),
|
|
}));
|
|
|
|
describe('createToolEndCallback', () => {
|
|
let req, res, artifactPromises, createToolEndCallback;
|
|
let logger;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Get the mocked logger
|
|
logger = require('@librechat/data-schemas').logger;
|
|
|
|
// Now require the module after all mocks are set up
|
|
const callbacks = require('../callbacks');
|
|
createToolEndCallback = callbacks.createToolEndCallback;
|
|
|
|
req = {
|
|
user: { id: 'user123' },
|
|
};
|
|
res = {
|
|
headersSent: false,
|
|
write: jest.fn(),
|
|
};
|
|
artifactPromises = [];
|
|
});
|
|
|
|
describe('ui_resources artifact handling', () => {
|
|
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [
|
|
{ type: 'button', label: 'Click me' },
|
|
{ type: 'input', placeholder: 'Enter text' },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
|
|
// Wait for all promises to resolve
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
// When headers are not sent, it returns attachment without writing
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
|
|
const attachment = results[0];
|
|
expect(attachment).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [
|
|
{ type: 'button', label: 'Click me' },
|
|
{ type: 'input', placeholder: 'Enter text' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should write to response when headers are already sent', async () => {
|
|
res.headersSent = true;
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'carousel', items: [] }],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(res.write).toHaveBeenCalled();
|
|
expect(results[0]).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [{ type: 'carousel', items: [] }],
|
|
});
|
|
});
|
|
|
|
it('should handle errors when processing ui_resources', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
// Mock res.write to throw an error
|
|
res.headersSent = true;
|
|
res.write.mockImplementation(() => {
|
|
throw new Error('Write failed');
|
|
});
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'test' }],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
'Error processing artifact content:',
|
|
expect.any(Error),
|
|
);
|
|
expect(results[0]).toBeNull();
|
|
});
|
|
|
|
it('should handle multiple artifacts including ui_resources', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [{ type: 'chart', data: [] }],
|
|
},
|
|
[Tools.web_search]: {
|
|
results: ['result1', 'result2'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
// Both ui_resources and web_search should be processed
|
|
expect(artifactPromises).toHaveLength(2);
|
|
expect(results).toHaveLength(2);
|
|
|
|
// Check ui_resources attachment
|
|
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
|
expect(uiResourceAttachment).toBeTruthy();
|
|
expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
|
|
|
|
// Check web_search attachment
|
|
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
|
expect(webSearchAttachment).toBeTruthy();
|
|
expect(webSearchAttachment[Tools.web_search]).toEqual({
|
|
results: ['result1', 'result2'],
|
|
});
|
|
});
|
|
|
|
it('should not process artifacts when output has no artifacts', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
content: 'Some regular content',
|
|
// No artifact property
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty ui_resources data object', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(results[0]).toEqual({
|
|
type: Tools.ui_resources,
|
|
messageId: 'run456',
|
|
toolCallId: 'tool123',
|
|
conversationId: 'thread789',
|
|
[Tools.ui_resources]: [],
|
|
});
|
|
});
|
|
|
|
it('should handle ui_resources with complex nested data', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const complexData = {
|
|
0: {
|
|
type: 'form',
|
|
fields: [
|
|
{ name: 'field1', type: 'text', required: true },
|
|
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
|
|
],
|
|
nested: {
|
|
deep: {
|
|
value: 123,
|
|
array: [1, 2, 3],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const output = {
|
|
tool_call_id: 'tool123',
|
|
artifact: {
|
|
[Tools.ui_resources]: {
|
|
data: complexData,
|
|
},
|
|
},
|
|
};
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output }, metadata);
|
|
const results = await Promise.all(artifactPromises);
|
|
|
|
expect(results[0][Tools.ui_resources]).toEqual(complexData);
|
|
});
|
|
|
|
it('should handle when output is undefined', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback({ output: undefined }, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle when data parameter is undefined', async () => {
|
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
|
|
|
const metadata = {
|
|
run_id: 'run456',
|
|
thread_id: 'thread789',
|
|
};
|
|
|
|
await toolEndCallback(undefined, metadata);
|
|
|
|
expect(artifactPromises).toHaveLength(0);
|
|
expect(res.write).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|