mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-21 15:16:33 +01:00
* fix: Set response format for agent tools in DALLE3, FluxAPI, and StableDiffusion classes - Added logic to set `responseFormat` to 'content_and_artifact' when `isAgent` is true in DALLE3.js, FluxAPI.js, and StableDiffusion.js. * test: Add regression tests for image tool agent mode in imageTools-agent.spec.js - Introduced a new test suite for DALLE3, FluxAPI, and StableDiffusion classes to verify that the invoke() method returns a ToolMessage with base64 in artifact.content, ensuring it is not serialized into content. - Validated that responseFormat is set to 'content_and_artifact' when isAgent is true, and confirmed the correct handling of base64 data in the response. * fix: handle agent error paths and generateFinetunedImage in image tools - StableDiffusion._call() was returning a raw string on API error, bypassing returnValue() and breaking the content_and_artifact contract when isAgent is true - FluxAPI.generateFinetunedImage() had no isAgent branch; it would call processFileURL (unset in agent context) instead of fetching and returning the base64 image as an artifact tuple - Add JSDoc to all three responseFormat assignments clarifying why LangChain requires this property for correct ToolMessage construction * test: expand image tool agent mode regression suite - Add env var save/restore in beforeEach/afterEach to prevent test pollution - Add error path tests for all three tools verifying ToolMessage content and artifact are correctly populated when the upstream API fails - Add generate_finetuned action test for FluxAPI covering the new agent branch in generateFinetunedImage * chore: fix lint errors in FluxAPI and imageTools-agent spec * chore: fix import ordering in imageTools-agent spec
294 lines
10 KiB
JavaScript
294 lines
10 KiB
JavaScript
/**
|
|
* Regression tests for image tool agent mode — verifies that invoke() returns
|
|
* a ToolMessage with base64 in artifact.content rather than serialized into content.
|
|
*
|
|
* Root cause: DALLE3/FluxAPI/StableDiffusion extend LangChain's Tool but did not
|
|
* set responseFormat = 'content_and_artifact'. LangChain's invoke() would then
|
|
* JSON.stringify the entire [content, artifact] tuple into ToolMessage.content,
|
|
* dumping base64 into token counting and causing context exhaustion.
|
|
*/
|
|
|
|
const axios = require('axios');
|
|
const OpenAI = require('openai');
|
|
const undici = require('undici');
|
|
const fetch = require('node-fetch');
|
|
const { ToolMessage } = require('@langchain/core/messages');
|
|
const { ContentTypes } = require('librechat-data-provider');
|
|
const StableDiffusionAPI = require('../StableDiffusion');
|
|
const FluxAPI = require('../FluxAPI');
|
|
const DALLE3 = require('../DALLE3');
|
|
|
|
jest.mock('axios');
|
|
jest.mock('openai');
|
|
jest.mock('node-fetch');
|
|
jest.mock('undici', () => ({
|
|
ProxyAgent: jest.fn(),
|
|
fetch: jest.fn(),
|
|
}));
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
|
}));
|
|
jest.mock('path', () => ({
|
|
resolve: jest.fn(),
|
|
join: jest.fn().mockReturnValue('/mock/path'),
|
|
relative: jest.fn().mockReturnValue('relative/path'),
|
|
extname: jest.fn().mockReturnValue('.png'),
|
|
}));
|
|
jest.mock('fs', () => ({
|
|
existsSync: jest.fn().mockReturnValue(true),
|
|
mkdirSync: jest.fn(),
|
|
promises: { writeFile: jest.fn(), readFile: jest.fn(), unlink: jest.fn() },
|
|
}));
|
|
|
|
const FAKE_BASE64 = 'aGVsbG8=';
|
|
|
|
const makeToolCall = (name, args) => ({
|
|
id: 'call_test_123',
|
|
name,
|
|
args,
|
|
type: 'tool_call',
|
|
});
|
|
|
|
describe('image tools - agent mode ToolMessage format', () => {
|
|
const ENV_KEYS = ['DALLE_API_KEY', 'FLUX_API_KEY', 'SD_WEBUI_URL', 'PROXY'];
|
|
let savedEnv = {};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
for (const key of ENV_KEYS) {
|
|
savedEnv[key] = process.env[key];
|
|
}
|
|
process.env.DALLE_API_KEY = 'test-dalle-key';
|
|
process.env.FLUX_API_KEY = 'test-flux-key';
|
|
process.env.SD_WEBUI_URL = 'http://localhost:7860';
|
|
delete process.env.PROXY;
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const key of ENV_KEYS) {
|
|
if (savedEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = savedEnv[key];
|
|
}
|
|
}
|
|
savedEnv = {};
|
|
});
|
|
|
|
describe('DALLE3', () => {
|
|
beforeEach(() => {
|
|
OpenAI.mockImplementation(() => ({
|
|
images: {
|
|
generate: jest.fn().mockResolvedValue({
|
|
data: [{ url: 'https://example.com/image.png' }],
|
|
}),
|
|
},
|
|
}));
|
|
undici.fetch.mockResolvedValue({
|
|
arrayBuffer: () => Promise.resolve(Buffer.from(FAKE_BASE64, 'base64')),
|
|
});
|
|
});
|
|
|
|
it('sets responseFormat to content_and_artifact when isAgent is true', () => {
|
|
const dalle = new DALLE3({ isAgent: true });
|
|
expect(dalle.responseFormat).toBe('content_and_artifact');
|
|
});
|
|
|
|
it('does not set responseFormat when isAgent is false', () => {
|
|
const dalle = new DALLE3({ isAgent: false, processFileURL: jest.fn() });
|
|
expect(dalle.responseFormat).not.toBe('content_and_artifact');
|
|
});
|
|
|
|
it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
|
|
const dalle = new DALLE3({ isAgent: true });
|
|
const result = await dalle.invoke(
|
|
makeToolCall('dalle', {
|
|
prompt: 'a box',
|
|
quality: 'standard',
|
|
size: '1024x1024',
|
|
style: 'vivid',
|
|
}),
|
|
);
|
|
|
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
|
|
const contentStr =
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
|
expect(contentStr).not.toContain(FAKE_BASE64);
|
|
|
|
expect(result.artifact).toBeDefined();
|
|
const artifactContent = result.artifact?.content;
|
|
expect(Array.isArray(artifactContent)).toBe(true);
|
|
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
|
|
expect(artifactContent[0].image_url.url).toContain('base64');
|
|
});
|
|
|
|
it('invoke() returns ToolMessage with error string in content when API fails', async () => {
|
|
OpenAI.mockImplementation(() => ({
|
|
images: { generate: jest.fn().mockRejectedValue(new Error('API error')) },
|
|
}));
|
|
|
|
const dalle = new DALLE3({ isAgent: true });
|
|
const result = await dalle.invoke(
|
|
makeToolCall('dalle', {
|
|
prompt: 'a box',
|
|
quality: 'standard',
|
|
size: '1024x1024',
|
|
style: 'vivid',
|
|
}),
|
|
);
|
|
|
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
const contentStr =
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
|
expect(contentStr).toContain('Something went wrong');
|
|
expect(result.artifact).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('FluxAPI', () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers();
|
|
axios.post.mockResolvedValue({ data: { id: 'task-123' } });
|
|
axios.get.mockResolvedValue({
|
|
data: { status: 'Ready', result: { sample: 'https://example.com/image.png' } },
|
|
});
|
|
fetch.mockResolvedValue({
|
|
arrayBuffer: () => Promise.resolve(Buffer.from(FAKE_BASE64, 'base64')),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it('sets responseFormat to content_and_artifact when isAgent is true', () => {
|
|
const flux = new FluxAPI({ isAgent: true });
|
|
expect(flux.responseFormat).toBe('content_and_artifact');
|
|
});
|
|
|
|
it('does not set responseFormat when isAgent is false', () => {
|
|
const flux = new FluxAPI({ isAgent: false, processFileURL: jest.fn() });
|
|
expect(flux.responseFormat).not.toBe('content_and_artifact');
|
|
});
|
|
|
|
it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
|
|
const flux = new FluxAPI({ isAgent: true });
|
|
const invokePromise = flux.invoke(
|
|
makeToolCall('flux', { prompt: 'a box', endpoint: '/v1/flux-dev' }),
|
|
);
|
|
await jest.runAllTimersAsync();
|
|
const result = await invokePromise;
|
|
|
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
const contentStr =
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
|
expect(contentStr).not.toContain(FAKE_BASE64);
|
|
|
|
expect(result.artifact).toBeDefined();
|
|
const artifactContent = result.artifact?.content;
|
|
expect(Array.isArray(artifactContent)).toBe(true);
|
|
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
|
|
expect(artifactContent[0].image_url.url).toContain('base64');
|
|
});
|
|
|
|
it('invoke() returns ToolMessage with base64 in artifact for generate_finetuned action', async () => {
|
|
const flux = new FluxAPI({ isAgent: true });
|
|
const invokePromise = flux.invoke(
|
|
makeToolCall('flux', {
|
|
action: 'generate_finetuned',
|
|
prompt: 'a box',
|
|
finetune_id: 'ft-abc123',
|
|
endpoint: '/v1/flux-pro-finetuned',
|
|
}),
|
|
);
|
|
await jest.runAllTimersAsync();
|
|
const result = await invokePromise;
|
|
|
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
const contentStr =
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
|
expect(contentStr).not.toContain(FAKE_BASE64);
|
|
|
|
expect(result.artifact).toBeDefined();
|
|
const artifactContent = result.artifact?.content;
|
|
expect(Array.isArray(artifactContent)).toBe(true);
|
|
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
|
|
expect(artifactContent[0].image_url.url).toContain('base64');
|
|
});
|
|
|
|
it('invoke() returns ToolMessage with error string in content when task submission fails', async () => {
|
|
axios.post.mockRejectedValue(new Error('Network error'));
|
|
|
|
const flux = new FluxAPI({ isAgent: true });
|
|
const invokePromise = flux.invoke(
|
|
makeToolCall('flux', { prompt: 'a box', endpoint: '/v1/flux-dev' }),
|
|
);
|
|
await jest.runAllTimersAsync();
|
|
const result = await invokePromise;
|
|
|
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
const contentStr =
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
|
expect(contentStr).toContain('Something went wrong');
|
|
expect(result.artifact).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('StableDiffusion', () => {
|
|
beforeEach(() => {
|
|
axios.post.mockResolvedValue({
|
|
data: {
|
|
images: [FAKE_BASE64],
|
|
info: JSON.stringify({ height: 1024, width: 1024, seed: 42, infotexts: [] }),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('sets responseFormat to content_and_artifact when isAgent is true', () => {
|
|
const sd = new StableDiffusionAPI({ isAgent: true, override: true });
|
|
expect(sd.responseFormat).toBe('content_and_artifact');
|
|
});
|
|
|
|
it('does not set responseFormat when isAgent is false', () => {
|
|
const sd = new StableDiffusionAPI({
|
|
isAgent: false,
|
|
override: true,
|
|
uploadImageBuffer: jest.fn(),
|
|
});
|
|
expect(sd.responseFormat).not.toBe('content_and_artifact');
|
|
});
|
|
|
|
it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
|
|
const sd = new StableDiffusionAPI({ isAgent: true, override: true, userId: 'user-1' });
|
|
const result = await sd.invoke(
|
|
makeToolCall('stable-diffusion', { prompt: 'a box', negative_prompt: '' }),
|
|
);
|
|
|
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
const contentStr =
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
|
expect(contentStr).not.toContain(FAKE_BASE64);
|
|
|
|
expect(result.artifact).toBeDefined();
|
|
const artifactContent = result.artifact?.content;
|
|
expect(Array.isArray(artifactContent)).toBe(true);
|
|
expect(artifactContent[0].type).toBe(ContentTypes.IMAGE_URL);
|
|
expect(artifactContent[0].image_url.url).toContain('base64');
|
|
});
|
|
|
|
it('invoke() returns ToolMessage with error string in content when API fails', async () => {
|
|
axios.post.mockRejectedValue(new Error('Connection refused'));
|
|
|
|
const sd = new StableDiffusionAPI({ isAgent: true, override: true, userId: 'user-1' });
|
|
const result = await sd.invoke(
|
|
makeToolCall('stable-diffusion', { prompt: 'a box', negative_prompt: '' }),
|
|
);
|
|
|
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
const contentStr =
|
|
typeof result.content === 'string' ? result.content : JSON.stringify(result.content);
|
|
expect(contentStr).toContain('Error making API request');
|
|
});
|
|
});
|
|
});
|